diff --git a/chat/src/agent-tool.tsx b/chat/src/agent-tool.tsx new file mode 100644 index 00000000..d57a9a09 --- /dev/null +++ b/chat/src/agent-tool.tsx @@ -0,0 +1,777 @@ +// The "Agent" context-tool — the watercooler's multi-chat sibling. Where the +// watercooler stores ONE chat url at focusedDoc['@patchwork'].chitchat, this +// keeps a LIST of chats with a tab bar on top to switch between them, create +// new ones, and rename them. A tab's name IS the chat doc's title (rename +// writes doc.title, same as the datatype's setTitle), and each tab's +// conversation is the ordinary ChatRoot in context mode — same chat UI, same +// computer, just wrapped. +// +// STORAGE — everything branch-independent lives OFF the focused doc, because +// the focused doc forks per draft: +// - The chat list lives in a separate `agent-chats` index doc; the focused +// doc carries only a write-once pointer (`@patchwork.agentChats`), read +// and written through the RAW `window.repo` so it always hits the real +// doc whatever branch is checked out. +// - Chat docs (and their message docs) are the `agent-chat` datatype. Both +// types are on the drafts skip-list, so the overlay never forks them — +// tabs and conversations are identical on every branch. +// +// DRAFTS — the computer's edits land on a per-chat draft (reusing the drafts +// plugin's machinery, see lib/agent-drafts.ts): when a run starts, the chat's +// draft is created (first run) or reused and checked out, so the overlay +// routes the run's edits into it; when a run that edited docs finishes, a +// draft-review embed (accept/reject) is posted into the conversation. +// Selecting a tab checks out the draft its agent is working on. +// +// Every tab's ChatRoot stays MOUNTED while another tab is showing (inactive +// panes are hidden with CSS, not unmounted), so each chat's computer keeps +// running in the background: in-flight responses keep streaming and its host +// keeps listening for new mentions. +import {render} from "solid-js/web" +import { + createSignal, + createEffect, + createMemo, + onCleanup, + Show, + For, +} from "solid-js" +import {isValidAutomergeUrl} from "@automerge/automerge-repo/slim" +import type {Repo, DocHandle, AutomergeUrl} from "@automerge/automerge-repo/slim" +import {ChatRoot} from "./components/ChatRoot" +import {CHAT_VERSION} from "./version" +import {selectedDocUrl, subscribe, toolStorageUrl} from "./lib/selected-doc" +import {setRepo} from "./lib/repo" +import {generateId} from "./lib/helpers" +import {copyChatTranscript} from "./lib/transcript" +import {SVG_ICONS} from "./lib/svg-icons" +import { + DEFAULT_CONTEXT_CHAT_PLUGINS, + ensureDefaultPlugins, + rememberPluginsAsDefault, + createContextChat, + type ToolStorageDoc, +} from "./lib/context-chat" +import { + AGENT_CHAT_TYPE, + rawRepo, + resolveAgentChatsIndex, + createAgentDraft, + rejectAgentDraft, + checkedOutDraftHandle, + checkoutDraft, + checkoutAgentDraft, + resolveInDraft, + type AgentChatsIndexDoc, + type CheckedOutDraft, + type DraftDoc, +} from "./lib/agent-drafts" +import type {ChatDoc} from "./types" + +// The slice of the shared `patchwork:focus` doc this tool reads — +// structurally matches the focus provider's schema without a build-time +// dependency on it. +type FocusDocShape = { + openAgentChat?: {url: AutomergeUrl; at: number} +} + +/** patchwork:component render: `(element) => cleanup`. */ +export function AgentContextComponent(element: HTMLElement) { + const repo: Repo = (element as any).repo || (window as any).repo + setRepo(repo) + + if (getComputedStyle(element).position === "static") { + element.style.position = "relative" + } + + const dispose = render( + () => , + element + ) + return () => dispose() +} + +function AgentHost(props: {element: HTMLElement; repo: Repo}) { + const targetUrl = selectedDocUrl(props.element) + + // The ephemeral checked-out doc (the drafts machinery's selection) — one + // subscription shared by every pane and review embed under this tool. + const checkedOut = checkedOutDraftHandle(props.element) + + // Resolve the focused doc's agent-chats index (creating it on first use) + // through the RAW repo — the pointer on the focused doc and the index doc + // itself are branch-independent. + const [indexHandle, setIndexHandle] = + createSignal | null>(null) + const [activeUrl, setActiveUrl] = createSignal(null) + createEffect(() => { + const url = targetUrl() + setIndexHandle(null) + setActiveUrl(null) + if (!url) return + let stale = false + resolveAgentChatsIndex(rawRepo(), url) + .then((h) => { + if (!stale && targetUrl() === url) setIndexHandle(h) + }) + .catch((e) => console.warn("[agent] chat index:", e)) + onCleanup(() => { + stale = true + }) + }) + + // Mirror the index doc into a signal so the tab list stays live (local and + // remote edits alike). + const [indexDoc, setIndexDoc] = createSignal( + undefined + ) + createEffect(() => { + const h = indexHandle() + if (!h) { + setIndexDoc(undefined) + return + } + const update = () => setIndexDoc(() => h.doc()) + update() + h.on("change", update) + onCleanup(() => h.off("change", update)) + }) + + const chats = createMemo(() => { + const list = indexDoc()?.chats + return Array.isArray(list) ? [...list] : [] + }) + + // Consume `openAgentChat` one-shots from the shared focus doc: another + // view (the drafts timeline's "via agent" badge) asks for a specific chat + // tab. Same convention as the comments panel's `openThread` — act, then + // delete the request. Waits for the chat index (a request racing the + // index resolve isn't dropped); a request for a chat not in this doc's + // list (stale or foreign) is cleared without selecting anything. + const focusDocUrl = subscribe( + props.element, + {type: "patchwork:focus"}, + undefined + ) + const [focusHandle, setFocusHandle] = + createSignal | null>(null) + createEffect(() => { + const url = focusDocUrl() + if (!url) return + let stale = false + props.repo + .find(url) + .then((h) => { + if (!stale) setFocusHandle(h as DocHandle) + }) + .catch((e) => console.warn("[agent] focus doc:", e)) + onCleanup(() => { + stale = true + }) + }) + const [focusDoc, setFocusDoc] = createSignal( + undefined + ) + createEffect(() => { + const h = focusHandle() + if (!h) return + const update = () => setFocusDoc(() => h.doc()) + update() + h.on("change", update) + onCleanup(() => h.off("change", update)) + }) + createEffect(() => { + const request = focusDoc()?.openAgentChat + if (!request) return + if (!indexDoc()) return + const h = focusHandle() + if (!h) return + if (chats().includes(request.url)) setActiveUrl(request.url) + h.change((d) => { + delete d.openAgentChat + }) + }) + + // Default plugin set for NEW chats — shared with the watercooler via the + // same `chitchat` tool-storage doc, so both remember the same last-used set. + const storageUrl = toolStorageUrl(props.element, "chitchat") + const [storageHandle, setStorageHandle] = + createSignal | null>(null) + createEffect(() => { + const url = storageUrl() + if (!url) return + props.repo + .find(url) + .then((h) => { + const storage = h as DocHandle + ensureDefaultPlugins(storage) + setStorageHandle(storage) + }) + .catch((e) => console.warn("[agent] tool-storage:", e)) + }) + const defaultPlugins = () => + storageHandle()?.doc()?.defaultPlugins ?? DEFAULT_CONTEXT_CHAT_PLUGINS + + /** Create a chat, link it into the index doc, and switch to it. */ + const addChat = async () => { + const index = indexHandle() + const url = targetUrl() + if (!index || !url) return + try { + const chat = await createContextChat( + props.repo, + "Chat " + (chats().length + 1), + defaultPlugins(), + AGENT_CHAT_TYPE + ) + index.change((d) => { + if (!Array.isArray(d.chats)) d.chats = [] + d.chats.push(chat.url) + }) + // Only steal the active tab if the user is still on the same doc. + if (targetUrl() === url) setActiveUrl(chat.url) + } catch (e) { + console.warn("[agent] create chat:", e) + } + } + + // A doc focused for the first time gets its first chat automatically (like + // the watercooler). Guarded per-url so a slow create doesn't loop. + let autoCreatedFor: string | null = null + createEffect(() => { + const index = indexHandle() + const url = targetUrl() + if (!index || !url) return + if (chats().length > 0) return + if (autoCreatedFor === url) return + autoCreatedFor = url + addChat() + }) + + // The active tab: the explicit selection while it's still in the list, else + // the first chat. + const effectiveActiveUrl = createMemo(() => { + const list = chats() + const a = activeUrl() + return a && list.includes(a) ? a : list[0] + }) + + return ( +
+ + Select a document to chat about it. +
+ }> +
+ + {(url) => ( + setActiveUrl(url)} + /> + )} + + + + + + {CHAT_VERSION} + + +
+
+ 0} + fallback={
Loading chat…
}> + + {(url) => ( + + )} + +
+
+ + + ) +} + +/** One always-mounted chat pane. When its tab is inactive the pane is hidden + * with CSS (visibility) rather than unmounted, so the chat's computer keeps + * running: heartbeat, mention listener, and any in-flight response all stay + * alive, and scroll position is preserved across tab switches. + * + * The pane also owns its chat's draft lifecycle: one open draft per chat + * (`chatDoc.agentDraftUrl`), forked off the focused doc's main on the first + * editing run, checked out for the duration of every run (and whenever this + * tab is selected), and closed by the review embed's accept/reject. */ +function ChatPane(props: { + repo: Repo + element: HTMLElement + url: AutomergeUrl + active: boolean + targetUrl: () => AutomergeUrl | undefined + storageHandle: () => DocHandle | null + checkedOut: () => DocHandle | null +}) { + const [handle, setHandle] = createSignal | null>(null) + createEffect(() => { + const url = props.url + let stale = false + props.repo + .find(url) + .then((h) => { + if (!stale) setHandle(h as DocHandle) + }) + .catch((e) => console.warn("[agent] find chat:", e)) + onCleanup(() => { + stale = true + }) + }) + + // Live chat doc snapshot, for the open draft pointer. + const [chatDoc, setChatDoc] = createSignal(undefined) + createEffect(() => { + const h = handle() + if (!h) { + setChatDoc(undefined) + return + } + const update = () => setChatDoc(() => h.doc()) + update() + h.on("change", update) + onCleanup(() => h.off("change", update)) + }) + const agentDraftUrl = createMemo(() => { + const u = chatDoc()?.agentDraftUrl + return typeof u === "string" && isValidAutomergeUrl(u) ? u : null + }) + + // Selecting a tab checks out the draft its agent is working on — with diff + // baselines at the fork points, so the document view highlights what the + // agent changed. A tab with NO open draft resets the view to main: a fresh + // chat starts from a clean slate, and after accept/reject (which clears + // the chat's draft pointer) this re-fires and lands on main. Waits for the + // chat doc to load so an existing draft isn't mistaken for "no draft". + // Not reactive to the checkout itself, so the user can still browse other + // branches from the drafts sidebar without being yanked back mid-tab. + const chatLoaded = createMemo(() => chatDoc() !== undefined) + createEffect(() => { + if (!props.active || !chatLoaded()) return + const draft = agentDraftUrl() + const co = props.checkedOut() + if (!co) return + if (!draft) { + checkoutDraft(co, null) + return + } + checkoutAgentDraft(rawRepo(), co, draft).catch((e) => + console.warn("[agent] tab checkout:", e) + ) + }) + + // Mirror last-used: while this pane is the active tab, remember its plugin + // set as the default for future context chats. + createEffect(() => { + if (!props.active) return + const chat = handle() + const storage = props.storageHandle() + if (!chat || !storage) return + const write = () => rememberPluginsAsDefault(chat, storage) + write() + chat.on("change", write) + onCleanup(() => chat.off("change", write)) + }) + + /** The chat's open draft, creating (and recording) a fresh one when there + * is none yet or the last one was merged away. */ + const ensureRunDraft = async (): Promise => { + const chat = handle() + const turl = props.targetUrl() + if (!chat || !turl) return null + const repo = rawRepo() + const existing = agentDraftUrl() + if (existing) { + try { + const d = await repo.find(existing) + if (d.doc()?.mergedAt === undefined) return existing + } catch (e) { + console.warn("[agent] stale draft pointer:", e) + } + } + const name = (chat.doc() as any)?.title || "Agent chat" + const draftUrl = await createAgentDraft(repo, turl, name) + chat.change((d: any) => { + d.agentDraftUrl = draftUrl + }) + return draftUrl + } + + // The draft the CURRENT run writes to, set by onRunStart. Run-time doc + // resolution goes through resolveDoc below — aimed at this draft directly, + // never via the global checkout — so switching tabs mid-run (or running + // several chats in parallel) can't redirect in-flight edits. + let runDraftUrl: AutomergeUrl | null = null + + // Scenario branches opened by the current run (via the start_scenario + // tool), in creation order. Non-empty ⇒ the run ends with the scenario + // picker instead of the single draft-review embed. + let runScenarios: {url: AutomergeUrl; name: string}[] = [] + + const onRunStart = async () => { + runScenarios = [] + // No checked-out doc = no drafts machinery mounted; run plainly (edits + // land on the real docs) rather than bookkeeping invisible drafts. + if (!props.checkedOut()) { + runDraftUrl = null + return + } + runDraftUrl = await ensureRunDraft() + // If this tab is the one being watched, the tab-select effect checks + // the (possibly new) draft out for VIEWING — the run doesn't depend on + // it. Background tabs leave the checkout entirely alone. + } + + /** The start_scenario tool: fork a fresh branch off main and re-aim the + * run's edits at it. The run's default draft (ensured by onRunStart) is + * folded in on the first call: untouched, it's unlinked and forgotten; + * already edited (e.g. reused from an earlier run), it joins the picker + * as a scenario of its own. */ + const startScenario = async (name: string): Promise => { + const turl = props.targetUrl() + const chat = handle() + if (!turl || !chat) return "Error: no focused document to branch." + if (!props.checkedOut()) { + return "Error: drafts are unavailable here — make the edits plainly instead of using scenarios." + } + const repo = rawRepo() + + if (runScenarios.length === 0 && runDraftUrl) { + const defaultDraft = runDraftUrl + try { + const d = await repo.find(defaultDraft) + const touched = + Object.keys(d.doc()?.clones ?? {}).length > 0 + if (touched) { + runScenarios.push({ + url: defaultDraft, + name: d.doc()?.name || "Earlier changes", + }) + } else { + await rejectAgentDraft(repo, defaultDraft) + chat.change((c: any) => { + if (c.agentDraftUrl === defaultDraft) + delete c.agentDraftUrl + }) + } + } catch (e) { + console.warn("[agent] fold default draft into scenarios:", e) + } + } + + const draftUrl = await createAgentDraft(repo, turl, name) + runScenarios.push({url: draftUrl, name}) + runDraftUrl = draftUrl + // Repointing the chat's open draft makes the active tab's checkout + // effect follow along, so a watching user sees each scenario as it + // is being built. + chat.change((d: any) => { + d.agentDraftUrl = draftUrl + }) + return ( + `Scenario “${name}” started (branch ${runScenarios.length}). ` + + "All document edits now land on this scenario's branch until the next start_scenario call." + ) + } + + /** Run-path doc resolution: the chat's own draft clone when a draft run is + * open, else whatever the overlay repo decides (checked-out draft or + * main). Handed to ChatRoot for every read/edit during a computer run. */ + const resolveDoc = async (url: string) => { + const draft = runDraftUrl + if (!draft) return props.repo.find(url as AutomergeUrl) + return resolveInDraft(rawRepo(), draft, url as AutomergeUrl) + } + + const onRunEnd = async (edited: boolean, summary?: string) => { + try { + if (runScenarios.length > 0) { + await finishScenarios() + } else if (edited) { + if (summary) await renameDraft(summary) + await postDraftReview() + } + } catch (e) { + console.warn("[agent] draft review embed:", e) + } + // If the user is watching this draft, refresh the checkout so the diff + // baselines cover the docs that forked during this run. + const co = props.checkedOut() + if (edited && co) { + const draft = agentDraftUrl() + if (draft && (co.doc()?.checkedOut ?? null) === draft) { + await checkoutAgentDraft(rawRepo(), co, draft) + } + } + } + + /** Rename the chat's open draft (the LLM's change summary becomes the + * draft's name in the sidebar and the review embed). */ + const renameDraft = async (name: string) => { + const draftUrl = agentDraftUrl() + if (!draftUrl) return + const draft = await rawRepo().find(draftUrl) + draft.change((d) => { + d.name = name + }) + } + + /** Scenario run wrap-up: unlink scenarios the model opened but never + * edited, point the chat's open draft at a surviving scenario, and post + * the picker embed. */ + const finishScenarios = async () => { + const scenarios = runScenarios + runScenarios = [] + const chat = handle() + if (!chat) return + const repo = rawRepo() + + const kept: {url: AutomergeUrl; name: string}[] = [] + for (const s of scenarios) { + try { + const d = await repo.find(s.url) + if (Object.keys(d.doc()?.clones ?? {}).length > 0) { + kept.push(s) + } else { + await rejectAgentDraft(repo, s.url) + } + } catch { + kept.push(s) + } + } + + if (kept.length === 0) { + chat.change((d: any) => { + delete d.agentDraftUrl + }) + return + } + if (!kept.some((s) => s.url === agentDraftUrl())) { + chat.change((d: any) => { + d.agentDraftUrl = kept[kept.length - 1].url + }) + } + + const msgData = { + id: generateId(), + name: "computer", + text: + kept.length === 1 + ? "I prepared one scenario — review it below." + : `I prepared ${kept.length} scenarios on separate branches — browse them below and accept one.`, + timestamp: Date.now(), + isComputer: true, + font: "monospace", + richBlocks: [ + { + type: "scenario-review", + content: JSON.stringify(kept), + meta: String(kept.length), + }, + ], + "@patchwork": {type: AGENT_CHAT_TYPE}, + } + const mh = await repo.create2(msgData as any) + chat.change((d: any) => { + if (!d.messages) d.messages = [] + d.messages.push({ref: true, url: mh.url, timestamp: msgData.timestamp}) + }) + } + + /** Post the accept/reject review embed for the chat's open draft. */ + const postDraftReview = async () => { + const chat = handle() + const draftUrl = agentDraftUrl() + if (!chat || !draftUrl) return + const repo = rawRepo() + let name = "Draft" + try { + name = (await repo.find(draftUrl)).doc()?.name || name + } catch {} + const msgData = { + id: generateId(), + name: "computer", + text: `I made my changes on the draft “${name}” — review them below.`, + timestamp: Date.now(), + isComputer: true, + font: "monospace", + richBlocks: [{type: "draft-review", content: draftUrl, meta: name}], + "@patchwork": {type: AGENT_CHAT_TYPE}, + } + const mh = await repo.create2(msgData as any) + chat.change((d: any) => { + if (!d.messages) d.messages = [] + d.messages.push({ref: true, url: mh.url, timestamp: msgData.timestamp}) + }) + } + + return ( +
+ Loading chat…
}> + {(h) => ( + runScenarios.length > 0, + }} + /> + )} + + + ) +} + +/** One tab. Its label is the chat doc's live title; double-click to rename + * (the new name is written onto the chat doc itself). */ +function ChatTab(props: { + repo: Repo + url: AutomergeUrl + active: boolean + onSelect: () => void +}) { + const [handle, setHandle] = createSignal | null>(null) + const [title, setTitle] = createSignal("…") + const [editing, setEditing] = createSignal(false) + + createEffect(() => { + const url = props.url + let stale = false + let found: DocHandle | null = null + const update = () => { + if (found) setTitle((found.doc() as any)?.title || "chat") + } + props.repo + .find(url) + .then((h) => { + if (stale) return + found = h as DocHandle + setHandle(found) + update() + found.on("change", update) + }) + .catch(() => setTitle("?")) + onCleanup(() => { + stale = true + found?.off("change", update) + }) + }) + + const commitRename = (value: string) => { + if (!editing()) return + setEditing(false) + const name = value.trim() + const h = handle() + if (!name || !h || name === title()) return + h.change((d) => { + d.title = name + }) + } + + return ( + + queueMicrotask(() => { + el.focus() + el.select() + }) + } + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(e.currentTarget.value) + else if (e.key === "Escape") setEditing(false) + }} + onBlur={(e) => commitRename(e.currentTarget.value)} + /> + }> + + + ) +} + +/** Puts the active chat's whole conversation on the clipboard as markdown — + * the way to hand this chat's history to another LLM. */ +function CopyTranscriptButton(props: {repo: Repo; url: AutomergeUrl | undefined}) { + const [state, setState] = createSignal<"idle" | "copied" | "failed">("idle") + let resetTimer: ReturnType | undefined + onCleanup(() => clearTimeout(resetTimer)) + + const copy = async () => { + const url = props.url + if (!url) return + try { + await copyChatTranscript(props.repo, url) + setState("copied") + } catch (e) { + console.warn("[agent] copy transcript:", e) + setState("failed") + } + clearTimeout(resetTimer) + resetTimer = setTimeout(() => setState("idle"), 1600) + } + + return ( + + + + setReplyToId(null)} diff --git a/chat/src/components/InputArea.tsx b/chat/src/components/InputArea.tsx index a2a4ba97..91963ccc 100644 --- a/chat/src/components/InputArea.tsx +++ b/chat/src/components/InputArea.tsx @@ -3,6 +3,7 @@ import {useChat} from "../context/ChatContext" import {useIdentity} from "../context/IdentityContext" import {usePresence} from "../context/PresenceContext" import {generateId} from "../lib/helpers" +import {agentMessageMetadata} from "../lib/agent-drafts" import {createFileDoc} from "../lib/file-helpers" import {createLoadedPlugins} from "../lib/slots" import {Slot, useSlotContext} from "../context/SlotContext" @@ -443,6 +444,10 @@ export function InputArea(props: { } if (Object.keys(usedEmoticons).length > 0) msgData.emoticons = usedEmoticons + // Agent chats stamp their message docs with the drafts-skipped datatype + // (see lib/agent-drafts.ts) — a no-op spread for ordinary chats. + Object.assign(msgData, agentMessageMetadata(handle.doc())) + const msgHandle = await repo.create2(msgData) handle.change((d: any) => { if (!d.messages) d.messages = [] diff --git a/chat/src/components/MessageBody.tsx b/chat/src/components/MessageBody.tsx index a9bc57ad..bd2e4b06 100644 --- a/chat/src/components/MessageBody.tsx +++ b/chat/src/components/MessageBody.tsx @@ -7,6 +7,7 @@ import {highlightCode} from "../lib/highlighter" import {ensureFontLoaded} from "../lib/blob-cache" import {resolveNamedColor} from "../lib/named-colors" import {generateId} from "../lib/helpers" +import {agentMessageMetadata} from "../lib/agent-drafts" import {useChat} from "../context/ChatContext" import {useIdentity} from "../context/IdentityContext" import {usePresence} from "../context/PresenceContext" @@ -181,7 +182,10 @@ export function MessageBody(props: { /> - + @@ -205,6 +209,7 @@ function QuickReplies(props: {options: string[]}) { name: myName(), text: `@computer ${opt}`, timestamp: Date.now(), + ...agentMessageMetadata(handle.doc()), } const cu = myContactUrl() if (cu) msgData.contactUrl = cu diff --git a/chat/src/components/RichBlockView.tsx b/chat/src/components/RichBlockView.tsx index 5942bdfd..df024d2f 100644 --- a/chat/src/components/RichBlockView.tsx +++ b/chat/src/components/RichBlockView.tsx @@ -1,18 +1,371 @@ import {createSignal, createResource, For, Show} from "solid-js" +import {isValidAutomergeUrl} from "@automerge/automerge-repo/slim" +import type {AutomergeUrl} from "@automerge/automerge-repo/slim" import type {RichBlock} from "../types" import {highlightCode} from "../lib/highlighter" import {useTheme} from "../context/ThemeContext" +import {useChat} from "../context/ChatContext" +import { + rawRepo, + mergeAgentDraft, + rejectAgentDraft, + checkedOutDraftHandle, + checkoutDraft, + checkoutAgentDraft, +} from "../lib/agent-drafts" -export function RichBlockList(props: {blocks: RichBlock[]}) { +export function RichBlockList(props: { + blocks: RichBlock[] + // The message doc these blocks live on — lets stateful blocks (the agent + // draft review) record their outcome back onto the block. + messageUrl?: AutomergeUrl +}) { return (
- {(block) => } + {(block) => ( + }> + + + }> + + + )}
) } +/** The agent-draft review embed: names the draft the computer's edits landed + * on, with Accept (merge it into what it branched off, then check that out) + * and Reject (delete the draft, back to main) — both through the existing + * drafts machinery (see lib/agent-drafts.ts). The decision is recorded on the + * block's `result` ("accepted"/"rejected"), which syncs to every peer and + * freezes the embed; embeds from earlier runs on the same draft freeze as + * "closed" once the draft is no longer the chat's open one. */ +function DraftReviewBlock(props: { + block: RichBlock + messageUrl?: AutomergeUrl +}) { + const {handle, doc, element} = useChat() + const checkedOut = checkedOutDraftHandle(element) + const [busy, setBusy] = createSignal(false) + + const draftUrl = (): AutomergeUrl | null => + isValidAutomergeUrl(props.block.content) ? props.block.content : null + const decided = () => props.block.result + // Only the chat's currently-open draft is actionable. + const open = () => + !!draftUrl() && (doc() as any)?.agentDraftUrl === draftUrl() + + async function decide(action: "accept" | "reject") { + const url = draftUrl() + if (!url || busy() || decided() || !open()) return + setBusy(true) + try { + const repo = rawRepo() + if (action === "accept") await mergeAgentDraft(repo, url) + else await rejectAgentDraft(repo, url) + // Accept or reject, the story ends on main: merged changes are + // there now, rejected ones are gone. + const co = checkedOut() + if (co) checkoutDraft(co, null) + // Close the chat's open draft; the next run forks a fresh one. + handle.change((d: any) => { + if (d.agentDraftUrl === url) delete d.agentDraftUrl + }) + // Freeze this embed for every peer. + if (props.messageUrl) { + const mh = await repo.find<{richBlocks?: RichBlock[]}>( + props.messageUrl + ) + mh.change((d) => { + const block = (d.richBlocks || []).find( + (b) => + b.type === "draft-review" && b.content === url && !b.result + ) + if (block) + block.result = action === "accept" ? "accepted" : "rejected" + }) + } + } catch (e) { + console.warn("[agent] draft " + action + " failed:", e) + } finally { + setBusy(false) + } + } + + return ( +
+ + + + + + + + {props.block.meta || "Draft"} + + + {decided() === "accepted" + ? "Accepted" + : decided() === "rejected" + ? "Rejected" + : "Closed"} + + }> + + + + + +
+ ) +} + +/** The multi-scenario picker: one chip per scenario branch a run produced. + * Clicking a chip checks that branch out (with diff baselines) and makes it + * the chat's open draft, so the user can flip back and forth through the + * alternatives in the document view. Accept merges the SELECTED scenario and + * unlinks the rest; Reject all unlinks everything. The outcome is stamped on + * the block's `result` (\`accepted:\` / "rejected") so the embed freezes + * for every peer, mirroring DraftReviewBlock. */ +function ScenarioReviewBlock(props: { + block: RichBlock + messageUrl?: AutomergeUrl +}) { + const {handle, doc, element} = useChat() + const checkedOut = checkedOutDraftHandle(element) + const [busy, setBusy] = createSignal(false) + + const scenarios = (): {url: AutomergeUrl; name: string}[] => { + try { + const parsed = JSON.parse(props.block.content) + if (!Array.isArray(parsed)) return [] + return parsed.filter( + (s: any) => + s && + typeof s.name === "string" && + isValidAutomergeUrl(s.url) + ) + } catch { + return [] + } + } + const decided = () => props.block.result + // Actionable while the chat's open draft is still one of these scenarios + // (a later run replaces the open draft, which closes this picker). + const open = () => + !decided() && + scenarios().some((s) => (doc() as any)?.agentDraftUrl === s.url) + const selected = (): AutomergeUrl | null => { + const current = (doc() as any)?.agentDraftUrl + return scenarios().some((s) => s.url === current) ? current : null + } + const acceptedUrl = () => { + const r = decided() + return r?.startsWith("accepted:") ? r.slice("accepted:".length) : null + } + + /** Chip click: browse this scenario — check its branch out and make it + * the chat's open draft (so follow-up messages continue on it). */ + async function view(url: AutomergeUrl) { + if (!open() || busy()) return + handle.change((d: any) => { + d.agentDraftUrl = url + }) + const co = checkedOut() + if (co) { + try { + await checkoutAgentDraft(rawRepo(), co, url) + } catch (e) { + console.warn("[agent] scenario checkout:", e) + } + } + } + + async function accept() { + const chosen = selected() + if (!chosen || busy() || !open()) return + setBusy(true) + try { + const repo = rawRepo() + await mergeAgentDraft(repo, chosen) + for (const s of scenarios()) { + if (s.url !== chosen) await rejectAgentDraft(repo, s.url) + } + await settle("accepted:" + chosen) + } catch (e) { + console.warn("[agent] scenario accept failed:", e) + } finally { + setBusy(false) + } + } + + async function rejectAll() { + if (busy() || !open()) return + setBusy(true) + try { + const repo = rawRepo() + for (const s of scenarios()) { + await rejectAgentDraft(repo, s.url) + } + await settle("rejected") + } catch (e) { + console.warn("[agent] scenario reject failed:", e) + } finally { + setBusy(false) + } + } + + /** Either way the story ends on main: check it out, close the chat's + * open draft, and freeze this embed for every peer. */ + async function settle(result: string) { + const co = checkedOut() + if (co) checkoutDraft(co, null) + const urls = new Set(scenarios().map((s) => s.url)) + handle.change((d: any) => { + if (urls.has(d.agentDraftUrl)) delete d.agentDraftUrl + }) + if (props.messageUrl) { + const mh = await rawRepo().find<{richBlocks?: RichBlock[]}>( + props.messageUrl + ) + mh.change((d) => { + const block = (d.richBlocks || []).find( + (b) => + b.type === "scenario-review" && + b.content === props.block.content && + !b.result + ) + if (block) block.result = result + }) + } + } + + const acceptedName = () => + scenarios().find((s) => s.url === acceptedUrl())?.name + + return ( +
+
+ + + + + + + + {scenarios().length}{" "} + {scenarios().length === 1 ? "scenario" : "scenarios"} + + + {acceptedUrl() + ? `Accepted “${acceptedName() || "scenario"}”` + : decided() === "rejected" + ? "Rejected" + : "Closed"} + + }> + + + + + +
+
+ + {(s) => ( + + )} + +
+
+ ) +} + function RichBlockView(props: {block: RichBlock}) { const {isLightBg} = useTheme() const [open, setOpen] = createSignal(false) diff --git a/chat/src/components/SkillsDebug.tsx b/chat/src/components/SkillsDebug.tsx new file mode 100644 index 00000000..2b25486e --- /dev/null +++ b/chat/src/components/SkillsDebug.tsx @@ -0,0 +1,105 @@ +// A small fold-out debug panel listing every known llm:skill and its state: +// whether it was active for the most recent computer run, whether its module +// has been loaded, and what would activate it (datatype match or /plugin). +// The list is re-read each time the panel is opened, so late-registering +// bundles show up without a reload. + +import {createSignal, For, Show} from "solid-js" +import { + listSkills, + loadedSkillIds, + peekSkillModule, + type ActiveSkill, + type LlmSkillDescription, +} from "../lib/llm-skills" +import {CHAT_VERSION} from "../version" + +export function SkillsDebug(props: { + /** Skills active for the most recent computer run. */ + active: () => ActiveSkill[] + /** Skill ids enabled on this chat via /plugin load. */ + enabledIds: () => Set +}) { + const [open, setOpen] = createSignal(false) + + return ( +
setOpen((e.currentTarget as HTMLDetailsElement).open)}> + + debug: skills + {CHAT_VERSION} + 0}> + + {props.active().length} active + + + + {/* Remounted on every open so listSkills()/loadedSkillIds() are fresh. */} + + + +
+ ) +} + +function SkillRows(props: { + active: () => ActiveSkill[] + enabledIds: () => Set +}) { + const skills = listSkills() + const loaded = loadedSkillIds() + const isActive = (id: string) => props.active().some((s) => s.id === id) + + return ( +
+ +
no skills registered
+
+ + {(skill) => ( +
+ + {isActive(skill.id) + ? "active" + : loaded.has(skill.id) + ? "loaded" + : "idle"} + + {skill.id} + {skillMeta(skill, props.enabledIds())} +
{skill.description}
+
+ )} +
+
+ active = in the last computer run's prompt · loaded = module cached · + a skill activates when the focused doc matches its datatypes, when + the model reads a matching doc or calls load_skill, or via /plugin + load <id> +
+
+ ) +} + +// The activation summary for one row: how the skill turns on, plus the tools +// its loaded module contributes. +function skillMeta( + skill: LlmSkillDescription, + enabledIds: Set +): string { + const parts: string[] = [] + if (skill.datatypes?.length) parts.push(`on: ${skill.datatypes.join(", ")}`) + if (enabledIds.has(skill.id)) parts.push("enabled via /plugin") + const tools = peekSkillModule(skill.id)?.tools + if (tools?.length) parts.push(`tools: ${tools.map((t) => t.name).join(", ")}`) + return parts.join(" · ") +} diff --git a/chat/src/context-tool.tsx b/chat/src/context-tool.tsx index 5251afb4..935f7edb 100644 --- a/chat/src/context-tool.tsx +++ b/chat/src/context-tool.tsx @@ -17,23 +17,16 @@ import type {Repo, DocHandle, AutomergeUrl} from "@automerge/automerge-repo/slim import {ChatRoot} from "./components/ChatRoot" import {selectedDocUrl, toolStorageUrl} from "./lib/selected-doc" import {setRepo} from "./lib/repo" +import { + DEFAULT_CONTEXT_CHAT_PLUGINS, + isOldDefaultContextChatPlugins, + ensureDefaultPlugins, + rememberPluginsAsDefault, + createContextChat, + type ToolStorageDoc, +} from "./lib/context-chat" import type {ChatDoc} from "./types" -interface ToolStorageDoc { - defaultPlugins?: string[] -} - -const DEFAULT_CHITCHAT_PLUGINS = ["computer", "model"] -const OLD_DEFAULT_CHITCHAT_PLUGINS = ["computer"] - -function isOldDefaultChitchatPlugins(plugins: unknown): plugins is string[] { - return ( - Array.isArray(plugins) && - plugins.length === OLD_DEFAULT_CHITCHAT_PLUGINS.length && - plugins.every((p, i) => p === OLD_DEFAULT_CHITCHAT_PLUGINS[i]) - ) -} - /** Find (or create + link) the chat doc stored on the focused document, seeding a * new one's plugin set from the remembered default. */ async function ensureChitchat( @@ -48,8 +41,8 @@ async function ensureChitchat( chat.change((d: any) => { const isMissingPlugins = !Array.isArray(d.plugins) if (isMissingPlugins) d.plugins = defaultPlugins.slice() - else if (isOldDefaultChitchatPlugins(d.plugins)) { - d.plugins = DEFAULT_CHITCHAT_PLUGINS.slice() + else if (isOldDefaultContextChatPlugins(d.plugins)) { + d.plugins = DEFAULT_CONTEXT_CHAT_PLUGINS.slice() } if (isMissingPlugins && d["@patchwork"]?.type === "chitterchatter") { d["@patchwork"].type = "chat" @@ -59,26 +52,16 @@ async function ensureChitchat( } const targetTitle = (target.doc() as any)?.title - const created = await repo.create2({ - title: "chat: " + (targetTitle || "document"), - messages: [], - docs: [], - // Seed the plugin set from the user's remembered chitchat default (starts as - // just the computer). The computer is auto-invited below. - plugins: defaultPlugins.slice(), - "@patchwork": {type: "chat"}, - // Auto-invite the computer (ChatRoot's onMount claims the host when - // hasComputer is set) — but it stays off nosey, so it only replies when - // @mentioned or replied to. - hasComputer: true, - } as any) - // Resolve through find so a draft forks the new doc into this draft's clones. - const chat = await repo.find(created.url) + const chat = await createContextChat( + repo, + "chat: " + (targetTitle || "document"), + defaultPlugins + ) target.change((d: any) => { if (!d["@patchwork"]) d["@patchwork"] = {} d["@patchwork"].chitchat = chat.url }) - return chat as DocHandle + return chat } function ContextHost(props: {element: HTMLElement; repo: Repo}) { @@ -100,23 +83,13 @@ function ContextHost(props: {element: HTMLElement; repo: Repo}) { .find(url) .then((h) => { const storage = h as DocHandle - if (!Array.isArray(storage.doc()?.defaultPlugins)) { - storage.change((d) => { - if (!Array.isArray(d.defaultPlugins)) - d.defaultPlugins = DEFAULT_CHITCHAT_PLUGINS.slice() - }) - } else if (isOldDefaultChitchatPlugins(storage.doc()?.defaultPlugins)) { - storage.change((d) => { - if (isOldDefaultChitchatPlugins(d.defaultPlugins)) - d.defaultPlugins = DEFAULT_CHITCHAT_PLUGINS.slice() - }) - } + ensureDefaultPlugins(storage) setStorageHandle(storage) }) .catch((e) => console.warn("[chitchat] tool-storage:", e)) }) const defaultPlugins = () => - storageHandle()?.doc()?.defaultPlugins ?? DEFAULT_CHITCHAT_PLUGINS + storageHandle()?.doc()?.defaultPlugins ?? DEFAULT_CONTEXT_CHAT_PLUGINS createEffect(() => { const url = targetUrl() @@ -142,20 +115,7 @@ function ContextHost(props: {element: HTMLElement; repo: Repo}) { const chat = chatHandle() const storage = storageHandle() if (!chat || !storage) return - const write = () => { - const plugins = (chat.doc() as any)?.plugins - if (!Array.isArray(plugins)) return - const current = storage.doc()?.defaultPlugins - if ( - Array.isArray(current) && - current.length === plugins.length && - current.every((p, i) => p === plugins[i]) - ) - return - storage.change((d) => { - d.defaultPlugins = plugins.slice() - }) - } + const write = () => rememberPluginsAsDefault(chat, storage) write() chat.on("change", write) onCleanup(() => chat.off("change", write)) diff --git a/chat/src/datatype.ts b/chat/src/datatype.ts index 992330fd..fe1b5607 100644 --- a/chat/src/datatype.ts +++ b/chat/src/datatype.ts @@ -23,3 +23,17 @@ export const ChatDatatype = { getTitle, setTitle, } + +// `agent-chat` — the Agent context-tool's chats. Same document shape and same +// chat tool as `chat`; the distinct type exists so the drafts overlay can skip +// (never fork) these docs — see lib/agent-drafts.ts. +export const AgentChatDatatype = { + init(doc: ChatDoc) { + base(doc, "agent chat " + new Date().toLocaleString(), [ + "computer", + "model", + ]) + }, + getTitle, + setTitle, +} diff --git a/chat/src/index.ts b/chat/src/index.ts index 07d33939..378aba2b 100644 --- a/chat/src/index.ts +++ b/chat/src/index.ts @@ -19,6 +19,19 @@ export const plugins = [ return (await import("./datatype")).ChatDatatype }, }, + { + // The Agent tool's chats: identical to `chat`, but a distinct type the + // drafts overlay skips (never forks) — the conversation must survive a + // rejected draft. Unlisted: created by the Agent tool, not by hand. + type: "patchwork:datatype", + id: "agent-chat", + name: "Agent Chat", + icon: "Bot", + unlisted: true, + async load() { + return (await import("./datatype")).AgentChatDatatype + }, + }, { // The single chat tool. Which features are active is driven by the // document's `plugins` array, not by the tool. Registered under `chat`; @@ -27,7 +40,7 @@ export const plugins = [ id: "chat", name: "Chat", icon: "MessageSquare", - supportedDatatypes: ["chitterchatter", "chat", "chitter"], + supportedDatatypes: ["chitterchatter", "chat", "chitter", "agent-chat"], async load() { return (await import("./tool")).ChatTool }, @@ -57,6 +70,22 @@ export const plugins = [ return ChatContextComponent }, }, + { + // Multi-chat context-sidebar variant: like the watercooler, but with a + // tab bar of chats per focused document (the list lives in a separate, + // never-forked `agent-chats` index doc the focused doc points at), and + // the computer's edits land on a per-chat draft with an accept/reject + // review embed in the conversation. + type: "patchwork:component", + id: "agent", + name: "Agent", + icon: "Bot", + tags: ["context-tool"], + async load() { + const {AgentContextComponent} = await import("./agent-tool") + return AgentContextComponent + }, + }, // ── Host-registrable feature plugins ──────────────────────────────────────── // The four extensible seams, registered like newspace's sketchy:* plugins so // other modules can contribute inline syntax, slash commands, hover actions and diff --git a/chat/src/lib/agent-change.ts b/chat/src/lib/agent-change.ts new file mode 100644 index 00000000..d15658a8 --- /dev/null +++ b/chat/src/lib/agent-change.ts @@ -0,0 +1,77 @@ +// Agent-attributed writes: every document edit the agent makes on the user's +// behalf goes through `agentChange`, which stamps the Automerge change's +// `message` with a JSON tag naming the chat (and its heads at execution time, +// so the tag deep-links to the conversation state that produced the edit). +// The message travels with the change itself — through draft clones, merges, +// and sync — so any consumer (the drafts timeline splits change groups on it +// and shows a "via agent" badge) can tell agent edits from the same person's +// manual ones without a side channel. +// +// The tag is an envelope under "@patchwork" so other tools can add their own +// change metadata later without colliding. Consumers must parse defensively: +// a change message is free text to Automerge, so anything non-JSON (or JSON +// of another shape) simply means "not an agent edit". +// +// Known gap: custom tools minted via define_tool call ctx.handle.change +// themselves and their edits stay untagged for now. +import type {DocHandle, AutomergeUrl, UrlHeads} from "@automerge/automerge-repo/slim" + +export type AgentChangeTag = { + "@patchwork": { + agent: { + /** The agent chat doc the edit came from. */ + chatUrl: AutomergeUrl + /** The chat's heads when the tool call executed. */ + chatHeads?: string[] + /** Provider tool-call id, when the provider supplies one. */ + toolCallId?: string + } + } +} + +export function makeAgentTag( + chatHandle: DocHandle, + toolCallId?: string +): AgentChangeTag { + const agent: AgentChangeTag["@patchwork"]["agent"] = { + chatUrl: chatHandle.url, + } + const heads = headsOf(chatHandle) + if (heads) agent.chatHeads = heads + if (toolCallId) agent.toolCallId = toolCallId + return {"@patchwork": {agent}} +} + +/** `handle.change` / `handle.changeAt` with the agent tag as the change + * message. Use for every agent edit to a target document (not for the chat + * doc's own message writes — those aren't document edits). */ +export function agentChange( + handle: DocHandle, + tag: AgentChangeTag, + mut: (doc: T) => void, + heads?: UrlHeads +): void { + const options = {message: JSON.stringify(tag)} + console.log( + "[agent-change] tagging", + heads && heads.length ? "changeAt" : "change", + "on", + handle.url, + "message:", + options.message + ) + if (heads && heads.length) { + handle.changeAt(heads, mut, options) + } else { + handle.change(mut, options) + } +} + +function headsOf(handle: DocHandle): string[] | undefined { + try { + const heads = handle.heads() + return heads && heads.length ? [...heads] : undefined + } catch { + return undefined + } +} diff --git a/chat/src/lib/agent-drafts.ts b/chat/src/lib/agent-drafts.ts new file mode 100644 index 00000000..e0763f06 --- /dev/null +++ b/chat/src/lib/agent-drafts.ts @@ -0,0 +1,487 @@ +// Agent drafts — the Agent context-tool's "LLM edits land on a draft" flow, +// built on the drafts plugin's document model. The drafts package can't be +// imported (every tool in this repo is standalone), so the doc shapes and the +// fork/merge/unlink recipes are restated here against the same conventions +// (see drafts/src/draft-types.ts): a host doc points at its main DraftDoc via +// `@patchwork.mainDraftUrl`, top-level drafts hang off `mainDraft.drafts`, and +// each draft's `clones` maps original doc urls to per-draft clones (written +// lazily by the overlay as docs get resolved beneath it while the draft is +// checked out). +// +// Everything here goes through the RAW `window.repo`, never a pane's +// OverlayRepo: the overlay forks every non-skipped doc resolved beneath it, so +// touching a clone (or the ephemeral checkout doc) through it while a draft is +// checked out would fork the draft machinery itself into the draft. +import { + encodeHeads, + isValidAutomergeUrl, + parseAutomergeUrl, + stringifyAutomergeUrl, +} from "@automerge/automerge-repo/slim" +import type { + Repo, + DocHandle, + AutomergeUrl, + UrlHeads, +} from "@automerge/automerge-repo/slim" +import {createSignal, createEffect, onCleanup, type Accessor} from "solid-js" +import {subscribe} from "./selected-doc" + +/** `@patchwork.type` of agent chat docs AND their message docs. On the drafts + * skip-list, so the conversation never forks into a draft (a rejected draft + * must not take the chat history — including the accept/reject exchange — + * with it). */ +export const AGENT_CHAT_TYPE = "agent-chat" + +/** `@patchwork.type` of the per-document index doc holding the agent tool's + * chat-tab list. Also skipped, so the tab list is identical on every branch: + * chats don't appear/disappear as you switch drafts. */ +export const AGENT_CHATS_INDEX_TYPE = "agent-chats" + +// ── Drafts document model (mirrors drafts/src/draft-types.ts) ─────────────── + +export type CloneEntry = { + cloneUrl: AutomergeUrl + clonedAt: UrlHeads + mergedAt?: UrlHeads + /** The clone's heads at merge time — with `clonedAt`, brackets the head + * range the drafts timeline attributes to this draft after it merges. */ + mergedFrom?: UrlHeads +} + +export type DraftDoc = { + "@patchwork": {type: "draft"} + isMain?: boolean + name?: string + parent: AutomergeUrl + drafts: AutomergeUrl[] + clones: Record + mergedAt?: number + /** The draft the merge landed in, so its timeline can attribute this + * draft's changes to a dedicated "Merged …" group. */ + mergedInto?: AutomergeUrl + draftCounter?: number +} + +/** The ephemeral, per-client selection doc owned by the draft-state provider: + * which draft is checked out (`null` = main) and the optional checkpoint. */ +export type CheckedOutDraft = { + checkedOut: AutomergeUrl | null + at?: unknown | null +} + +type HasDrafts = { + "@patchwork"?: {type?: string; mainDraftUrl?: AutomergeUrl} +} + +/** The Agent tool's per-document chat list. Lives in its own doc (pointed at + * by `focusedDoc['@patchwork'].agentChats`) rather than on the focused doc, + * because the focused doc forks per draft — an on-doc list would make chats + * appear and disappear as branches are switched. */ +export type AgentChatsIndexDoc = { + "@patchwork": {type: string} + chats: AutomergeUrl[] +} + +/** The raw realm repo — bypasses any draft overlay between the tool and the + * documents, so reads and writes always hit the real docs. */ +export function rawRepo(): Repo { + const repo = (window as {repo?: Repo}).repo + if (!repo) throw new Error("[agent] window.repo is not set") + return repo +} + +/** Resolve the focused doc's agent-chats index, creating it (and migrating a + * legacy on-doc `lmchats` array) on first use. `repo` must be the raw repo and + * `targetUrl` the original (main) doc url: the pointer is branch-independent + * metadata, so it is read from and written to the real doc even while a draft + * is checked out — written through the overlay it would land on the clone and + * be lost on reject. */ +export async function resolveAgentChatsIndex( + repo: Repo, + targetUrl: AutomergeUrl +): Promise> { + const target = await repo.find>( + targetUrl + ) + const meta = target.doc()?.["@patchwork"] as + | {agentChats?: AutomergeUrl; lmchats?: AutomergeUrl[]} + | undefined + const existing = meta?.agentChats + if (existing && isValidAutomergeUrl(existing)) { + return repo.find(existing) + } + const legacy = meta?.lmchats + const index = await repo.create2({ + "@patchwork": {type: AGENT_CHATS_INDEX_TYPE}, + chats: Array.isArray(legacy) ? [...legacy] : [], + }) + target.change((d) => { + const anyDoc = d as { + "@patchwork"?: {agentChats?: AutomergeUrl; lmchats?: unknown} + } + if (!anyDoc["@patchwork"]) anyDoc["@patchwork"] = {} + if (!anyDoc["@patchwork"].agentChats) + anyDoc["@patchwork"].agentChats = index.url + if (Array.isArray(anyDoc["@patchwork"].lmchats)) + delete anyDoc["@patchwork"].lmchats + }) + // Re-read: a concurrent creator may have won the pointer. + const settled = ( + target.doc()?.["@patchwork"] as {agentChats?: AutomergeUrl} | undefined + )?.agentChats + return settled && settled !== index.url + ? repo.find(settled) + : index +} + +/** Fork a new draft off the target doc's live main and return its url. Forking + * main live needs no eager clones — the overlay's lazy resolveClone forks each + * doc at its current heads the first time it's resolved beneath the draft. */ +export async function createAgentDraft( + repo: Repo, + targetUrl: AutomergeUrl, + name: string +): Promise { + const target = await repo.find(targetUrl) + const mainDraft = await ensureMainDraft(repo, target) + const draft = await repo.create2({ + "@patchwork": {type: "draft"}, + name, + parent: mainDraft.url, + drafts: [], + clones: {}, + }) + mainDraft.change((d) => { + d.drafts.push(draft.url) + }) + return draft.url +} + +/** Resolve the host doc's main draft, creating it and stamping + * `@patchwork.mainDraftUrl` the first time (the draft-state provider creates + * it eagerly for selected docs, so this is normally just a lookup). */ +async function ensureMainDraft( + repo: Repo, + target: DocHandle +): Promise> { + const existing = target.doc()?.["@patchwork"]?.mainDraftUrl + if (existing && isValidAutomergeUrl(existing)) { + return repo.find(existing) + } + const mainDraft = await repo.create2({ + "@patchwork": {type: "draft"}, + isMain: true, + parent: target.url, + drafts: [], + clones: {}, + }) + target.change((d) => { + // Mutate `@patchwork` in place — reassigning a spread would carry + // references to existing Automerge objects into a new object. + if (!d["@patchwork"]) d["@patchwork"] = {} + if (!d["@patchwork"]!.mainDraftUrl) + d["@patchwork"]!.mainDraftUrl = mainDraft.url + }) + // Re-read: a concurrent creator may have won the pointer. + const settled = target.doc()?.["@patchwork"]?.mainDraftUrl + return settled && settled !== mainDraft.url + ? repo.find(settled) + : mainDraft +} + +/** Accept: merge every cloned doc back into the parent draft's copy of it — + * the parent's clone when it has one, adopting the clone into the parent's + * map when it doesn't (a member the parent never forked must stay scoped to + * the parent, not leak into the original) — then mark the draft merged + * (which hides it from the drafts sidebar). Records `mergedFrom` per member + * and `mergedInto` on the draft, the provenance the drafts timeline reads to + * attribute the merged changes to a dedicated "Merged …" group. Children + * (unlikely on agent drafts) are handed up to the merge target so they never + * dangle under a hidden draft. Mirrors the sidebar's mergeDraft. */ +export async function mergeAgentDraft( + repo: Repo, + draftUrl: AutomergeUrl +): Promise { + const draftHandle = await repo.find(draftUrl) + const doc = draftHandle.doc() + const parentHandle = await findMergeTarget(repo, doc?.parent) + const parentIsMain = parentHandle?.doc()?.isMain === true + const entries = Object.entries(doc?.clones ?? {}) as [ + AutomergeUrl, + CloneEntry, + ][] + for (const [originalUrl, entry] of entries) { + // A member the target never forked: a real draft adopts the clone (no + // data moves); main gets the identity entry its clone sync would + // eventually add, so its timeline is guaranteed to include the member. + if (parentHandle && !parentHandle.doc()?.clones[originalUrl]) { + // Copy the heads array: it was read out of the draft's doc, and a + // live Automerge object must not be assigned into another document. + const adopted: CloneEntry = parentIsMain + ? {cloneUrl: originalUrl, clonedAt: encodeHeads([])} + : { + cloneUrl: entry.cloneUrl, + clonedAt: [...entry.clonedAt] as UrlHeads, + } + parentHandle.change((d) => { + if (!d.clones[originalUrl]) d.clones[originalUrl] = adopted + }) + } + // Re-read the target's clones: the adoption above (or a concurrent + // creator winning its guard) may have just changed the mapping. + const parentClones = parentHandle?.doc()?.clones ?? {} + const targetUrl = parentClones[originalUrl]?.cloneUrl ?? originalUrl + const clone = await repo.find(entry.cloneUrl) + const mergedFrom = clone.heads() + if (entry.cloneUrl === targetUrl) { + // The clone IS the target's copy (adopted above, or an identity + // entry); nothing to merge — just record the join point. + draftHandle.change((d) => { + const e = d.clones[originalUrl] + if (e && mergedFrom) { + e.mergedAt = mergedFrom + e.mergedFrom = mergedFrom + } + }) + continue + } + const target = await repo.find(targetUrl) + target.merge(clone) + const mergedAt = target.heads() + draftHandle.change((d) => { + const e = d.clones[originalUrl] + if (e && mergedAt && mergedFrom) { + e.mergedAt = mergedAt + e.mergedFrom = mergedFrom + } + }) + } + draftHandle.change((d) => { + d.mergedAt = Date.now() + if (parentHandle) d.mergedInto = parentHandle.url + }) + + if (parentHandle) { + const children = (draftHandle.doc()?.drafts ?? []).filter( + isValidAutomergeUrl + ) + for (const childUrl of children) { + try { + const child = await repo.find(childUrl) + child.change((d) => { + d.parent = parentHandle.url + }) + parentHandle.change((d) => { + if (!d.drafts.includes(childUrl)) d.drafts.push(childUrl) + }) + draftHandle.change((d) => { + const i = d.drafts.indexOf(childUrl) + if (i >= 0) d.drafts.splice(i, 1) + }) + } catch (err) { + console.warn("[agent] failed to re-parent child draft:", childUrl, err) + } + } + } +} + +/** The draft the merge should land in: the nearest non-merged ancestor (a + * merged-away parent hands its role up the chain, ending at the main draft, + * which is never merged). Null when the chain can't be resolved — the caller + * then falls back to merging into the originals. */ +async function findMergeTarget( + repo: Repo, + parentUrl: AutomergeUrl | undefined +): Promise | null> { + const seen = new Set() + let cursor = parentUrl + while (cursor && isValidAutomergeUrl(cursor) && !seen.has(cursor)) { + seen.add(cursor) + try { + const candidate = await repo.find(cursor) + if (candidate.doc()?.mergedAt === undefined) return candidate + cursor = candidate.doc()?.parent + } catch (err) { + console.warn("[agent] failed to load ancestor draft for merge:", err) + return null + } + } + return null +} + +/** Reject: unlink the draft from its parent's `drafts` list, which drops it + * from every peer's tree walk. Nothing is merged; the clones are left in + * place, just unreachable. Mirrors the sidebar's delete. */ +export async function rejectAgentDraft( + repo: Repo, + draftUrl: AutomergeUrl +): Promise { + const draftHandle = await repo.find(draftUrl) + const parentUrl = draftHandle.doc()?.parent + if (!parentUrl || !isValidAutomergeUrl(parentUrl)) return + const parent = await repo.find(parentUrl) + parent.change((d) => { + const i = d.drafts.indexOf(draftUrl) + if (i >= 0) d.drafts.splice(i, 1) + }) +} + +/** Solid accessor for the ephemeral CheckedOutDraft handle, resolved from the + * ancestor draft-state provider's `draft:checked-out` selector — writing + * `checkedOut` on it is how a branch gets checked out. Resolved via the raw + * repo (the checkout doc carries no `@patchwork.type`, so an overlay find + * would fork it into the current draft). Null while unresolved, or when no + * draft-state provider answers (drafts plugin absent). */ +export function checkedOutDraftHandle( + element: HTMLElement +): Accessor | null> { + const url = subscribe( + element, + {type: "draft:checked-out"}, + undefined + ) + const [handle, setHandle] = createSignal | null>( + null + ) + createEffect(() => { + const u = url() + if (!u || !isValidAutomergeUrl(u)) return + let stale = false + rawRepo() + .find(u) + .then((h) => { + if (!stale) setHandle(h) + }) + .catch((e) => console.warn("[agent] checked-out doc:", e)) + onCleanup(() => { + stale = true + }) + }) + return handle +} + +/** Check out a draft (`null` = main), returning to the live latest heads — + * same move as the sidebar's selectDraft. No-op when already there (so a + * user-scrubbed checkpoint on the same branch isn't clobbered). */ +export function checkoutDraft( + handle: DocHandle, + url: AutomergeUrl | null +): void { + if ((handle.doc()?.checkedOut ?? null) === url) return + handle.change((d) => { + d.checkedOut = url + d.at = null + }) +} + +/** Check out an agent draft WITH diff baselines: every member diffs against + * its fork point (the sidebar's eye-open view), so the document view + * highlights exactly what the agent has changed. Re-running on an + * already-checked-out draft refreshes the baselines — docs forked since the + * last checkout (the overlay adds clones lazily) get theirs added. */ +export async function checkoutAgentDraft( + repo: Repo, + handle: DocHandle, + draftUrl: AutomergeUrl +): Promise { + const baselines: Record = {} + try { + const draft = await repo.find(draftUrl) + const clones = draft.doc()?.clones ?? {} + for (const [original, entry] of Object.entries(clones)) { + if (entry.clonedAt) + baselines[original as AutomergeUrl] = {from: entry.clonedAt} + } + } catch (e) { + console.warn("[agent] draft baselines:", e) + } + handle.change((d) => { + d.checkedOut = draftUrl + d.at = Object.keys(baselines).length > 0 ? baselines : null + }) +} + +// Datatypes the draft machinery never forks — mirrors +// drafts/src/clone-policy.ts (this package can't import the drafts package at +// build time; keep the two lists in step). +const SKIPPED_DATATYPES = new Set([ + "account", + "contact", + "draft", + "change-group", + "change-group-cache", + AGENT_CHAT_TYPE, + AGENT_CHATS_INDEX_TYPE, +]) + +/** Reduce a url to its bare document identity (strip heads/path suffixes) so + * urls from different traversals dedupe to the same clones key. Mirrors the + * overlay's canonicalUrl. */ +function canonicalUrl(url: AutomergeUrl): AutomergeUrl { + return stringifyAutomergeUrl({documentId: parseAutomergeUrl(url).documentId}) +} + +/** Resolve a document AGAINST A SPECIFIC DRAFT, independent of the global + * checkout: return the draft's clone of the doc, forking the original at its + * current heads (and recording the fork point in `DraftDoc.clones`) on first + * touch — the same move as the overlay's resolveClone, but aimed by the + * caller instead of by the checked-out selection. Skipped datatypes pass + * through to the real doc, exactly like the overlay. + * + * This is what lets every chat tab's agent write to ITS OWN draft while the + * user switches tabs or browses other branches: run-time reads/writes go + * through here, and the checkout stays a purely visual concern. + * + * ⚠ Callers must keep the returned handle's url to themselves: a clone url fed + * back in as `url` is not recognised as a clone (`clones` is keyed by the + * ORIGINALS), so it gets cloned in turn. Report the original url instead — see + * resolveRunDoc in components/ChatRoot.tsx. */ +export async function resolveInDraft( + repo: Repo, + draftUrl: AutomergeUrl, + url: AutomergeUrl +): Promise> { + const original = canonicalUrl(url) + const draft = await repo.find(draftUrl) + const existing = draft.doc()?.clones?.[original] + if (existing) return repo.find(canonicalUrl(existing.cloneUrl)) + + const originalHandle = await repo.find>(original) + const type = (originalHandle.doc() as any)?.["@patchwork"]?.type + if (typeof type === "string" && SKIPPED_DATATYPES.has(type)) { + return originalHandle + } + // Re-check after the async find: a concurrent resolution (another run, or + // the overlay itself while this draft is checked out) may have recorded a + // clone meanwhile. + const raced = draft.doc()?.clones?.[original] + if (raced) return repo.find(canonicalUrl(raced.cloneUrl)) + + const clonedAt = originalHandle.heads() + const clone = repo.clone(originalHandle) + const cloneUrl = canonicalUrl(clone.url) + draft.change((d) => { + if (!d.clones[original]) d.clones[original] = {cloneUrl, clonedAt} + }) + // Honor whichever record won (ours, or a racing writer's). + const settled = draft.doc()?.clones?.[original] + return settled && settled.cloneUrl !== cloneUrl + ? repo.find(canonicalUrl(settled.cloneUrl)) + : clone +} + +/** Extra fields for a message doc created inside `chatDoc`: agent chats stamp + * their message docs with the skipped datatype so the overlay never forks + * them (messages are created empty and streamed into — a forked message would + * lose its text when the draft is rejected). Empty for ordinary chats. */ +export function agentMessageMetadata( + chatDoc: unknown +): Record { + const type = (chatDoc as {"@patchwork"?: {type?: string}} | undefined)?.[ + "@patchwork" + ]?.type + return type === AGENT_CHAT_TYPE + ? {"@patchwork": {type: AGENT_CHAT_TYPE}} + : {} +} diff --git a/chat/src/lib/context-chat.ts b/chat/src/lib/context-chat.ts new file mode 100644 index 00000000..23902108 --- /dev/null +++ b/chat/src/lib/context-chat.ts @@ -0,0 +1,84 @@ +// Shared plumbing for the context-tool chat variants (watercooler, agent): the +// per-account remembered default plugin set (kept in the `patchwork:tool-storage` +// doc) and the recipe for creating a chat doc that talks about a focused +// document. +import type {Repo, DocHandle} from "@automerge/automerge-repo/slim" +import type {ChatDoc} from "../types" + +export type ToolStorageDoc = { + defaultPlugins?: string[] +} + +export const DEFAULT_CONTEXT_CHAT_PLUGINS = ["computer", "model"] +const OLD_DEFAULT_CONTEXT_CHAT_PLUGINS = ["computer"] + +export function isOldDefaultContextChatPlugins( + plugins: unknown +): plugins is string[] { + return ( + Array.isArray(plugins) && + plugins.length === OLD_DEFAULT_CONTEXT_CHAT_PLUGINS.length && + plugins.every((p, i) => p === OLD_DEFAULT_CONTEXT_CHAT_PLUGINS[i]) + ) +} + +/** Initialise (or migrate) the tool-storage doc's remembered default plugin + * set for new context chats. */ +export function ensureDefaultPlugins(storage: DocHandle) { + if (!Array.isArray(storage.doc()?.defaultPlugins)) { + storage.change((d) => { + if (!Array.isArray(d.defaultPlugins)) + d.defaultPlugins = DEFAULT_CONTEXT_CHAT_PLUGINS.slice() + }) + } else if (isOldDefaultContextChatPlugins(storage.doc()?.defaultPlugins)) { + storage.change((d) => { + if (isOldDefaultContextChatPlugins(d.defaultPlugins)) + d.defaultPlugins = DEFAULT_CONTEXT_CHAT_PLUGINS.slice() + }) + } +} + +/** Mirror a chat's current plugin set back to storage as the remembered + * default for future context chats (no-op when unchanged). */ +export function rememberPluginsAsDefault( + chat: DocHandle, + storage: DocHandle +) { + const plugins = (chat.doc() as any)?.plugins + if (!Array.isArray(plugins)) return + const current = storage.doc()?.defaultPlugins + if ( + Array.isArray(current) && + current.length === plugins.length && + current.every((p, i) => p === plugins[i]) + ) + return + storage.change((d) => { + d.defaultPlugins = plugins.slice() + }) +} + +/** Create a context chat doc: the computer auto-invited (ChatRoot's onMount + * claims the host when `hasComputer` is set — but it stays off nosey, so it + * only replies when @mentioned or replied to) and the plugin set seeded from + * the remembered default. `datatype` is the `@patchwork.type` stamp: `chat` + * for the watercooler (whose chitchat deliberately forks per draft), + * `agent-chat` for the agent tool (on the drafts skip-list, so the + * conversation never forks). Resolved through find so a non-skipped type + * created on a draft forks into the draft's clones. */ +export async function createContextChat( + repo: Repo, + title: string, + defaultPlugins: string[], + datatype = "chat" +): Promise> { + const created = await repo.create2({ + title, + messages: [], + docs: [], + plugins: defaultPlugins.slice(), + "@patchwork": {type: datatype}, + hasComputer: true, + } as any) + return (await repo.find(created.url)) as DocHandle +} diff --git a/chat/src/lib/llm-skills.ts b/chat/src/lib/llm-skills.ts new file mode 100644 index 00000000..7cc926dd --- /dev/null +++ b/chat/src/lib/llm-skills.ts @@ -0,0 +1,265 @@ +// `llm:skill` — a domain instruction pack for the computer, registrable by any +// bundle through the host plugin registry (late-bound: a chat without a skill +// installed simply doesn't list it). A skill contributes: +// - instructions: markdown appended to the system prompt while ACTIVE +// - tools/runTool: optional extra LLM tools the skill implements itself +// +// A skill is active for a run when: +// - the focused document's `@patchwork.type` is in its `datatypes`, or +// - its id is enabled for the chat (`doc.plugins`, via /plugin load ), or +// - the run forces it (e.g. @momputer forces the momputer skill). +// +// Skills are deliberately NOT part of the tier system: the "all" selector never +// auto-enables them (a chitterchatter with selector "all" must not inhale every +// registered skill into its prompt). They appear in the /plugin panel through +// pluginCatalog(), which lists them separately from BUILTIN_PLUGIN_TYPES. +// +// Registration shape (per the registry rules, functions live behind load()): +// { type: "llm:skill", id, name, description, datatypes?, async load() { +// return { instructions, tools?, runTool? } } } + +import {mergePlugins, loadPlugin} from "./registry" + +export type LlmSkillTool = { + name: string + description: string + parameters?: any +} + +export type LlmSkillToolCtx = { + repo: any + handle: any + element: HTMLElement + focusedUrl: string | undefined + applyAutomerge: (doc: any, path: any[], range: any, value: any) => void +} + +export type LlmSkillModule = { + /** Markdown appended to the system prompt while the skill is active. */ + instructions: string + /** Optional extra tool schemas offered to the model while active. */ + tools?: LlmSkillTool[] + /** Implementation for this skill's tools. */ + runTool?: ( + name: string, + args: any, + ctx: LlmSkillToolCtx + ) => unknown | Promise +} + +export type LlmSkillDescription = { + type: "llm:skill" + id: string + name: string + /** One-liner ALWAYS shown to the model (the index) — write it like a + * trigger condition ("applies when…"), not marketing copy. */ + description: string + /** Auto-activate when the focused doc's `@patchwork.type` matches. */ + datatypes?: string[] + /** Built-ins carry load() inline; registry entries load via reg.load(id). */ + load?: () => Promise +} + +export type ActiveSkill = { + id: string + name: string + description: string + module: LlmSkillModule +} + +/** Every known skill: built-ins merged with host-registered ones (built-ins + * win on id conflict, per mergePlugins). */ +export function listSkills(): LlmSkillDescription[] { + return mergePlugins("llm:skill", builtinSkills).filter( + (s: any): s is LlmSkillDescription => + !!s && typeof s.id === "string" && typeof s.description === "string" + ) +} + +/** The skills active for a run: datatype-matched against the focused doc, + * enabled by id on the chat, or forced by the caller. Loads each active + * skill's module (cached); skills that fail to load are skipped. */ +export async function resolveActiveSkills(opts: { + focusedType?: string | null + enabledIds: Set + forcedIds?: string[] +}): Promise { + const out: ActiveSkill[] = [] + for (const desc of listSkills()) { + const byType = + !!opts.focusedType && + Array.isArray(desc.datatypes) && + desc.datatypes.includes(opts.focusedType) + const byId = opts.enabledIds.has(desc.id) + const forced = opts.forcedIds?.includes(desc.id) ?? false + if (!byType && !byId && !forced) continue + const module = await loadSkillModule(desc) + if (!module) continue + out.push({ + id: desc.id, + name: desc.name || desc.id, + description: desc.description, + module, + }) + } + return out +} + +// Loaded modules by skill id. A failed load is NOT cached, so a bundle that +// registers late (or a transient import failure) gets retried next run. +const moduleCache = new Map() + +/** Load one skill by id and return it as an ActiveSkill, or null when the id + * is unknown or its module fails to load. For MID-RUN activation — the + * load_skill tool and read_doc datatype auto-activation — where the caller + * appends the result to its active set and feeds the instructions back to + * the model as tool output (the already-sent system prompt is not rebuilt). */ +export async function activateSkill(id: string): Promise { + const desc = listSkills().find((s) => s.id === id) + if (!desc) return null + const module = await loadSkillModule(desc) + if (!module) return null + return { + id: desc.id, + name: desc.name || desc.id, + description: desc.description, + module, + } +} + +/** Ids of skills whose module has been loaded (for the debug panel). */ +export function loadedSkillIds(): Set { + return new Set(moduleCache.keys()) +} + +/** The cached module for a skill, if it has been loaded (for the debug + * panel — does NOT trigger a load). */ +export function peekSkillModule(id: string): LlmSkillModule | undefined { + return moduleCache.get(id) +} + +async function loadSkillModule( + desc: LlmSkillDescription +): Promise { + const cached = moduleCache.get(desc.id) + if (cached) return cached + try { + // Built-ins expose load() inline; registry entries lose their load() + // crossing the registration boundary, so they load via reg.load(id) + // (which caches the result under `.module`). + const module = + typeof desc.load === "function" + ? await desc.load() + : (await loadPlugin("llm:skill", desc.id))?.module + if (!module || typeof module.instructions !== "string") { + console.warn("[llm-skills] skill has no instructions:", desc.id) + return null + } + moduleCache.set(desc.id, module) + return module + } catch (e) { + console.warn("[llm-skills] failed to load skill:", desc.id, e) + return null + } +} + +/** The "## Skills" system-prompt section: full instructions for active skills, + * plus a one-line index of the inactive ones (so the model knows what exists + * and can tell the user how to activate it). Empty string when there is + * nothing to say. */ +export function skillsPromptSection( + active: ActiveSkill[], + all: LlmSkillDescription[] +): string { + const activeIds = new Set(active.map((s) => s.id)) + const inactive = all.filter((s) => !activeIds.has(s.id)) + if (active.length === 0 && inactive.length === 0) return "" + const parts: string[] = ["## Skills"] + if (active.length > 0) { + parts.push( + "Instruction packs active for this turn. Follow each skill's instructions when working in its domain." + ) + for (const s of active) { + parts.push(`### Skill: ${s.name}\n${s.module.instructions.trim()}`) + } + } + if (inactive.length > 0) { + parts.push( + "Installed but NOT active. A skill auto-activates when you read_doc a document matching its datatypes. To work on a matching document you have NOT read (or before creating one), activate the skill YOURSELF first with the load_skill tool — do not guess a skill's document schema, and do not ask the user to activate it for you:\n" + + inactive + .map((s) => `- ${s.id}: ${s.description}`) + .join("\n") + ) + } + return parts.join("\n\n") +} + +/** The tool schemas contributed by the active skills, deduped against the + * given already-taken names (built-ins and custom tools win). */ +export function skillToolSchemas( + active: ActiveSkill[], + takenNames: Set +): LlmSkillTool[] { + const out: LlmSkillTool[] = [] + for (const s of active) { + for (const t of s.module.tools ?? []) { + if (!t?.name || takenNames.has(t.name)) continue + takenNames.add(t.name) + out.push({ + name: t.name, + description: t.description || `(${s.name} skill tool)`, + parameters: + t.parameters && typeof t.parameters === "object" + ? t.parameters + : {type: "object", properties: {}}, + }) + } + } + return out +} + +/** Dispatch a tool call to the active skill that owns it. Returns null when no + * active skill declares the tool (callers then fall through to other + * dispatchers), else the stringified result. */ +export async function runSkillTool( + active: ActiveSkill[], + name: string, + args: any, + ctx: LlmSkillToolCtx +): Promise { + for (const s of active) { + if (!(s.module.tools ?? []).some((t) => t?.name === name)) continue + if (typeof s.module.runTool !== "function") continue + try { + const result = await s.module.runTool(name, args, ctx) + if (result === undefined) return "(tool ran; no return value)" + return typeof result === "string" + ? result + : JSON.stringify(result, null, 2) + } catch (e: any) { + return `skill tool error (${s.id}): ` + (e?.message || String(e)) + } + } + return null +} + +// ── Built-in skills ────────────────────────────────────────────────────────── +// Reference implementations, and the registry fallback (the same pattern as +// featurePlugins/slashPlugins). The momputer persona used to be an inline +// system-prompt addendum in ChatRoot; it is forced active when the user +// addresses @momputer, and can also be enabled chat-wide via /plugin. + +const MOMPUTER_INSTRUCTIONS = `Be warm, nurturing, and motherly in your responses. Use gentle encouragement, express care and concern, and be supportive like a loving mom would be. You can use pet names like "sweetie", "honey", "dear", etc. Still be helpful and knowledgeable, but with a cozy maternal energy.` + +export const builtinSkills: LlmSkillDescription[] = [ + { + type: "llm:skill", + id: "momputer", + name: "Momputer", + description: + "A warm, nurturing, motherly persona. Applies automatically when the user addresses @momputer.", + async load() { + return {instructions: MOMPUTER_INSTRUCTIONS} + }, + }, +] diff --git a/chat/src/lib/plugin-catalog.ts b/chat/src/lib/plugin-catalog.ts index cb7e8c6b..44a0f269 100644 --- a/chat/src/lib/plugin-catalog.ts +++ b/chat/src/lib/plugin-catalog.ts @@ -15,6 +15,7 @@ import {syntaxPlugins} from "./syntax" import {slashPlugins} from "./slash-plugins" import {messageActionPlugins} from "./message-actions" import {emojiPackPlugins} from "./emoji-packs" +import {listSkills} from "./llm-skills" // Every plugin type this tool tiers over, paired with its built-in declarations. export const BUILTIN_PLUGIN_TYPES: {type: string; builtins: any[]}[] = [ @@ -63,6 +64,10 @@ export interface CatalogEntry { } // One entry per known plugin id (deduped), for the `/plugin` panel. +// llm:skill entries are catalogued here (so the panel and `/plugin load` can +// see them) but deliberately NOT part of BUILTIN_PLUGIN_TYPES: skills must +// never ride the "all" selector (allFullIds), only explicit enabling or a +// focused-doc datatype match activates one. export function pluginCatalog(): CatalogEntry[] { const out: CatalogEntry[] = [] const seen = new Set() @@ -78,6 +83,11 @@ export function pluginCatalog(): CatalogEntry[] { }) } } + for (const s of listSkills()) { + if (seen.has(s.id)) continue + seen.add(s.id) + out.push({id: s.id, type: "llm:skill", name: s.name || s.id, tier: "full"}) + } return out } diff --git a/chat/src/lib/svg-icons.ts b/chat/src/lib/svg-icons.ts index 1b353f5d..80792fd2 100644 --- a/chat/src/lib/svg-icons.ts +++ b/chat/src/lib/svg-icons.ts @@ -52,4 +52,7 @@ export const SVG_ICONS: Record = { '', monitor: '', + copy: '', + check: + '', } diff --git a/chat/src/lib/transcript.test.ts b/chat/src/lib/transcript.test.ts new file mode 100644 index 00000000..9e24f8c2 --- /dev/null +++ b/chat/src/lib/transcript.test.ts @@ -0,0 +1,66 @@ +import {describe, expect, it} from "vitest" +import {formatTranscript} from "./transcript" +import type {ChatMessage} from "../types" + +const message = (m: Partial): ChatMessage => ({ + id: "1", + name: "paul", + text: "hello", + timestamp: 1_700_000_000_000, + ...m, +}) + +describe("formatTranscript", () => { + it("writes a heading and one section per message", () => { + const out = formatTranscript( + [ + message({id: "a", text: "hello"}), + message({id: "b", name: "computer", text: "hi", isComputer: true}), + ], + "Chat 1" + ) + expect(out).toMatch(/^# Chat 1\n/) + expect(out).toContain("2 messages") + expect(out).toContain("**paul**") + expect(out).toContain("hello") + expect(out).toContain("**computer**") + expect(out).toContain("hi") + }) + + it("keeps tool calls and their results", () => { + const out = formatTranscript([ + message({ + name: "computer", + text: "reading it", + richBlocks: [ + {type: "tool-call", content: 'read_doc {"url":"x"}', result: "{}"}, + ], + }), + ]) + expect(out).toContain("```tool-call\nread_doc {\"url\":\"x\"}\n```") + expect(out).toContain("```tool-call-result\n{}\n```") + }) + + it("lengthens the fence around a body that contains backticks", () => { + const out = formatTranscript([ + message({richBlocks: [{type: "code", content: "```js\nx\n```"}]}), + ]) + expect(out).toContain("````code\n```js\nx\n```\n````") + }) + + it("names attachments and the message a reply answers", () => { + const out = formatTranscript([ + message({id: "a", text: "look at this"}), + message({ + id: "b", + name: "chee", + text: "nice", + replyTo: "a", + imageUrl: "automerge:img" as any, + imageName: "shot.png", + }), + ]) + expect(out).toContain("> in reply to **paul**: look at this") + expect(out).toContain("[image: shot.png automerge:img]") + }) +}) diff --git a/chat/src/lib/transcript.ts b/chat/src/lib/transcript.ts new file mode 100644 index 00000000..e44b3e43 --- /dev/null +++ b/chat/src/lib/transcript.ts @@ -0,0 +1,176 @@ +// Export a chat's whole conversation as markdown — plain text you can paste +// into another LLM. Most messages live in their own doc (the chat holds a ref), +// so building a transcript means reading every referenced message back. +import type {AutomergeUrl, Repo} from "@automerge/automerge-repo/slim" +import type {ChatDoc, ChatMessage} from "../types" + +/** Copy a chat's whole history to the clipboard as markdown. Returns the + * transcript it wrote. */ +export async function copyChatTranscript( + repo: Repo, + url: AutomergeUrl +): Promise { + const doc = (await repo.find(url)).doc() + const text = formatTranscript(await resolveMessages(repo, doc), doc?.title) + await writeToClipboard(text) + return text +} + +/** Render resolved messages as markdown: a header, then one section per + * message with its attachments and any tool calls the computer made. */ +export function formatTranscript( + messages: ChatMessage[], + title?: string +): string { + const byId = new Map( + messages.filter((m) => m.id).map((m) => [m.id, m] as const) + ) + const parts = [ + "# " + (title || "Chat"), + "", + messages.length + + (messages.length === 1 ? " message" : " messages") + + ", exported " + + formatStamp(Date.now()) + + ".", + ] + for (const msg of messages) { + parts.push( + "", + "---", + "", + "**" + (msg.name || "unknown") + "** · " + formatStamp(msg.timestamp), + "", + messageBody(msg, byId) + ) + } + return parts.join("\n") + "\n" +} + +async function resolveMessages( + repo: Repo, + doc: ChatDoc | undefined +): Promise { + const messages: ChatMessage[] = [] + for (const entry of (doc?.messages ?? []) as any[]) { + if (!entry) continue + if (!entry.ref || !entry.url) { + messages.push(entry as ChatMessage) + continue + } + try { + const msg = (await repo.find(entry.url)).doc() + messages.push(msg ?? missingMessage(entry.timestamp)) + } catch { + // A message doc that hasn't synced to this peer — keep its slot so + // the transcript doesn't silently close a gap in the conversation. + messages.push(missingMessage(entry.timestamp)) + } + } + return messages +} + +function missingMessage(timestamp?: number): ChatMessage { + return { + id: "", + name: "unknown", + text: "[message not available on this device]", + timestamp: timestamp || 0, + } +} + +function messageBody(msg: ChatMessage, byId: Map): string { + const blocks: string[] = [] + const parent = msg.replyTo ? byId.get(msg.replyTo) : undefined + if (parent) { + blocks.push( + "> in reply to **" + + (parent.name || "unknown") + + "**: " + + oneLine(parent.text) + ) + } + if (msg.text?.trim()) blocks.push(msg.text.trim()) + const attachments = attachmentLines(msg) + if (attachments.length) blocks.push(attachments.join("\n")) + for (const block of msg.richBlocks ?? []) { + const kind = block.type || "block" + blocks.push( + fence(kind, [block.meta, block.content].filter(Boolean).join("\n")) + ) + if (block.result) blocks.push(fence(kind + "-result", block.result)) + } + return blocks.join("\n\n") || "_(no text)_" +} + +function attachmentLines(msg: ChatMessage): string[] { + const lines: string[] = [] + if (msg.imageUrl) { + lines.push("[image: " + (msg.imageName || "image") + " " + msg.imageUrl + "]") + } + if (msg.voiceUrl) { + const length = msg.voiceDuration + ? " " + Math.round(msg.voiceDuration) + "s" + : "" + lines.push("[voice note" + length + " " + msg.voiceUrl + "]") + } + if (msg.gifSelfieUrl) lines.push("[gif selfie " + msg.gifSelfieUrl + "]") + for (const file of msg.files ?? []) { + lines.push("[file: " + file.name + " " + file.url + "]") + } + for (const embed of msg.embeds ?? []) { + lines.push( + "[document: " + + (embed.title || embed.type || "document") + + " " + + embed.docUrl + + "]" + ) + } + if (msg.quickReplies?.length) { + lines.push("[suggested replies: " + msg.quickReplies.join(" / ") + "]") + } + return lines +} + +/** A code fence long enough to survive backticks in the body. */ +function fence(language: string, body: string): string { + let longest = 0 + for (const run of body.match(/`+/g) ?? []) { + longest = Math.max(longest, run.length) + } + const ticks = "`".repeat(Math.max(3, longest + 1)) + return ticks + language + "\n" + body + "\n" + ticks +} + +function oneLine(text: string, max = 100): string { + const flat = (text || "").replace(/\s+/g, " ").trim() + return flat.length > max ? flat.slice(0, max - 1) + "…" : flat +} + +function formatStamp(timestamp: number): string { + if (!timestamp) return "unknown time" + return new Date(timestamp).toLocaleString([], { + dateStyle: "medium", + timeStyle: "short", + }) +} + +async function writeToClipboard(text: string) { + try { + await navigator.clipboard.writeText(text) + return + } catch {} + // execCommand still works where the async clipboard API is blocked (an + // insecure origin, or a permission the host hasn't granted the view). + const scratch = document.createElement("textarea") + scratch.value = text + scratch.style.cssText = "position:fixed;top:0;left:0;opacity:0" + document.body.append(scratch) + scratch.select() + try { + if (!document.execCommand("copy")) throw new Error("copy rejected") + } finally { + scratch.remove() + } +} diff --git a/chat/src/styles/chat.css b/chat/src/styles/chat.css index c8879f3d..dfdaa923 100644 --- a/chat/src/styles/chat.css +++ b/chat/src/styles/chat.css @@ -8,7 +8,7 @@ both light and dark themes. The oklch fallbacks keep the tool legible if it's ever loaded outside the host. ================================================================ */ - .chat-root { + .chat-root, .agent-root { /* Fill/line surfaces derive from the editor tokens so the chat body matches the active document/editor surface exactly. */ --bg-dark: var(--editor-fill, var(--studio-fill, oklch(0.15 0.03 270))); /* base surface (messages) */ @@ -30,6 +30,7 @@ --text-secondary: var(--studio-line-offset-30, oklch(1 0 0 / 0.6)); --text-muted: var(--studio-line-offset-50, oklch(1 0 0 / 0.4)); --link: var(--studio-link-text, var(--studio-link, oklch(0.75 0.15 250))); + --danger: var(--studio-danger-text, var(--studio-danger, oklch(0.65 0.2 25))); } /* ---- Reset ---- */ @@ -346,6 +347,61 @@ font-size:11px; color:var(--text-muted); padding:6px 10px 2px; text-transform:uppercase; letter-spacing:0.5px; border-top:1px solid var(--border); } + /* Agent-draft review embed: draft name + Accept/Reject. */ + .chat-draft-review { + display:flex; align-items:center; gap:8px; + margin-top:6px; padding:8px 10px; + border:1px solid var(--border); border-radius:8px; + background:var(--bg-darkest); font-size:13px; + } + .chat-draft-review-icon { flex-shrink:0; color:var(--accent-text, var(--text-secondary)); } + .chat-draft-review-name { + color:var(--text-primary); font-weight:600; + overflow:hidden; text-overflow:ellipsis; white-space:nowrap; + } + .chat-draft-review-actions { display:flex; gap:6px; margin-left:auto; } + .chat-draft-review-btn { + font:inherit; font-size:12px; cursor:pointer; + padding:3px 12px; border-radius:var(--studio-radius-round, 999px); + border:1px solid var(--accent-line, currentColor); + background:transparent; color:var(--accent-text, var(--text-primary)); + } + .chat-draft-review-btn:disabled { opacity:0.5; cursor:default; } + .chat-draft-review-accept:hover:not(:disabled) { background:var(--accent); color:var(--accent-fg); } + .chat-draft-review-reject { border-color:var(--border); color:var(--text-secondary); } + .chat-draft-review-reject:hover:not(:disabled) { background:var(--bg-hover); color:var(--text-primary); } + .chat-draft-review-state { + margin-left:auto; font-size:12px; color:var(--text-muted); + text-transform:uppercase; letter-spacing:0.5px; + } + .chat-draft-review[data-state="accepted"] .chat-draft-review-state { color:var(--accent-text, var(--text-secondary)); } + .chat-draft-review[data-state="closed"] { opacity:0.65; } + /* Scenario picker embed: chip per branch, accept-selected / reject-all. */ + .chat-scenario-review { + display:flex; flex-direction:column; gap:8px; + margin-top:6px; padding:8px 10px; + border:1px solid var(--border); border-radius:8px; + background:var(--bg-darkest); font-size:13px; + } + .chat-scenario-review-header { display:flex; align-items:center; gap:8px; } + .chat-scenario-review-title { color:var(--text-primary); font-weight:600; white-space:nowrap; } + .chat-scenario-review-chips { display:flex; flex-wrap:wrap; gap:6px; } + .chat-scenario-chip { + font:inherit; font-size:12px; cursor:pointer; + padding:3px 12px; border-radius:var(--studio-radius-round, 999px); + border:1px solid var(--border); + background:transparent; color:var(--text-secondary); + max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; + } + .chat-scenario-chip:hover:not(:disabled) { background:var(--bg-hover); color:var(--text-primary); } + .chat-scenario-chip[data-selected] { + border-color:var(--accent-line, currentColor); + background:var(--accent); color:var(--accent-fg); + } + .chat-scenario-chip:disabled { cursor:default; } + .chat-scenario-chip:disabled:not([data-selected]) { opacity:0.6; } + .chat-scenario-review[data-state="closed"], + .chat-scenario-review[data-state="rejected"] { opacity:0.65; } .shiki { margin:0 !important; padding:10px 12px !important; font-size:13px !important; line-height:1.5 !important; overflow-x:auto; border-radius:6px; } .shiki code { font-family:ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace !important; } @@ -639,6 +695,53 @@ font-style:normal; } .chat-stop-btn:hover { background:var(--bg-hover); color:var(--text-primary); } + + /* Fold-out skills debug panel (above the input) */ + .chat-skills-debug { + flex-shrink:0; padding:0 16px 2px; font-size:11px; + font-family:monospace; color:var(--text-muted); + } + .chat-skills-debug summary { + cursor:pointer; user-select:none; list-style:none; opacity:0.7; + } + .chat-skills-debug summary::before { content:"▸ "; } + .chat-skills-debug[open] summary::before { content:"▾ "; } + .chat-skills-debug summary:hover { opacity:1; } + .chat-skills-debug-count { + margin-left:6px; padding:0 5px; border-radius:8px; + background:var(--accent-soft); color:var(--accent-text); + } + .chat-skills-debug-version { margin-left:6px; opacity:0.6; } + .chat-skills-debug-list { + margin:4px 0; padding:6px 8px; border:1px solid var(--border); + border-radius:6px; background:var(--bg-mid); + max-height:180px; overflow-y:auto; overscroll-behavior:contain; + } + .chat-skills-debug-row { + display:grid; grid-template-columns:auto auto 1fr; gap:2px 8px; + align-items:baseline; padding:3px 0; + } + .chat-skills-debug-row + .chat-skills-debug-row { + border-top:1px solid var(--border); + } + .chat-skills-debug-state { + padding:0 5px; border-radius:8px; background:var(--bg-hover); + } + .chat-skills-debug-state[data-state="active"] { + background:var(--accent-soft); color:var(--accent-text); + } + .chat-skills-debug-id { color:var(--text-primary); } + .chat-skills-debug-meta { color:var(--text-muted); } + .chat-skills-debug-desc { + grid-column:1 / -1; color:var(--text-secondary); + white-space:normal; + } + .chat-skills-debug-empty { padding:4px 0; font-style:italic; } + .chat-skills-debug-note { + margin-top:6px; padding-top:4px; border-top:1px solid var(--border); + opacity:0.7; + } + .chat-input-wrapper { flex-shrink:0; padding:0 16px 16px; position:relative; } /* Emoji/emoticon autocomplete popup */ @@ -1298,3 +1401,73 @@ /* Time gap lines */ .chat-time-gap::before { left:10%; right:10%; } } + + /* ================================================================ + AGENT — the multi-chat context tool. A tab bar on top (one tab per + chat in the focused doc's lmchats array) over the ordinary ChatRoot, + which fills the positioned .agent-chat-pane below. + ================================================================ */ + .agent-root { + position:absolute; inset:0; + display:flex; flex-direction:column; + font-family:var(--studio-family-sans, system-ui,-apple-system,sans-serif); + font-size:var(--studio-font-size, 15px); + background:var(--bg-dark); color:var(--text-primary); + overflow:hidden; box-sizing:border-box; + } + .agent-root *, .agent-root *::before, .agent-root *::after { box-sizing:border-box; } + .agent-tabs { + display:flex; align-items:flex-end; gap:2px; padding:6px 6px 0; + background:var(--bg-darkest); border-bottom:1px solid var(--border); + flex-shrink:0; overflow-x:auto; scrollbar-width:thin; + } + .agent-tab { + background:none; border:none; border-bottom:2px solid transparent; + color:var(--text-secondary); cursor:pointer; + font:inherit; font-size:12px; padding:4px 10px 5px; + border-radius:6px 6px 0 0; white-space:nowrap; + max-width:160px; overflow:hidden; text-overflow:ellipsis; + flex-shrink:0; + } + .agent-tab:hover { background:var(--bg-hover); color:var(--text-primary); } + .agent-tab[data-selected] { + background:var(--bg-dark); color:var(--text-primary); + border-bottom-color:var(--accent); + } + .agent-tab-rename { + background:var(--bg-input); border:1px solid var(--accent-line); + color:var(--text-primary); font:inherit; font-size:12px; + padding:3px 8px; border-radius:6px 6px 0 0; width:120px; outline:none; + flex-shrink:0; + } + .agent-tab-new { + background:none; border:none; color:var(--text-muted); cursor:pointer; + font:inherit; font-size:14px; line-height:1; padding:5px 8px 6px; + border-radius:6px 6px 0 0; flex-shrink:0; + } + .agent-tab-new:hover { background:var(--bg-hover); color:var(--text-primary); } + .agent-tabs-end { + margin-left:auto; align-self:center; flex-shrink:0; + display:flex; align-items:center; gap:2px; + } + .agent-tab-copy { + display:flex; align-items:center; background:none; border:none; + color:var(--text-muted); cursor:pointer; padding:4px 5px; + border-radius:5px; line-height:0; + } + .agent-tab-copy:hover:not(:disabled) { background:var(--bg-hover); color:var(--text-primary); } + .agent-tab-copy:disabled { opacity:0.4; cursor:default; } + .agent-tab-copy[data-state="copied"] { color:var(--accent-text); } + .agent-tab-copy[data-state="failed"] { color:var(--danger); } + /* Deploy marker: tells at a glance whether the synced bundle is current. */ + .agent-version { + align-self:center; flex-shrink:0; + font-size:10px; color:var(--text-muted); padding:0 6px 4px; + user-select:none; white-space:nowrap; + } + .agent-panes { position:relative; flex:1; min-height:0; } + /* Panes stay MOUNTED when their tab is hidden (so each chat's computer + keeps running) — inactive ones are hidden with visibility rather than + display so scroll positions survive tab switches. */ + .agent-pane { position:absolute; inset:0; } + .agent-pane:not([data-selected]) { visibility:hidden; pointer-events:none; } diff --git a/chat/src/version.ts b/chat/src/version.ts new file mode 100644 index 00000000..08f92643 --- /dev/null +++ b/chat/src/version.ts @@ -0,0 +1,3 @@ +/** Shown in the chat UI (agent tab bar) so a glance tells you whether the + * deployed bundle has synced. Bump on every deploy. */ +export const CHAT_VERSION = "v0.0.10" diff --git a/codemirror-base/src/tool.tsx b/codemirror-base/src/tool.tsx index 757a10b9..39ac3c67 100644 --- a/codemirror-base/src/tool.tsx +++ b/codemirror-base/src/tool.tsx @@ -129,10 +129,8 @@ export function CodeMirrorEditor(props: PatchworkToolProps) { }; const decorations = () => { - const targetRefs = commentTargets(); - const emphasisRefs = emphasisTargets(); return RangeSet.of( - buildCommentDecorations(targetRefs, emphasisRefs), + buildCommentDecorations(commentTargets(), emphasisTargets()), true // sort ranges ); }; @@ -244,7 +242,7 @@ export function CodeMirrorEditor(props: PatchworkToolProps) { // The editor is only mounted once the datatype's extensions (themes, syntax // highlighting) are in hand, so it never paints unthemed first. const [datatypeExtensions] = createResource(() => - loadCodeMirrorExtensionsForDoc(props.handle) + loadCodeMirrorExtensionsForDoc(props.handle, props.element, props.repo) ); // Base CodeMirror extensions (context-specific, not language-specific) @@ -372,8 +370,21 @@ function commentTargetStyle(isEmphasised: boolean): string { `; } +// The context handed to context-aware `codemirror:extension` modules. A +// registered module may be a plain Extension (or array), or a FACTORY taking +// this context — that's how out-of-package extensions (e.g. the corkboard's +// provenance highlighter) reach the document handle and the provider tree +// without a build-time dependency on this package. +export type CodeMirrorExtensionContext = { + handle: DocHandle; + element: HTMLElement; + repo: Repo; +}; + async function loadCodeMirrorExtensionsForDoc( - handle: DocHandle + handle: DocHandle, + element: HTMLElement, + repo: Repo ): Promise { const docType = (handle.doc() as any)?.["@patchwork"]?.type; const registry = getRegistry("codemirror:extension"); @@ -386,8 +397,10 @@ async function loadCodeMirrorExtensionsForDoc( ); }) ); + const context: CodeMirrorExtensionContext = { handle, element, repo }; return loaded.flatMap((ext) => { - const impl = ext.module; + const impl = + typeof ext.module === "function" ? ext.module(context) : ext.module; return Array.isArray(impl) ? impl : [impl]; }); } diff --git a/comments-view/src/CommentsView.tsx b/comments-view/src/CommentsView.tsx index 82b75ff0..21c1ebde 100644 --- a/comments-view/src/CommentsView.tsx +++ b/comments-view/src/CommentsView.tsx @@ -49,10 +49,12 @@ export function CommentsView(props: { element: HTMLElement }) { // `selection` is read-only input (driven by the active editor), `highlight` // is our output. Splitting them avoids the feedback loop a single shared - // map would have. + // map would have. `openThread` is a one-shot reveal request from another + // view (e.g. the drafts timeline), consumed below. const [focusDoc, focusHandle] = subscribeDoc<{ selection: Record; highlight: Record; + openThread?: { url: AutomergeUrl; at: number }; }>(props.element, { type: "patchwork:focus" }); const [, contactHandle] = subscribeDoc>(props.element, { @@ -213,6 +215,45 @@ export function CommentsView(props: { element: HTMLElement }) { ? "secondary" : "inactive"; + // Rendered thread-card elements, for scrolling a revealed thread into view. + // Stale entries for unmounted cards are harmless — only urls in the current + // displayed list are ever looked up. + const cardEls = new Map(); + + // Consume `openThread` reveal requests from the focus doc (written by e.g. + // the drafts timeline): once the thread's card is in the rendered list, pin + // and select it — same as clicking the card — scroll it into view, and + // delete the request. A request whose card never appears (a resolved + // thread, which the panel doesn't list) is dropped once it goes stale, so + // it can't pin some unrelated selection much later. + const OPEN_THREAD_TTL_MS = 5_000; + createEffect(() => { + const request = focusDoc()?.openThread; + const handle = focusHandle(); + if (!request || !handle) return; + if (Date.now() - request.at > OPEN_THREAD_TTL_MS) { + handle.change((doc) => { + delete doc.openThread; + }); + return; + } + // Not rendered yet: wait — the effect re-runs as the list fills in. + if (!displayedThreadUrls().includes(request.url)) return; + const targetUrls = threadTargetUrlMap().get(request.url) ?? []; + setPinnedThread(request.url); + const next: Record = {}; + for (const u of targetUrls) next[u] = true; + handle.change((doc) => { + doc.selection = next; + delete doc.openThread; + }); + requestAnimationFrame(() => + cardEls + .get(request.url) + ?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + ); + }); + onCleanup(() => { const handle = focusHandle(); if (!handle) return; @@ -254,6 +295,7 @@ export function CommentsView(props: { element: HTMLElement }) { {(threadUrl) => (
cardEls.set(threadUrl, el)} onClick={(e) => onClickThreadCard(e, threadUrl)} > =22.13'} + + '@automerge/automerge-subduction@0.13.0': + resolution: {integrity: sha512-7XlCIS0GH3ctboE8UPmD9rfiv203Q8n5Lx63xE6xCgtlpwecDKqzLDp77e9hUEo76j17Y8bYQyfsROqaByLf5A==} + + '@automerge/automerge-subduction@0.15.0': + resolution: {integrity: sha512-UxD3hfzZoH9yj2/YQZZSr9bJH7R/l+xIIZ9nMPMpsBOAlEFI5AEC4hkVOqC52QOCwq4bcqek0gTYkSWWfNvc2A==} + + '@automerge/automerge-subduction@0.16.1': + resolution: {integrity: sha512-alH7U4eYn0O7sT6hNLv7CJhNODJi+QD6qfRQTNfZCxL3Q5JZdJ8lFpdvXUkBf+I3bu0GLvVlTaYPWGrBihjpIw==} + + '@automerge/automerge@3.3.0-fragments.1': + resolution: {integrity: sha512-Im2n3qcYSuIsLDSjofTRo7CZyyvFn5pKLSCCO5sjWzESw+9q9G25ZIrL2mczDX43cXtyzi+w/4b9VUaiNdY56Q==} + + '@automerge/automerge@3.3.2': + resolution: {integrity: sha512-9vCdCL7pdQwUra66SBxPVHr+/t9epXKni9KDeak2rNBFMzABVh2u6gSpcwxi3jkR5qr047jVqZv9DlrOfVxLFw==} + + '@automerge/automerge@3.4.1': + resolution: {integrity: sha512-zsZpbs/iDPvp+ZojIYd+gxmbcPVz2Xbkcx778G8zrt3E0zS+6saHJOm666lOuZyNRlTV4wHw9qzGTKueedeCsQ==} + + '@automerge/vanillajs@2.6.0-subduction.29': + resolution: {integrity: sha512-Vbpz8D4PE8CqVtuTN/2D1XWM0ZhrpFD1zpmbeexn8FQestDR4bIWfp8rnfPFoqE6R/t6+96Rr9wTkZhVba0Bgw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/view@6.43.9': + resolution: {integrity: sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@inkandswitch/patchwork-bootloader@0.2.8': + resolution: {integrity: sha512-jXH5Q2JF4zoyR3nw0JYTi8yLfXfFC0PsvDKVXs+Ko48lZb9NRTWphRrvrtoM+AoDVEx/lgBEHjzA6uKqHlj8AA==} + peerDependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.29 + '@automerge/automerge-repo-keyhive': 0.3.0-alpha.sub.1c + '@automerge/vanillajs': 2.6.0-subduction.29 + + '@inkandswitch/patchwork-elements@1.0.0': + resolution: {integrity: sha512-DoHfqmUPEAcdR7rlPVMi5Lon/AsitwY1uLdA1Pg+KOVbXsjKwrvPJIqkVo2F4uDSccO1Rluwau2cjT5Rg7w5IQ==} + peerDependencies: + '@automerge/automerge-repo': '*' + '@inkandswitch/patchwork-filesystem': workspace:^ + '@inkandswitch/patchwork-plugins': workspace:^ + '@inkandswitch/patchwork-providers': workspace:^ + '@types/react': '*' + react: '*' + solid-js: '*' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + solid-js: + optional: true + + '@inkandswitch/patchwork-elements@2.0.0': + resolution: {integrity: sha512-P1fF5IBwVQQwklGcInzW+SzYdMZ60Er5lfXlSLTc7E1QB3KDlpUrSC9wsBcqTG+OOEwZQ53G4MtTGrdfMqYFmw==} + peerDependencies: + '@automerge/automerge-repo': '*' + '@inkandswitch/patchwork-filesystem': ^0.0.8 + '@inkandswitch/patchwork-plugins': ^0.0.11 + '@inkandswitch/patchwork-providers': ^0.3.0 + '@types/react': '*' + react: '*' + solid-js: '*' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + solid-js: + optional: true + + '@inkandswitch/patchwork-filesystem@0.0.8': + resolution: {integrity: sha512-gfS7OHC2W1xG0EWl5yQMPQl+Ra/hLsgus9X0VmwHvsovFSR9tun7Clz3XcIyAqEU7VpEtCkGrxHkMgw54SJxlg==} + peerDependencies: + '@automerge/automerge': '*' + '@automerge/automerge-repo': '*' + + '@inkandswitch/patchwork-plugins@0.0.11': + resolution: {integrity: sha512-ElwDEixpZN64gdoE7EU8QFz4WzvyJt6j4Zn0pTezsW6SzGlx+Rc6Ox+7POK7gqEzQEcl3Iqo8+QdYRucYllRFw==} + peerDependencies: + '@automerge/automerge': '*' + '@automerge/automerge-repo': '*' + '@inkandswitch/patchwork-filesystem': ^0.0.8 + + '@inkandswitch/patchwork-providers@0.3.0': + resolution: {integrity: sha512-CUxWbONfOiz5SCzOpX/7zDTMePWgq7e4778eBJpbjC5Et4lnyL2/UrOZ6YJ4HiCG7r7MbfM86GUi63bqjTeutg==} + peerDependencies: + '@automerge/automerge-repo': '*' + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyhive/keyhive@0.0.0-alpha.56': + resolution: {integrity: sha512-hx/MRkRqCSJFIh5jHKs9HkzDQy4tUZzgaRlc85+8t22VciubmZAU1FyugmYboskPUxUGwqZC1GarwXfxhrp5DA==} + + '@keyhive/keyhive@0.0.0-alpha.57g': + resolution: {integrity: sha512-o8u0emy+vQQPmYKS8HqyjAUw9hXEApXv3tt+G9ZUJUPMVTuaor17N4BKFmNdAiH2Jt/TTX7Vsr8QaRNRf0FOIA==} + + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@rollup/rollup-android-arm-eabi@4.62.5': + resolution: {integrity: sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.5': + resolution: {integrity: sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.5': + resolution: {integrity: sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.5': + resolution: {integrity: sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.5': + resolution: {integrity: sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.5': + resolution: {integrity: sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + resolution: {integrity: sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + resolution: {integrity: sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + resolution: {integrity: sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.5': + resolution: {integrity: sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + resolution: {integrity: sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.5': + resolution: {integrity: sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + resolution: {integrity: sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + resolution: {integrity: sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + resolution: {integrity: sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + resolution: {integrity: sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + resolution: {integrity: sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.5': + resolution: {integrity: sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.5': + resolution: {integrity: sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.5': + resolution: {integrity: sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.5': + resolution: {integrity: sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + resolution: {integrity: sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + resolution: {integrity: sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.5': + resolution: {integrity: sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.5': + resolution: {integrity: sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/serviceworker@0.0.153': + resolution: {integrity: sha512-/cg6dFEkNchJLyRCGo4Gb8mF200qr3xskM5dCPgbtK0OzXxcFcXa6BEBdyG7JksRsTrvCR+V6aFPncoOYAwYhQ==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + babel-plugin-jsx-dom-expressions@0.40.10: + resolution: {integrity: sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-preset-solid@1.9.15: + resolution: {integrity: sha512-GBmg1OiPb+OwcH51XbDAKPtvrPfQW7rCJTJxcp8+yhtWwN+kqnbEJk2SgVybd+uhTxTKAvjaFyiQSr/eUZBwzg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.15 + peerDependenciesMeta: + solid-js: + optional: true + + base-x@4.0.1: + resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + baseline-browser-mapping@2.11.19: + resolution: {integrity: sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs58@5.0.0: + resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + + bs58check@3.0.1: + resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} + + bs58check@4.0.0: + resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} + hasBin: true + + cbor-x@1.6.6: + resolution: {integrity: sha512-8QiD9PGOxyQHo7s2pzwTBH6lTjqekxPdl9Aq6fXvZgCuCJHOht1puDEA/fTr6mciB76c+M+Gi0qT2i1a4pm4Wg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.414: + resolution: {integrity: sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + happy-dom@15.11.7: + resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} + engines: {node: '>=18.0.0'} + + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + rollup@4.62.5: + resolution: {integrity: sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + solid-js@1.9.15: + resolution: {integrity: sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + tinyargs@0.1.4: + resolution: {integrity: sha512-5OpdhMRRE70j0zT7mrBpx12FJI+y0EGx8zMe8Vl2/aiwGgpW3X70OVHlsHmHeeG0ncnnWdq1o+k/S5d9ONgzGA==} + engines: {node: '>=14'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite-plugin-css-injected-by-js@3.5.2: + resolution: {integrity: sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==} + peerDependencies: + vite: '>2.0.0-0' + + vite-plugin-solid@2.11.14: + resolution: {integrity: sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0 + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xstate@5.32.5: + resolution: {integrity: sha512-ULazi1oe6wGrXl0Frb6otSlkm5HLifbbVTkMk5kkSKqz4TkxJaVpnl6jOJwKeid3ORPxYyZQgNLUSYX9q65SIA==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.3)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.21 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.21 + '@automerge/automerge-subduction': 0.13.0 + '@keyhive/keyhive': 0.0.0-alpha.57g + cbor-x: 1.6.6 + eventemitter3: 5.0.4 + isomorphic-ws: 5.0.0(ws@8.21.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + - ws + + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.29': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.29 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.29': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.29 + debug: 4.4.3 + eventemitter3: 5.0.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.21': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.21 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + isomorphic-ws: 5.0.0(ws@8.21.3) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.29': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.29 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.29': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.29 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo@2.6.0-subduction.21': + dependencies: + '@automerge/automerge': 3.4.1 + '@automerge/automerge-subduction': 0.13.0 + bs58check: 3.0.1 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 9.0.1 + ws: 8.21.3 + xstate: 5.32.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo@2.6.0-subduction.29': + dependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-subduction': 0.15.0 + bs58check: 4.0.0 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 14.0.2 + ws: 8.21.3 + xstate: 5.32.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo@2.6.0-subduction.48': + dependencies: + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.16.1 + bs58check: 4.0.0 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 14.0.2 + ws: 8.21.3 + xstate: 5.32.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-subduction@0.13.0': {} + + '@automerge/automerge-subduction@0.15.0': {} + + '@automerge/automerge-subduction@0.16.1': {} + + '@automerge/automerge@3.3.0-fragments.1': {} + + '@automerge/automerge@3.3.2': {} + + '@automerge/automerge@3.4.1': {} + + '@automerge/vanillajs@2.6.0-subduction.29': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.29 + '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.29 + '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.29 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.29 + '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.29 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + optional: true + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/view@6.43.9': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@inkandswitch/patchwork-bootloader@0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.3))(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.29)(solid-js@1.9.15)': + dependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo-keyhive': 0.3.0-alpha.sub.1c(ws@8.21.3) + '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.29 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.29 + '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.29 + '@automerge/automerge-subduction': 0.15.0 + '@automerge/vanillajs': 2.6.0-subduction.29 + '@inkandswitch/patchwork-elements': 2.0.0(@automerge/automerge-repo@2.6.0-subduction.48)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.48))(solid-js@1.9.15) + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)) + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.48) + '@keyhive/keyhive': 0.0.0-alpha.56 + '@types/debug': 4.1.13 + debug: 4.4.3 + resolve.exports: 2.0.3 + service-worker-types: '@types/serviceworker@0.0.153' + tinyargs: 0.1.4 + transitivePeerDependencies: + - '@types/react' + - bufferutil + - react + - solid-js + - supports-color + - utf-8-validate + + '@inkandswitch/patchwork-elements@1.0.0(@automerge/automerge-repo@2.6.0-subduction.48)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.48))(solid-js@1.9.15)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)) + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.48) + debug: 4.4.3 + optionalDependencies: + solid-js: 1.9.15 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-elements@2.0.0(@automerge/automerge-repo@2.6.0-subduction.48)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.48))(solid-js@1.9.15)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)) + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.48) + debug: 4.4.3 + optionalDependencies: + solid-js: 1.9.15 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)': + dependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + debug: 4.4.3 + resolve.exports: 2.0.3 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1))': + dependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1) + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + debug: 4.4.3 + eventemitter3: 5.0.4 + resolve.exports: 2.0.3 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.48)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.48 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyhive/keyhive@0.0.0-alpha.56': {} + + '@keyhive/keyhive@0.0.0-alpha.57g': {} + + '@marijn/find-cluster-break@1.0.4': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@noble/hashes@1.8.0': {} + + '@rollup/rollup-android-arm-eabi@4.62.5': + optional: true + + '@rollup/rollup-android-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.5': + optional: true + + '@rollup/rollup-darwin-x64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.5': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/ms@2.1.0': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/serviceworker@0.0.153': {} + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@20.19.43))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@20.19.43) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + babel-plugin-jsx-dom-expressions@0.40.10(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-preset-solid@1.9.15(@babel/core@7.29.7)(solid-js@1.9.15): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.10(@babel/core@7.29.7) + optionalDependencies: + solid-js: 1.9.15 + + base-x@4.0.1: {} + + base-x@5.0.1: {} + + baseline-browser-mapping@2.11.19: {} + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.19 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.414 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + bs58@5.0.0: + dependencies: + base-x: 4.0.1 + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + + bs58check@3.0.1: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 5.0.0 + + bs58check@4.0.0: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 6.0.0 + + cac@6.7.14: {} + + caniuse-lite@1.0.30001810: {} + + cbor-extract@2.2.2: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 + '@cbor-extract/cbor-extract-linux-x64': 2.2.2 + '@cbor-extract/cbor-extract-win32-x64': 2.2.2 + optional: true + + cbor-x@1.6.6: + optionalDependencies: + cbor-extract: 2.2.2 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + convert-source-map@2.0.0: {} + + crelt@1.0.7: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + detect-libc@2.1.2: + optional: true + + electron-to-chromium@1.5.414: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + fast-sha256@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + happy-dom@15.11.7: + dependencies: + entities: 4.5.0 + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + + html-entities@2.3.3: {} + + is-what@4.1.16: {} + + isomorphic-ws@5.0.0(ws@8.21.3): + dependencies: + ws: 8.21.3 + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + loupe@3.2.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-releases@2.0.53: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + resolve.exports@2.0.3: {} + + rollup@4.62.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.5 + '@rollup/rollup-android-arm64': 4.62.5 + '@rollup/rollup-darwin-arm64': 4.62.5 + '@rollup/rollup-darwin-x64': 4.62.5 + '@rollup/rollup-freebsd-arm64': 4.62.5 + '@rollup/rollup-freebsd-x64': 4.62.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.5 + '@rollup/rollup-linux-arm-musleabihf': 4.62.5 + '@rollup/rollup-linux-arm64-gnu': 4.62.5 + '@rollup/rollup-linux-arm64-musl': 4.62.5 + '@rollup/rollup-linux-loong64-gnu': 4.62.5 + '@rollup/rollup-linux-loong64-musl': 4.62.5 + '@rollup/rollup-linux-ppc64-gnu': 4.62.5 + '@rollup/rollup-linux-ppc64-musl': 4.62.5 + '@rollup/rollup-linux-riscv64-gnu': 4.62.5 + '@rollup/rollup-linux-riscv64-musl': 4.62.5 + '@rollup/rollup-linux-s390x-gnu': 4.62.5 + '@rollup/rollup-linux-x64-gnu': 4.62.5 + '@rollup/rollup-linux-x64-musl': 4.62.5 + '@rollup/rollup-openbsd-x64': 4.62.5 + '@rollup/rollup-openharmony-arm64': 4.62.5 + '@rollup/rollup-win32-arm64-msvc': 4.62.5 + '@rollup/rollup-win32-ia32-msvc': 4.62.5 + '@rollup/rollup-win32-x64-gnu': 4.62.5 + '@rollup/rollup-win32-x64-msvc': 4.62.5 + fsevents: 2.3.3 + + semver@6.3.1: {} + + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + + siginfo@2.0.0: {} + + solid-js@1.9.15: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + + solid-refresh@0.6.3(solid-js@1.9.15): + dependencies: + '@babel/generator': 7.29.8 + '@babel/helper-module-imports': 7.29.7 + '@babel/types': 7.29.8 + solid-js: 1.9.15 + transitivePeerDependencies: + - supports-color + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + style-mod@4.1.3: {} + + tinyargs@0.1.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + uuid@14.0.2: {} + + uuid@9.0.1: {} + + vite-node@3.2.4(@types/node@20.19.43): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-plugin-css-injected-by-js@3.5.2(vite@7.3.6(@types/node@20.19.43)): + dependencies: + vite: 7.3.6(@types/node@20.19.43) + + vite-plugin-solid@2.11.14(solid-js@1.9.15)(vite@7.3.6(@types/node@20.19.43)): + dependencies: + '@babel/core': 7.29.7 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.15(@babel/core@7.29.7)(solid-js@1.9.15) + merge-anything: 5.1.7 + solid-js: 1.9.15 + solid-refresh: 0.6.3(solid-js@1.9.15) + vite: 7.3.6(@types/node@20.19.43) + vitefu: 1.1.3(vite@7.3.6(@types/node@20.19.43)) + transitivePeerDependencies: + - supports-color + + vite@7.3.6(@types/node@20.19.43): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.26 + rollup: 4.62.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + + vitefu@1.1.3(vite@7.3.6(@types/node@20.19.43)): + optionalDependencies: + vite: 7.3.6(@types/node@20.19.43) + + vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@20.19.43)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@20.19.43) + vite-node: 3.2.4(@types/node@20.19.43) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + happy-dom: 15.11.7 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-keyname@2.2.8: {} + + webidl-conversions@7.0.0: {} + + whatwg-mimetype@3.0.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.3: {} + + xstate@5.32.5: {} + + yallist@3.1.1: {} diff --git a/corkboard/pnpm-workspace.yaml b/corkboard/pnpm-workspace.yaml new file mode 100644 index 00000000..07535357 --- /dev/null +++ b/corkboard/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +# pnpm 11 reads its settings from this file. There is no root workspace, so +# every package carries the settings it needs rather than inheriting them. +minimumReleaseAge: 0 +verifyDepsBeforeRun: false +allowBuilds: + "@swc/core": true + cbor-extract: true + core-js: true + esbuild: true diff --git a/corkboard/src/ProvenanceProvider.ts b/corkboard/src/ProvenanceProvider.ts new file mode 100644 index 00000000..3d666712 --- /dev/null +++ b/corkboard/src/ProvenanceProvider.ts @@ -0,0 +1,218 @@ +import { + type AutomergeUrl, + type DocHandle, + type DocHandleChangePayload, + type DocHandleDeletePayload, +} from "@automerge/automerge-repo/slim"; +import { accept, type SubscribeEvent } from "@inkandswitch/patchwork-providers"; +import type { + MountedEvent, + UnmountedEvent, + PatchworkViewElement, +} from "@inkandswitch/patchwork-elements"; +import { + type DocWithProvenance, + type ProvenanceLink, + docUrlOfRef, + linkListsEqual, +} from "./provenance.js"; + +/** + * Answers `patchwork:provenance` subscriptions. Watches every doc mounted + * inside it for a `@patchwork.provenance` section and pushes the resulting + * `ProvenanceLink[]` to subscribers. + * + * The point of mounting this around a canvas: entries live in the GENERATED + * doc, so a source doc (e.g. a text document) cannot see inbound links by + * reading itself. Anything mounted in the same context can instead ask this + * provider, which indexes links by the doc url of EITHER end. + * + * Subscriptions are scoped: + * + * - `{ url }` → only links with that doc on either end. The subscriber is + * re-notified only when its own slice changes. + * - no args → the flat list of every link across all mounted docs. + */ +export const ProvenanceProvider = (element: PatchworkViewElement) => { + // Mount bookkeeping survives the `await repo.find` below. + const mountCounts = new Map(); + const handlesByUrl = new Map>(); + + // Two indices over the same links: keyed by the doc that *stores* the + // entries, and regrouped by the doc url of each link's ends. + const linksByStorageUrl = new Map(); + let linksByDocUrl = new Map(); + let flatLinks: ProvenanceLink[] = []; + + // Subscribers, split by the doc url they asked about (or the global set + // for url-less subscriptions). + const subscribersByUrl = new Map< + AutomergeUrl, + Set<(links: ProvenanceLink[]) => void> + >(); + const globalSubscribers = new Set<(links: ProvenanceLink[]) => void>(); + + element.addEventListener("patchwork:mounted", startWatch); + element.addEventListener("patchwork:unmounted", stopWatch); + element.addEventListener("patchwork:subscribe", onSubscribe); + + return () => { + element.removeEventListener("patchwork:mounted", startWatch); + element.removeEventListener("patchwork:unmounted", stopWatch); + element.removeEventListener("patchwork:subscribe", onSubscribe); + for (const url of [...handlesByUrl.keys()]) disposeDoc(url); + subscribersByUrl.clear(); + globalSubscribers.clear(); + }; + + function onSubscribe(event: SubscribeEvent) { + if (event.detail.selector.type !== "patchwork:provenance") return; + const url = event.detail.selector.url as AutomergeUrl | undefined; + + accept(event, (respond) => { + if (url) { + respond(linksByDocUrl.get(url) ?? []); + let set = subscribersByUrl.get(url); + if (!set) subscribersByUrl.set(url, (set = new Set())); + set.add(respond); + return () => { + set!.delete(respond); + if (set!.size === 0) subscribersByUrl.delete(url); + }; + } + respond(flatLinks); + globalSubscribers.add(respond); + return () => globalSubscribers.delete(respond); + }); + } + + async function startWatch(event: MountedEvent) { + if (!("url" in event.detail)) return; + const url = event.detail.url; + const wasMounted = isMounted(url); + mountDoc(url); + if (wasMounted) return; + + let handle: DocHandle; + try { + handle = await element.repo.find(url); + } catch (error) { + console.error(`[corkboard] failed to watch provenance on ${url}`, error); + return; + } + if (!isMounted(url)) return; + if (handlesByUrl.has(url)) return; + + handle.on("change", onChange); + handle.on("delete", onDelete); + handlesByUrl.set(url, handle); + linksByStorageUrl.set(url, buildLinksForDoc(handle)); + rebuild(); + } + + function stopWatch(event: UnmountedEvent) { + if (!("url" in event.detail)) return; + const url = event.detail.url; + unmountDoc(url); + if (!isMounted(url)) disposeDoc(url); + } + + function onChange({ handle }: DocHandleChangePayload) { + const prev = linksByStorageUrl.get(handle.url); + if (!prev) return; + const next = buildLinksForDoc(handle); + if (linkListsEqual(prev, next)) return; + linksByStorageUrl.set(handle.url, next); + rebuild(); + } + + function onDelete({ handle }: DocHandleDeletePayload) { + disposeDoc(handle.url); + } + + function disposeDoc(url: AutomergeUrl) { + const handle = handlesByUrl.get(url); + mountCounts.delete(url); + handlesByUrl.delete(url); + linksByStorageUrl.delete(url); + if (!handle) return; + handle.off("change", onChange); + handle.off("delete", onDelete); + rebuild(); + } + + // Recompute both indices from the per-storage-doc lists, then notify only + // the subscribers whose visible slice actually changed. + function rebuild() { + const nextFlat: ProvenanceLink[] = []; + for (const list of linksByStorageUrl.values()) { + for (const link of list) nextFlat.push(link); + } + + // A link is visible from the doc of either end. When both ends live in + // the same doc it's still listed once. + const nextByDoc = new Map(); + for (const link of nextFlat) { + const ends = new Set( + [docUrlOfRef(link.sourceUrl), docUrlOfRef(link.targetUrl)].filter( + (u): u is AutomergeUrl => u !== undefined + ) + ); + for (const docUrl of ends) { + let bucket = nextByDoc.get(docUrl); + if (!bucket) nextByDoc.set(docUrl, (bucket = [])); + bucket.push(link); + } + } + + const touched = new Set([ + ...nextByDoc.keys(), + ...linksByDocUrl.keys(), + ]); + for (const url of touched) { + const before = linksByDocUrl.get(url) ?? []; + const after = nextByDoc.get(url) ?? []; + if (linkListsEqual(before, after)) continue; + const subs = subscribersByUrl.get(url); + if (subs) for (const emit of subs) emit(after); + } + linksByDocUrl = nextByDoc; + + if (!linkListsEqual(flatLinks, nextFlat)) { + flatLinks = nextFlat; + for (const emit of globalSubscribers) emit(flatLinks); + } + } + + function buildLinksForDoc( + handle: DocHandle + ): ProvenanceLink[] { + const links: ProvenanceLink[] = []; + const entries = handle.doc()?.["@patchwork"]?.provenance ?? []; + for (const entry of entries) { + const entryUrl = handle.sub("@patchwork", "provenance", { + id: entry.id, + }).url; + for (const targetUrl of entry.targets ?? []) { + for (const sourceUrl of entry.sources ?? []) { + links.push({ sourceUrl, targetUrl, entryUrl }); + } + } + } + return links; + } + + function mountDoc(url: AutomergeUrl) { + mountCounts.set(url, (mountCounts.get(url) ?? 0) + 1); + } + + function unmountDoc(url: AutomergeUrl) { + const cur = mountCounts.get(url) ?? 0; + if (cur <= 1) mountCounts.delete(url); + else mountCounts.set(url, cur - 1); + } + + function isMounted(url: AutomergeUrl) { + return mountCounts.has(url); + } +}; diff --git a/corkboard/src/codemirror-provenance.ts b/corkboard/src/codemirror-provenance.ts new file mode 100644 index 00000000..8951355e --- /dev/null +++ b/corkboard/src/codemirror-provenance.ts @@ -0,0 +1,246 @@ +// Provenance rendering for CodeMirror editors, shipped with the corkboard. +// Registered as a `codemirror:extension` FACTORY: codemirror-base loads it +// through the plugin registry (late-bound — no build-time dependency either +// way) and calls it with the editor's Patchwork context. The extension: +// +// - subscribes to `patchwork:provenance` for the edited doc and decorates +// every source range (text another document was generated from) with a +// dotted underline in the link accent; +// - draws the stronger tint when the shared focus selection/highlight +// overlaps a range; +// - on selection change, pushes the targets linked to the ranges under the +// cursor into the shared focus selection, so views of the generated doc +// highlight what came from the selected text. +// +// Outside a provenance provider (see `ProvenanceProvider`) the subscription +// is never answered and the whole extension stays inert. + +import { + RangeSet, + StateEffect, + type Extension, + type Range, +} from "@codemirror/state"; +import { + Decoration, + EditorView, + ViewPlugin, + type DecorationSet, +} from "@codemirror/view"; +import { subscribe } from "@inkandswitch/patchwork-providers"; +import type { + AutomergeUrl, + DocHandle, + Repo, +} from "@automerge/automerge-repo/slim"; +import type { ProvenanceLink } from "./provenance.js"; + +// Mirrors codemirror-base's CodeMirrorExtensionContext structurally (the +// packages are standalone, so the type is not imported). +export type CodeMirrorExtensionContext = { + handle: DocHandle; + element: HTMLElement; + repo: Repo; +}; + +type FocusDoc = { + selection: Record; + highlight: Record; +}; + +// A provenance source range in the edited doc, resolved to a live ref +// handle, with the target refs (in other docs) it links to. +type ProvenanceSource = { + handle: DocHandle; + targetUrls: AutomergeUrl[]; +}; + +export function provenanceExtension( + ctx: CodeMirrorExtensionContext +): Extension { + // Dispatched to wake the view when async data (links, focus) lands. + const refresh = StateEffect.define(); + + const plugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet = Decoration.none; + + private view: EditorView; + private sources: ProvenanceSource[] = []; + private emphasis: DocHandle[] = []; + private focusUrls = new Set(); + private focusHandle: DocHandle | undefined; + private lastPushedKey = ""; + private destroyed = false; + private unsubscribers: (() => void)[] = []; + private onFocusChange = () => { + void this.refreshEmphasis(); + }; + + constructor(view: EditorView) { + this.view = view; + this.unsubscribers.push( + subscribe( + ctx.element, + { type: "patchwork:provenance", url: ctx.handle.url }, + (links) => void this.applyLinks(links) + ), + subscribe( + ctx.element, + { type: "patchwork:focus" }, + (url) => void this.attachFocus(url) + ) + ); + } + + // Ranges are re-read from the ref handles on every view update: doc + // changes move them, and the `refresh` effect (async data arriving) + // lands here too. Building is cheap — a handful of ranges. + update() { + this.decorations = this.build(); + } + + destroy() { + this.destroyed = true; + for (const unsubscribe of this.unsubscribers) unsubscribe(); + this.focusHandle?.off("change", this.onFocusChange); + } + + // Called from the selection update listener below — which runs AFTER + // the base editor's own listener has replaced the shared selection with + // the cursor's ref url — so this only merges targets in on top. + pushTargetsForSelection(from: number, to: number) { + const handle = this.focusHandle; + if (!handle) return; + const targets = this.targetsInRange(from, to); + const key = `${from}:${to}|${targets.join("|")}`; + if (key === this.lastPushedKey) return; + this.lastPushedKey = key; + if (targets.length === 0) return; + handle.change((doc) => { + if (!doc.selection) doc.selection = {}; + for (const url of targets) doc.selection[url] = true; + }); + } + + // Target refs of every source range that overlaps [from, to]. + private targetsInRange(from: number, to: number): AutomergeUrl[] { + const targets = new Set(); + for (const source of this.sources) { + const positions = source.handle.rangePositions(); + if (!positions) continue; + const [start, end] = positions; + if (start === end || to < start || from > end) continue; + for (const url of source.targetUrls) targets.add(url); + } + return [...targets]; + } + + // Scopes links to the ones whose SOURCE lives in this doc, dedupes by + // source ref, and resolves each source to a live handle. + private async applyLinks(links: ProvenanceLink[]) { + const targetsBySource = new Map>(); + for (const link of links) { + if (!link.sourceUrl.startsWith(ctx.handle.url)) continue; + let targets = targetsBySource.get(link.sourceUrl); + if (!targets) { + targetsBySource.set(link.sourceUrl, (targets = new Set())); + } + targets.add(link.targetUrl); + } + const sources: ProvenanceSource[] = []; + for (const [sourceUrl, targets] of targetsBySource) { + sources.push({ + handle: await ctx.repo.find(sourceUrl), + targetUrls: [...targets], + }); + } + if (this.destroyed) return; + this.sources = sources; + this.poke(); + } + + private async attachFocus(url: AutomergeUrl) { + this.focusHandle?.off("change", this.onFocusChange); + const handle = await ctx.repo.find(url); + if (this.destroyed) return; + this.focusHandle = handle; + handle.on("change", this.onFocusChange); + await this.refreshEmphasis(); + } + + // Focus refs (selection ∪ highlight) scoped to this doc, resolved to + // handles so `build` can test overlap against the source ranges. The + // raw url set is kept too: a source range is also emphasised when the + // focus holds one of its TARGETS (e.g. an element selected on a + // Petrinaut canvas), which never resolves into this doc. + private async refreshEmphasis() { + const doc = this.focusHandle?.doc(); + const urls = [ + ...Object.keys(doc?.selection ?? {}), + ...Object.keys(doc?.highlight ?? {}), + ] as AutomergeUrl[]; + const refs: DocHandle[] = []; + for (const url of urls) { + if (url.startsWith(ctx.handle.url)) { + refs.push(await ctx.repo.find(url)); + } + } + if (this.destroyed) return; + this.focusUrls = new Set(urls); + this.emphasis = refs; + this.poke(); + } + + // Safe to dispatch directly: every caller sits behind an `await`, so + // the view is never mid-update here. + private poke() { + if (this.destroyed) return; + this.view.dispatch({ effects: refresh.of(null) }); + } + + private build(): DecorationSet { + const out: Range[] = []; + for (const source of this.sources) { + const positions = source.handle.rangePositions(); + if (!positions) continue; + const [start, end] = positions; + if (start === end) continue; + const isEmphasised = + this.emphasis.some((ref) => ref.overlaps(source.handle)) || + source.targetUrls.some((url) => this.focusUrls.has(url)); + out.push( + Decoration.mark({ + attributes: { style: sourceStyle(isEmphasised) }, + }).range(start, end) + ); + } + return RangeSet.of(out, true); + } + }, + { decorations: (instance) => instance.decorations } + ); + + // Registered after the base editor's own selection listener (user + // extensions load later in the editor's extension array), so the merge in + // pushTargetsForSelection lands on the freshly replaced selection map. + const selectionListener = EditorView.updateListener.of((update) => { + if (!update.selectionSet) return; + const sel = update.state.selection.main; + update.view.plugin(plugin)?.pushTargetsForSelection(sel.from, sel.to); + }); + + return [plugin, selectionListener]; +} + +// Same highlighter-over-paper scheme as codemirror-base's comment targets, +// but on the link accent: the tint is anchored to the editor surface so it +// tracks the theme, and the dotted underline reads as "this points +// somewhere". Emphasised (focus overlaps the range) draws the stronger tint. +function sourceStyle(isEmphasised: boolean): string { + const paper = isEmphasised ? "56%" : "85%"; + return ` + border-bottom: 2px dotted var(--studio-link); + background-color: color-mix(in oklch, var(--studio-link), var(--text-editor-fill) ${paper}); + `; +} diff --git a/corkboard/src/datatypes.ts b/corkboard/src/datatypes.ts new file mode 100644 index 00000000..79d4a08a --- /dev/null +++ b/corkboard/src/datatypes.ts @@ -0,0 +1,52 @@ +// Live lookups against the `patchwork:datatype` plugin registry, shared by the +// two llm:skills in this package: the generic one that tells the computer which +// datatypes exist, and the corkboard one that creates a document of a chosen +// datatype as a canvas embed. + +import { + getRegistry, + type DatatypeDescription, + type LoadedDatatype, +} from "@inkandswitch/patchwork-plugins"; + +export type DatatypeInfo = { id: string; name: string }; + +/** Every datatype a user could sensibly create, id + name, sorted by name. + * `unlisted` datatypes (e.g. `file`, and the embed frame's own helpers) are + * omitted for the same reason the create-new menus omit them: they exist to be + * produced by something else, not conjured empty. */ +export function listDatatypes(): DatatypeInfo[] { + let plugins: { id: string; name?: string; unlisted?: boolean }[] = []; + try { + plugins = getRegistry("patchwork:datatype").all(); + } catch { + return []; + } + return plugins + .filter((p) => p && typeof p.id === "string" && !p.unlisted) + .map((p) => ({ id: p.id, name: p.name || p.id })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Load one datatype's implementation by id, or undefined if it isn't + * installed. Loading is what gives us `init` (for createDocOfDatatype2) and + * `getTitle`. */ +export async function loadDatatype( + id: string +): Promise { + try { + const loaded = await getRegistry( + "patchwork:datatype" + ).load(id); + return loaded as LoadedDatatype | undefined; + } catch { + return undefined; + } +} + +/** The installed ids as a one-line hint, for the "no such datatype" errors the + * model reads. */ +export function datatypeIdsHint(): string { + const ids = listDatatypes().map((d) => d.id); + return ids.length ? ids.join(", ") : "(none registered)"; +} diff --git a/corkboard/src/embed-placement.ts b/corkboard/src/embed-placement.ts new file mode 100644 index 00000000..7c1f293c --- /dev/null +++ b/corkboard/src/embed-placement.ts @@ -0,0 +1,128 @@ +// Pure helpers for dropping a `patchwork-doc` embed into a tldraw5 store from +// outside tldraw — no editor, no React, just the record map the chat computer +// edits with automerge_op. Kept separate from the skill so the arithmetic is +// testable. + +export const DEFAULT_EMBED_W = 640; +export const DEFAULT_EMBED_H = 480; +/** Gap left between the existing drawing and an auto-placed embed. */ +export const EMBED_GUTTER = 80; + +/** Fallback footprint for a shape that carries no explicit w/h (notes, text), + * used only to keep auto-placement clear of it. */ +const ASSUMED_W = 200; +const ASSUMED_H = 200; + +type Store = Record; + +/** Deterministic shape id for a document url. Mirrors tldraw5's + * `makeShapeId` (PatchworkDocShape.tsx) — copied rather than imported, since + * every folder here is a standalone package — so an embed we write converges + * with one the user creates by dragging the same document in. */ +export function shapeIdForUrl(docUrl: string): string { + return "shape:" + docUrl.replace(/[^a-zA-Z0-9]/g, "_"); +} + +function shapeRecords(store: Store): any[] { + return Object.values(store || {}).filter( + (r: any) => + r && + r.typeName === "shape" && + // Only top-level shapes: a child's x/y are relative to its frame, and the + // frame itself already covers that area. + (r.parentId === undefined || String(r.parentId).startsWith("page:")) + ); +} + +/** Bounding box of the canvas's top-level shapes, or null when it's empty. */ +export function contentBounds( + store: Store +): { minX: number; minY: number; maxX: number; maxY: number } | null { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const shape of shapeRecords(store)) { + const x = Number(shape.x); + const y = Number(shape.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + const w = Number(shape.props?.w); + const h = Number(shape.props?.h); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + (Number.isFinite(w) ? w : ASSUMED_W)); + maxY = Math.max(maxY, y + (Number.isFinite(h) ? h : ASSUMED_H)); + } + if (minX === Infinity) return null; + return { minX, minY, maxX, maxY }; +} + +/** Where to put an embed nobody gave coordinates for: to the right of whatever + * is already drawn, top-aligned with it — so it lands next to the user's work + * rather than on top of it or somewhere they have to hunt for. */ +export function placeClearOfContent(store: Store): { x: number; y: number } { + const bounds = contentBounds(store); + if (!bounds) return { x: 0, y: 0 }; + return { x: bounds.maxX + EMBED_GUTTER, y: bounds.minY }; +} + +// tldraw's fractional-index alphabet, ascending. Plain string comparison +// matches this order (ASCII puts 0-9 < A-Z < a-z), so the largest index in use +// is just the lexicographic max. +const INDEX_ALPHABET = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +/** An index key that sorts above every shape already on the canvas, so a new + * embed lands on top. Bumping the last character keeps keys short; when it is + * already the highest character, appending is the only way up. */ +export function indexAbove(store: Store): string { + let max = ""; + for (const shape of shapeRecords(store)) { + const index = shape.index; + if (typeof index === "string" && index > max) max = index; + } + if (max === "") return "a1"; + const last = max[max.length - 1]; + const next = INDEX_ALPHABET.indexOf(last) + 1; + if (next > 0 && next < INDEX_ALPHABET.length) { + return max.slice(0, -1) + INDEX_ALPHABET[next]; + } + return max + "1"; +} + +/** A complete `patchwork-doc` shape record. Complete matters: records go into + * tldraw's validating store one at a time, and a partial one is rejected. */ +export function buildEmbedRecord(opts: { + shapeId: string; + index: string; + x: number; + y: number; + w: number; + h: number; + docUrl: string; + docName: string; + docType: string; + toolId?: string; +}): Record { + return { + id: opts.shapeId, + typeName: "shape", + type: "patchwork-doc", + x: opts.x, + y: opts.y, + rotation: 0, + isLocked: false, + opacity: 1, + index: opts.index, + parentId: "page:page", + meta: {}, + props: { + w: opts.w, + h: opts.h, + docUrl: opts.docUrl, + docName: opts.docName, + docType: opts.docType, + toolId: opts.toolId ?? "", + }, + }; +} diff --git a/corkboard/src/index.ts b/corkboard/src/index.ts new file mode 100644 index 00000000..c05c1bf0 --- /dev/null +++ b/corkboard/src/index.ts @@ -0,0 +1,78 @@ +import type { Plugin } from "@inkandswitch/patchwork-plugins"; + +export const plugins: Plugin[] = [ + // A tldraw canvas wrapped in the provenance provider: docs pinned to the + // canvas mount inside the provider, so cross-document provenance links + // resolve for all of them (a text doc learns which of its ranges another + // doc was generated from). + { + type: "patchwork:tool", + id: "corkboard", + name: "Corkboard", + icon: "Pin", + supportedDatatypes: ["tldraw5"], + async load() { + return (await import("./tool")).default; + }, + }, + // Answers `patchwork:provenance` subscriptions for every doc mounted + // inside it. Registered separately so other hosts (e.g. a frame) can mount + // it without the corkboard tool. + { + type: "patchwork:component", + id: "patchwork-provenance-provider", + name: "Provenance Provider", + async load() { + const { ProvenanceProvider } = await import("./ProvenanceProvider.js"); + return ProvenanceProvider; + }, + }, + // Renders provenance in text editors: a codemirror-base extension FACTORY + // (it receives the editor's {handle, element, repo} context) that + // underlines provenance source ranges and pushes their linked targets into + // the shared focus selection. Inert outside a provenance provider. + { + type: "codemirror:extension", + id: "codemirror-provenance", + name: "Provenance highlights", + supportedDatatypes: "*", + async load() { + const { provenanceExtension } = await import( + "./codemirror-provenance.js" + ); + return provenanceExtension; + }, + }, + // Tells the chat computer which document datatypes this Patchwork actually + // has installed, by reading the `patchwork:datatype` registry. Nothing + // canvas-specific, and deliberately bound to no datatype, so it never + // auto-activates: it sits in the computer's skills index until something + // needs it (load_skill, or the tldraw skill below pointing at it). + { + type: "llm:skill", + id: "patchwork-datatypes", + name: "Patchwork Datatypes", + description: + "Lists the document datatypes installed in this Patchwork (ids and names) via the plugin registry. Applies whenever you need a datatype id — before creating a document, or before naming a type you haven't read.", + async load() { + const { skill } = await import("./llm-skill-datatypes.js"); + return skill; + }, + }, + // Instruction pack for Patchwork's chat computer (the `llm:skill` type the + // chat tool consumes): how to create and edit tldraw canvases with the + // generic document tools, plus a create_doc_on_canvas tool that makes a new + // document of a datatype and embeds it. Auto-activates on a focused tldraw5. + { + type: "llm:skill", + id: "tldraw5", + name: "tldraw Canvas", + description: + "Create and edit tldraw canvases — shapes, sticky notes, frames, arrows with bindings, and embedded Patchwork documents. Applies when the focused document is a tldraw5 canvas, or when the user asks to draw, diagram, or lay something out on a canvas.", + datatypes: ["tldraw5"], + async load() { + const { skill } = await import("./llm-skill.js"); + return skill; + }, + }, +]; diff --git a/corkboard/src/llm-skill-datatypes.ts b/corkboard/src/llm-skill-datatypes.ts new file mode 100644 index 00000000..d2c86307 --- /dev/null +++ b/corkboard/src/llm-skill-datatypes.ts @@ -0,0 +1,48 @@ +// A generic "llm:skill" for Patchwork's chat computer: what document datatypes +// this Patchwork actually has installed. Nothing canvas-specific — it reads the +// `patchwork:datatype` registry, which is the same list the sideboard's +// create-new menu and the canvas's new-doc tool draw from. +// +// Deliberately NOT bound to a datatype, so it never auto-activates: it appears +// in the computer's skills index and is pulled in with load_skill (or by +// another skill, like this package's tldraw one, telling it to). + +import { listDatatypes } from "./datatypes.js"; + +const INSTRUCTIONS = ` +Find out which document datatypes this Patchwork has installed, and what their +ids are, instead of guessing. + +Call list_datatypes. It reads the live \`patchwork:datatype\` plugin registry and +returns [{ id, name }, …]. The \`id\` is the canonical string everywhere else: a +document's \`@patchwork.type\`, a datatype argument to any tool that creates +documents, a \`docType\` on an embed. + +- The list is per-session — it is whatever modules this Patchwork has loaded, and + a different Patchwork will have a different set. Never assume an id that isn't + in it, and re-read it rather than trusting one from earlier in a conversation. +- Datatypes marked unlisted (e.g. \`file\`) are omitted. They are produced by + something else — an upload, an import — not created empty. +- An id here does NOT mean you know the document's shape. Once you have created + or opened one, read_doc it and follow whichever skill activates for its type. +`.trim(); + +export const skill = { + instructions: INSTRUCTIONS, + tools: [ + { + name: "list_datatypes", + description: + "List the document datatypes installed in this Patchwork as [{id, name}]. The id is what `@patchwork.type` and every datatype argument uses. Call this before creating a document or naming a type.", + parameters: { type: "object", properties: {} }, + }, + ], + runTool(name: string) { + if (name !== "list_datatypes") return undefined; + const datatypes = listDatatypes(); + if (datatypes.length === 0) { + return "No datatypes are registered in this Patchwork (nothing has loaded a `patchwork:datatype` plugin)."; + } + return { datatypes }; + }, +}; diff --git a/corkboard/src/llm-skill.ts b/corkboard/src/llm-skill.ts new file mode 100644 index 00000000..55ad4c3c --- /dev/null +++ b/corkboard/src/llm-skill.ts @@ -0,0 +1,346 @@ +// The "llm:skill" plugin for Patchwork's chat computer: instructions for +// creating and editing tldraw canvases with the chat's generic document tools +// (read_doc / automerge_op / replace_text). Auto-activates when the focused +// document is a tldraw5 canvas (see the registration in index.ts). +// +// It also contributes ONE tool, create_doc_on_canvas, for the one job those +// generic document tools can't do: bring a new document into existence. + +import { createDocOfDatatype2 } from "@inkandswitch/patchwork-plugins"; +import { datatypeIdsHint, loadDatatype } from "./datatypes.js"; +import { + DEFAULT_EMBED_H, + DEFAULT_EMBED_W, + buildEmbedRecord, + indexAbove, + placeClearOfContent, + shapeIdForUrl, +} from "./embed-placement.js"; + +// The chat's `llm:skill` tool contract, restated here — the chat tool is a +// separate package, so its types can't be imported, only matched. +type LlmSkillTool = { + name: string; + description: string; + parameters?: any; +}; +type LlmSkillToolCtx = { + repo: any; + handle: any; + element: HTMLElement; + focusedUrl: string | undefined; + applyAutomerge: (doc: any, path: any[], range: any, value: any) => void; +}; + +const INSTRUCTIONS = ` +Create and edit tldraw canvases — diagrams, sticky-note boards, flowcharts, +wireframes, embedded-document layouts. Edit the document directly with read_doc +and automerge_op; the tldraw tool renders your changes live. + +### Document + +{ "store": { "": , ... }, "schema": { ... }, + "docs": [ ... ], "@patchwork": { "type": "tldraw5", ... } } + +- \`store\` is a flat map of tldraw records keyed by their own id. Everything on + the canvas lives here. +- \`schema\` is tldraw's serialized schema. NEVER touch it. +- \`docs\` is a FolderDoc-style DocLink[] (the canvas's \`script/**\` tree — a + root \`main.js\` is a document script). Leave it alone unless asked. +- Never change \`@patchwork\`.type. + +Record ids carry their type as a prefix and are the store keys: +\`document:document\` (singleton settings), \`page:page\` (the single page — this +datatype has exactly one), \`shape:\`, \`binding:\`, +\`asset:\`. Generate unique suffixes in the style of the existing ones +(a short random alphanumeric string is fine; keep it to [A-Za-z0-9_-]). + +CRITICAL completeness rule: changes are pushed into tldraw's VALIDATING store, +one whole record at a time. A record that is missing a required prop, or that +carries a prop its shape type doesn't declare, is rejected — and a rejected +record can wedge the canvas until reload. So: write records COMPLETE, with +every field of the templates below, and copy the exact prop set of an existing +shape of the same type when in doubt (read_doc first). + +Every shape record has the same envelope: + +{ "id": "shape:", "typeName": "shape", "type": "", + "x": 0, "y": 0, "rotation": 0, "isLocked": false, "opacity": 1, + "index": "a1", "parentId": "page:page", "meta": {}, "props": { ... } } + +- \`x\`/\`y\` are page coordinates of the shape's top-left; y grows DOWNWARD. +- \`parentId\` is "page:page" (or "shape:" for a child of a frame, in + which case x/y are relative to the frame). +- \`index\` is the z-order key: a fractional index over the alphabet + 0-9A-Za-z. Ascending strings stack later shapes on top — use "a1", "a2", … + "a9", "aA", "aB", … and keep them unique per parent. + +### Text: richText + +Every label-bearing shape stores its text as a ProseMirror doc, not a string: + +{ "type": "doc", "content": [ { "type": "paragraph", + "content": [ { "type": "text", "text": "Hello" } ] } ] } + +One paragraph node per line. Empty label = { "type": "doc", +"content": [ { "type": "paragraph" } ] }. To change existing text, prefer +replace_text on the old string — it splices just that span, so a collaborator +typing in the same label doesn't get clobbered. + +### Shape templates (complete prop sets) + +geo — rectangle/ellipse/diamond/… , the workhorse box: +{ ..., "type": "geo", "props": { + "geo": "rectangle", "w": 200, "h": 100, "growY": 0, "scale": 1, + "color": "black", "labelColor": "black", "fill": "none", "dash": "draw", + "size": "m", "font": "draw", "align": "middle", "verticalAlign": "middle", + "url": "", "richText": { "type": "doc", "content": [ { "type": "paragraph", + "content": [ { "type": "text", "text": "Box" } ] } ] } } } + +text — a bare label, no box: +{ ..., "type": "text", "props": { + "color": "black", "size": "m", "font": "draw", "textAlign": "start", + "w": 200, "scale": 1, "autoSize": true, + "richText": { ...as above... } } } +With "autoSize": true tldraw sizes it to the text and \`w\` is ignored; set +"autoSize": false to wrap at \`w\`. + +note — a sticky note (no w/h; its size follows \`size\` and \`growY\`): +{ ..., "type": "note", "props": { + "color": "yellow", "labelColor": "black", "size": "m", "font": "draw", + "align": "middle", "verticalAlign": "middle", "growY": 0, "scale": 1, + "url": "", "fontSizeAdjustment": 0, "textLastEditedBy": null, + "richText": { ...as above... } } } + +frame — a titled container; put shapes inside by setting their parentId to the +frame and their x/y relative to it: +{ ..., "type": "frame", "props": { + "w": 720, "h": 480, "name": "Screen 1", "color": "black" } } + +arrow — start/end are offsets RELATIVE to the arrow's own x/y: +{ ..., "type": "arrow", "props": { + "kind": "arc", "start": { "x": 0, "y": 0 }, "end": { "x": 160, "y": 0 }, + "bend": 0, "elbowMidPoint": 0.5, "labelPosition": 0.5, "scale": 1, + "arrowheadStart": "none", "arrowheadEnd": "arrow", + "color": "black", "labelColor": "black", "fill": "none", "dash": "draw", + "size": "m", "font": "draw", + "richText": { "type": "doc", "content": [ { "type": "paragraph" } ] } } } + +patchwork-doc — embeds another Patchwork document on the canvas, rendered by +its own tool. \`docUrl\` is an "automerge:…" url, \`docType\` its datatype id, +\`toolId\` "" to let the host pick the default tool: +{ ..., "type": "patchwork-doc", "props": { + "w": 640, "h": 480, "docUrl": "automerge:", "docName": "Notes", + "docType": "", "toolId": "" } } +Write this record yourself only for a document that ALREADY exists (a url the +user gave you, or one from \`docs\` / another shape). To put a NEW document on +the canvas, use create_doc_on_canvas — see below. + +### Creating a new document as an embed + +create_doc_on_canvas { datatype, x?, y?, w?, h? } creates a real, empty document +of an installed datatype and writes its \`patchwork-doc\` shape onto this canvas, +in one step. \`datatype\` is a datatype id and the only required argument. + +- Get the id from the \`patchwork-datatypes\` skill's list_datatypes tool — it + lists what this Patchwork actually has installed. If that skill isn't active, + load_skill it first. Do not guess an id. +- Omit x/y and the embed is placed clear of the existing shapes; omit w/h for + 640×480. Don't pre-write a placeholder shape for it — the tool writes the + record, and writing your own would leave a duplicate. +- It returns the new document's url, its datatype, and the shape id. The document + starts EMPTY: read_doc that url next (which activates that datatype's own + skill, if one is installed), then fill it in with automerge_op as usual. + +Style values (any other value is rejected): +- color / labelColor: black, grey, light-violet, violet, blue, light-blue, + yellow, orange, green, light-green, light-red, red, white +- fill: none, semi, solid, pattern, fill +- dash: draw, solid, dashed, dotted +- size: s, m, l, xl font: draw, sans, serif, mono +- align / verticalAlign: start, middle, end textAlign: start, middle, end +- geo: rectangle, ellipse, oval, triangle, diamond, rhombus, rhombus-2, + pentagon, hexagon, octagon, star, cloud, heart, trapezoid, x-box, + check-box, arrow-up, arrow-down, arrow-left, arrow-right +- arrowheadStart / arrowheadEnd: none, arrow, triangle, square, dot, diamond, + inverted, bar, pipe +- kind: arc (curved, use \`bend\`) or elbow (right-angled, use \`elbowMidPoint\`) + +### Connecting arrows to shapes: bindings + +An arrow that should FOLLOW the shapes it joins needs a binding record per +end — the terminals are then recomputed by tldraw and \`start\`/\`end\` become +hints. \`fromId\` is always the arrow, \`toId\` the shape it attaches to: + +{ "id": "binding:", "typeName": "binding", "type": "arrow", + "fromId": "shape:", "toId": "shape:", "meta": {}, + "props": { "terminal": "start", "normalizedAnchor": { "x": 0.5, "y": 0.5 }, + "isExact": false, "isPrecise": false, "snap": "none" } } + +Write two: one with "terminal": "start" → the source shape, one with +"terminal": "end" → the target. \`normalizedAnchor\` is a fraction of the +target's bounds ({0.5,0.5} = centre, which is what you want with +"isPrecise": false). + +### Editing recipes (automerge_op) + +Add a record (shape, binding) = ONE op: + path ["store"], range "", value = the complete record. +Add a whole diagram = one op per record; do them in order (shapes, then the +arrows, then the arrows' bindings). + +Move a shape: path ["store","shape:"], range "x", value 240 (same for "y"). +Resize: path ["store","shape:","props"], range "w", value 320. +Restyle: path ["store","shape:","props"], range "color", value "blue". +Retext: replace_text {find: "old label", replace: "new label"} — or, to + replace the whole label, assign the richText doc: + path ["store","shape:","props"], range "richText", value { "type": "doc", … }. +Rename the canvas (this is the document's title): + path ["store","page:page"], range "name", value "Flowchart". +Reorder (z): path ["store","shape:"], range "index", value "a5". + +Delete a shape = delete its store key, AND delete every binding record whose +fromId/toId is that shape, AND any arrow left with no purpose, AND reparent or +delete shapes whose parentId pointed at it (a deleted frame's children): + path ["store"], range "shape:" (no value) +Never delete \`page:page\` or \`document:document\`. + +### Layout + +Nothing auto-lays-out — you place everything, so do the arithmetic. Sensible +defaults: a labelled box 200×100; a column gap of 60–80px and a row gap of +120–160px between boxes; notes on a 220px grid. Keep bounding boxes +non-overlapping unless overlap is the point, and start a fresh diagram near +the existing content's bounds (or at 0,0 on an empty canvas) so the user +doesn't have to hunt for it. For an arrow drawn between two boxes without +bindings, set x/y to the source's edge midpoint and end to the delta to the +target's edge midpoint. + +### Workflow + +1. read_doc the focused canvas. Note the page id, the existing shapes' bounds + (so you place new work clear of them), the largest \`index\` in use, and the + exact prop set of any shape type you're about to copy. +2. Plan the shapes and their coordinates; say briefly what you're drawing. +3. Apply the edits with automerge_op — one op per new record, complete records + only, arrows' bindings right after their arrow. +4. read_doc to verify the records landed as written, then summarize. +`.trim(); + +// ── create_doc_on_canvas ───────────────────────────────────────────────────── +// The one thing the instructions above can't do with automerge_op alone: bring +// a new Patchwork document into existence. Creating it through the datatype's +// own `init` (the same createDocOfDatatype2 the canvas's new-doc tool and the +// sideboard's "+" both call) is what makes it a valid document of its type +// rather than a hand-guessed blob. + +const CREATE_DOC_ON_CANVAS: LlmSkillTool = { + name: "create_doc_on_canvas", + description: + "Create a NEW, empty Patchwork document of an installed datatype and embed it on the focused tldraw canvas as a patchwork-doc shape. Takes the datatype id (see the patchwork-datatypes skill's list_datatypes); optional x/y/w/h, else it is auto-placed clear of the existing shapes at 640x480. Returns the new document's url — read_doc it to fill it in.", + parameters: { + type: "object", + properties: { + datatype: { + type: "string", + description: + "datatype id, e.g. from list_datatypes (NOT a display name)", + }, + x: { type: "number", description: "optional page x (top-left)" }, + y: { type: "number", description: "optional page y (top-left)" }, + w: { type: "number", description: "optional width (default 640)" }, + h: { type: "number", description: "optional height (default 480)" }, + }, + required: ["datatype"], + }, +}; + +async function createDocOnCanvas( + args: any, + ctx: LlmSkillToolCtx +): Promise { + const datatypeId = String(args?.datatype ?? "").trim(); + if (!datatypeId) { + return `Error: create_doc_on_canvas needs a \`datatype\` id. Installed: ${datatypeIdsHint()}`; + } + if (!ctx.focusedUrl) { + return "Error: no focused document — create_doc_on_canvas writes into the tldraw canvas you have open."; + } + + const canvas = await ctx.repo.find(ctx.focusedUrl); + const canvasDoc: any = canvas.doc(); + if (!canvasDoc || typeof canvasDoc.store !== "object") { + return `Error: the focused document (${ctx.focusedUrl}) is not a tldraw canvas — it has no \`store\`.`; + } + + const datatype = await loadDatatype(datatypeId); + if (!datatype) { + return `Error: no installed datatype "${datatypeId}". Installed: ${datatypeIdsHint()}`; + } + + // `createDocOfDatatype2` is typed against an older @automerge/automerge-repo + // Repo; cast to bridge the version skew (the same thing tldraw5's new-doc + // tool does). + const docHandle = await ( + createDocOfDatatype2 as unknown as ( + d: unknown, + r: unknown + ) => Promise<{ url: string; doc(): unknown }> + )(datatype, ctx.repo); + const docUrl = docHandle.url; + + // Register with the sync server when the host exposes a keyhive, as the + // sideboard and folder do on create. Absent hive = local-only host, which is + // fine; a failure here must not lose the document we just made. + try { + await (ctx.element as any)?.hive?.addSyncServerPullToDoc?.(docUrl); + } catch (e) { + console.warn("[corkboard] sync-server registration failed for", docUrl, e); + } + + let docName = datatype.name || datatypeId; + try { + docName = (datatype as any).module?.getTitle?.(docHandle.doc()) || docName; + } catch { + // A datatype whose getTitle chokes on a fresh doc still gets an embed. + } + + const store = canvasDoc.store as Record; + const auto = placeClearOfContent(store); + const shapeId = shapeIdForUrl(docUrl); + const record = buildEmbedRecord({ + shapeId, + index: indexAbove(store), + x: Number.isFinite(Number(args?.x)) ? Number(args.x) : auto.x, + y: Number.isFinite(Number(args?.y)) ? Number(args.y) : auto.y, + w: Number.isFinite(Number(args?.w)) ? Number(args.w) : DEFAULT_EMBED_W, + h: Number.isFinite(Number(args?.h)) ? Number(args.h) : DEFAULT_EMBED_H, + docUrl, + docName, + docType: datatypeId, + }); + + canvas.change((d: any) => { + ctx.applyAutomerge(d, ["store"], shapeId, record); + }); + + return { + url: docUrl, + docType: datatypeId, + docName, + shapeId, + canvasUrl: ctx.focusedUrl, + x: record.x, + y: record.y, + next: `The document is empty. read_doc ${docUrl} and fill it in — its own skill (if installed) activates when you read it.`, + }; +} + +export const skill = { + instructions: INSTRUCTIONS, + tools: [CREATE_DOC_ON_CANVAS], + async runTool(name: string, args: any, ctx: LlmSkillToolCtx) { + if (name !== "create_doc_on_canvas") return undefined; + return createDocOnCanvas(args, ctx); + }, +}; diff --git a/corkboard/src/provenance.ts b/corkboard/src/provenance.ts new file mode 100644 index 00000000..2698bfd6 --- /dev/null +++ b/corkboard/src/provenance.ts @@ -0,0 +1,67 @@ +import type { AutomergeUrl } from "@automerge/automerge-repo/slim"; + +// Provenance is a generalization of comments: a link between references, +// e.g. a text range in one document and an element generated from it in +// another. Entries are stored in the GENERATED document — under its +// `@patchwork` envelope, next to the datatype tag — pointing back at their +// sources. The source document knows nothing about them, which is why the +// provider (see `ProvenanceProvider`) exists to invert the links for +// anything mounted alongside it. +export type DocWithProvenance = { + "@patchwork"?: { + type?: string; + provenance?: ProvenanceEntry[]; + }; +}; + +export type ProvenanceEntry = { + id: string; + /** What in the storing doc was generated — always automerge urls (a ref url + * into the doc, or the bare doc url for whole-document provenance). */ + targets: AutomergeUrl[]; + /** Where it came from — always automerge urls, typically cursor-anchored + * ref urls into the source doc (edit-stable), or a bare doc url. */ + sources: AutomergeUrl[]; + /** Who/what generated it (a contact or agent doc). */ + contactUrl?: AutomergeUrl; + createdAt?: number; + note?: string; +}; + +/** One (source, target) pair, flattened out of a stored entry. What the + * provider pushes to subscribers. */ +export type ProvenanceLink = { + sourceUrl: AutomergeUrl; + targetUrl: AutomergeUrl; + /** Ref url of the stored entry itself, resolvable to the full record. */ + entryUrl: AutomergeUrl; +}; + +/** The bare document url a ref url points into (strips path and heads). */ +export function docUrlOfRef(ref: AutomergeUrl): AutomergeUrl | undefined { + const slash = ref.indexOf("/"); + const hash = ref.indexOf("#"); + const end = + slash === -1 + ? hash === -1 + ? ref.length + : hash + : hash === -1 + ? slash + : Math.min(slash, hash); + const head = ref.slice(0, end); + return head ? (head as AutomergeUrl) : undefined; +} + +export const linksEqual = (a: ProvenanceLink, b: ProvenanceLink) => + a.sourceUrl === b.sourceUrl && + a.targetUrl === b.targetUrl && + a.entryUrl === b.entryUrl; + +export const linkListsEqual = (a: ProvenanceLink[], b: ProvenanceLink[]) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!linksEqual(a[i], b[i])) return false; + } + return true; +}; diff --git a/corkboard/src/styles.css b/corkboard/src/styles.css new file mode 100644 index 00000000..0630c1a4 --- /dev/null +++ b/corkboard/src/styles.css @@ -0,0 +1,14 @@ +.corkboard { + height: 100%; +} + +/* The provider view stays layout-neutral; the tool view fills the pane. */ +.corkboard > patchwork-view { + display: contents; +} + +.corkboard patchwork-view[tool-id] { + display: block; + width: 100%; + height: 100%; +} diff --git a/corkboard/src/tool.tsx b/corkboard/src/tool.tsx new file mode 100644 index 00000000..49628c50 --- /dev/null +++ b/corkboard/src/tool.tsx @@ -0,0 +1,70 @@ +import "./styles.css"; +// Pulls in JSX intrinsic type augmentations. +import type {} from "@inkandswitch/patchwork-elements"; +import type { AutomergeUrl } from "@automerge/automerge-repo/slim"; +import type { ToolImplementation } from "@inkandswitch/patchwork-plugins"; +import { + createEffect, + createSignal, + onCleanup, + Show, + type Accessor, +} from "solid-js"; +import { render } from "solid-js/web"; + +// The corkboard: a tldraw canvas wrapped in the provenance provider. The +// canvas itself is rendered by the tldraw5 tool via ``; the +// value added here is the context — every doc pinned to the canvas mounts +// inside the provider's subtree, so the provider can index their +// `@patchwork.provenance` sections and answer `patchwork:provenance` +// subscriptions from any of them (see `ProvenanceProvider`). +const mount: ToolImplementation = (handle, element) => + render(() => , element); + +export default mount; + +function Corkboard(props: { docUrl: AutomergeUrl }) { + const [providerElement, setProviderElement] = createSignal(); + const isProviderReady = useProviderReady( + "patchwork-provenance-provider", + providerElement + ); + + return ( +
+ + + + + +
+ ); +} + +// Gate the canvas on the provider having attached its listeners, so +// `patchwork:subscribe` events from the canvas (and the docs pinned to it) +// can't fire into the void. Same pattern as the threepane frame. +function useProviderReady( + componentId: string, + element: Accessor +): Accessor { + const [isReady, setReady] = createSignal(false); + + createEffect(() => { + const el = element(); + if (!el) return; + setReady(false); + const onMounted = (event: Event) => { + const detail = (event as CustomEvent<{ componentId?: string }>).detail; + if (detail?.componentId !== componentId) return; + setReady(true); + }; + el.addEventListener("patchwork:mounted", onMounted); + onCleanup(() => el.removeEventListener("patchwork:mounted", onMounted)); + }); + + return isReady; +} diff --git a/corkboard/tsconfig.json b/corkboard/tsconfig.json new file mode 100644 index 00000000..e466053a --- /dev/null +++ b/corkboard/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "outDir": "dist", + "declaration": true, + "lib": ["ESNext", "DOM"], + "types": ["vite/client"], + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "jsx": "preserve", + "jsxImportSource": "solid-js" + }, + "include": ["src"] +} diff --git a/corkboard/vite.config.ts b/corkboard/vite.config.ts new file mode 100644 index 00000000..b4f67118 --- /dev/null +++ b/corkboard/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vite"; +import solid from "vite-plugin-solid"; +import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; +import externals from "@inkandswitch/patchwork-bootloader/externals"; + +export default defineConfig({ + base: "./", + plugins: [solid(), cssInjectedByJsPlugin({ relativeCSSInjection: true })], + + build: { + sourcemap: true, + cssCodeSplit: true, + emptyOutDir: true, + minify: false, + rollupOptions: { + external: externals, + input: "./src/index.ts", + output: { + format: "es", + entryFileNames: "[name].js", + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: "assets/[name][extname]", + }, + preserveEntrySignatures: "strict", + }, + }, +}); diff --git a/corkboard/vitest.config.ts b/corkboard/vitest.config.ts new file mode 100644 index 00000000..f69a7dec --- /dev/null +++ b/corkboard/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import solid from "vite-plugin-solid"; + +export default defineConfig({ + plugins: [solid()], + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, + resolve: { + conditions: ["development", "browser"], + }, +}); diff --git a/csv/.gitignore b/csv/.gitignore new file mode 100644 index 00000000..f928cfcf --- /dev/null +++ b/csv/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules +.pushwork diff --git a/csv/package.json b/csv/package.json new file mode 100644 index 00000000..a1904793 --- /dev/null +++ b/csv/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tiny-patchwork/csv", + "version": "0.1.0", + "type": "module", + "private": true, + "main": "./dist/index.js", + "scripts": { + "dev": "vite build --watch", + "build": "vite build", + "sync": "vite build && pushwork sync", + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@automerge/automerge": "3.3.0-fragments.1", + "@automerge/automerge-repo": "^2.6.0-subduction.26", + "@inkandswitch/patchwork-plugins": "^0.0.11", + "@inkandswitch/patchwork-providers": "0.3.0", + "solid-js": "^1.9.9" + }, + "devDependencies": { + "@inkandswitch/patchwork-bootloader": "^0.2.8", + "happy-dom": "^15.11.7", + "vite": "^7.1.9", + "vite-plugin-css-injected-by-js": "^3.5.2", + "vite-plugin-solid": "^2.11.10", + "vitest": "^3.2.4" + } +} diff --git a/csv/pnpm-lock.yaml b/csv/pnpm-lock.yaml new file mode 100644 index 00000000..cf6a82e5 --- /dev/null +++ b/csv/pnpm-lock.yaml @@ -0,0 +1,2118 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@automerge/automerge': + specifier: 3.3.0-fragments.1 + version: 3.3.0-fragments.1 + '@automerge/automerge-repo': + specifier: ^2.6.0-subduction.26 + version: 2.6.0-subduction.48 + '@inkandswitch/patchwork-plugins': + specifier: ^0.0.11 + version: 0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)) + '@inkandswitch/patchwork-providers': + specifier: 0.3.0 + version: 0.3.0(@automerge/automerge-repo@2.6.0-subduction.48) + solid-js: + specifier: ^1.9.9 + version: 1.9.15 + devDependencies: + '@inkandswitch/patchwork-bootloader': + specifier: ^0.2.8 + version: 0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.3))(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.29)(solid-js@1.9.15) + happy-dom: + specifier: ^15.11.7 + version: 15.11.7 + vite: + specifier: ^7.1.9 + version: 7.3.6(@types/node@20.19.43) + vite-plugin-css-injected-by-js: + specifier: ^3.5.2 + version: 3.5.2(vite@7.3.6(@types/node@20.19.43)) + vite-plugin-solid: + specifier: ^2.11.10 + version: 2.11.14(solid-js@1.9.15)(vite@7.3.6(@types/node@20.19.43)) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7) + +packages: + + '@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c': + resolution: {integrity: sha512-VM1tL9G+qQZ+0s0CM7DxBFkldwv5/GwqwZsW9U33qIU4LJES4MLc8QNeTtg9EJ7n7Pv0aovghRBaro2RdQHk8g==} + + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.29': + resolution: {integrity: sha512-2NnV1U6l0jNWUpRoL24p8j2/G+CsVPRQfqZDQwZXderrdo2FkaZxVAIM8HXd96xLbIV0HiMsTMGWDo1nQdVTZA==} + + '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.29': + resolution: {integrity: sha512-PhEmtKzD+2s5kPf7divNJl0NjuCgJ9IRPKB5ZoqMCJOn0+6ZKAClQAqZ+rJKbI+XmjaJLGIKprnQ4HIx/vnW9Q==} + + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.21': + resolution: {integrity: sha512-9advk34JgRvBRLtSnMIrP4jhkC6eKB2bbH+v/q+zsRLkk3vrdTxQCWcuh1LqdbF3U8DEZmIjU9M7pYumS9Mwaw==} + + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.29': + resolution: {integrity: sha512-M44FxVH01ihg2bLhwuEqnzkLd69I5LRROvRsS2Nf/7kvo4Smxkwr38ATKRc9cEzIF9Hla++6Gsf4Jj7vFgpzug==} + + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.29': + resolution: {integrity: sha512-Tzr3n3rfUFq2rOgc0HeIDm1dorUBHkg0DUl7qXqMa9iyincSOCdFfyr8GQBGe9rDIWYfhy8QSccwY8gEqflrWg==} + + '@automerge/automerge-repo@2.6.0-subduction.21': + resolution: {integrity: sha512-MG9v9c7z0zrGmXpdwkeuSiOcdAO+vmPp857WoJrECBG6au0uKJqXszndahLZYXUoolZw3TvByVAOe7Swosft+w==} + + '@automerge/automerge-repo@2.6.0-subduction.29': + resolution: {integrity: sha512-tTx13I4egBCwDeVoSXG8y9+p7O1VDgY0erD67crBJek/oasme32+cchHimE7Tl4Zf5a+5mJ90qMRV5f3J48KcQ==} + + '@automerge/automerge-repo@2.6.0-subduction.48': + resolution: {integrity: sha512-HNS1YsD0XmQ0vtwIincF7NvEBrQuOMnk+mjCVluRiIpUFvcaVeNAFXCCyBVeXubmg88AHe+LkIQ96v/lyHZ7Wg==} + engines: {node: '>=22.13'} + + '@automerge/automerge-subduction@0.13.0': + resolution: {integrity: sha512-7XlCIS0GH3ctboE8UPmD9rfiv203Q8n5Lx63xE6xCgtlpwecDKqzLDp77e9hUEo76j17Y8bYQyfsROqaByLf5A==} + + '@automerge/automerge-subduction@0.15.0': + resolution: {integrity: sha512-UxD3hfzZoH9yj2/YQZZSr9bJH7R/l+xIIZ9nMPMpsBOAlEFI5AEC4hkVOqC52QOCwq4bcqek0gTYkSWWfNvc2A==} + + '@automerge/automerge-subduction@0.16.1': + resolution: {integrity: sha512-alH7U4eYn0O7sT6hNLv7CJhNODJi+QD6qfRQTNfZCxL3Q5JZdJ8lFpdvXUkBf+I3bu0GLvVlTaYPWGrBihjpIw==} + + '@automerge/automerge@3.3.0-fragments.1': + resolution: {integrity: sha512-Im2n3qcYSuIsLDSjofTRo7CZyyvFn5pKLSCCO5sjWzESw+9q9G25ZIrL2mczDX43cXtyzi+w/4b9VUaiNdY56Q==} + + '@automerge/automerge@3.3.2': + resolution: {integrity: sha512-9vCdCL7pdQwUra66SBxPVHr+/t9epXKni9KDeak2rNBFMzABVh2u6gSpcwxi3jkR5qr047jVqZv9DlrOfVxLFw==} + + '@automerge/automerge@3.4.1': + resolution: {integrity: sha512-zsZpbs/iDPvp+ZojIYd+gxmbcPVz2Xbkcx778G8zrt3E0zS+6saHJOm666lOuZyNRlTV4wHw9qzGTKueedeCsQ==} + + '@automerge/vanillajs@2.6.0-subduction.29': + resolution: {integrity: sha512-Vbpz8D4PE8CqVtuTN/2D1XWM0ZhrpFD1zpmbeexn8FQestDR4bIWfp8rnfPFoqE6R/t6+96Rr9wTkZhVba0Bgw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + resolution: {integrity: sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + resolution: {integrity: sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + resolution: {integrity: sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + resolution: {integrity: sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + resolution: {integrity: sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + resolution: {integrity: sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@inkandswitch/patchwork-bootloader@0.2.8': + resolution: {integrity: sha512-jXH5Q2JF4zoyR3nw0JYTi8yLfXfFC0PsvDKVXs+Ko48lZb9NRTWphRrvrtoM+AoDVEx/lgBEHjzA6uKqHlj8AA==} + peerDependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.29 + '@automerge/automerge-repo-keyhive': 0.3.0-alpha.sub.1c + '@automerge/vanillajs': 2.6.0-subduction.29 + + '@inkandswitch/patchwork-elements@2.0.0': + resolution: {integrity: sha512-P1fF5IBwVQQwklGcInzW+SzYdMZ60Er5lfXlSLTc7E1QB3KDlpUrSC9wsBcqTG+OOEwZQ53G4MtTGrdfMqYFmw==} + peerDependencies: + '@automerge/automerge-repo': '*' + '@inkandswitch/patchwork-filesystem': ^0.0.8 + '@inkandswitch/patchwork-plugins': ^0.0.11 + '@inkandswitch/patchwork-providers': ^0.3.0 + '@types/react': '*' + react: '*' + solid-js: '*' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + solid-js: + optional: true + + '@inkandswitch/patchwork-filesystem@0.0.8': + resolution: {integrity: sha512-gfS7OHC2W1xG0EWl5yQMPQl+Ra/hLsgus9X0VmwHvsovFSR9tun7Clz3XcIyAqEU7VpEtCkGrxHkMgw54SJxlg==} + peerDependencies: + '@automerge/automerge': '*' + '@automerge/automerge-repo': '*' + + '@inkandswitch/patchwork-plugins@0.0.11': + resolution: {integrity: sha512-ElwDEixpZN64gdoE7EU8QFz4WzvyJt6j4Zn0pTezsW6SzGlx+Rc6Ox+7POK7gqEzQEcl3Iqo8+QdYRucYllRFw==} + peerDependencies: + '@automerge/automerge': '*' + '@automerge/automerge-repo': '*' + '@inkandswitch/patchwork-filesystem': ^0.0.8 + + '@inkandswitch/patchwork-providers@0.3.0': + resolution: {integrity: sha512-CUxWbONfOiz5SCzOpX/7zDTMePWgq7e4778eBJpbjC5Et4lnyL2/UrOZ6YJ4HiCG7r7MbfM86GUi63bqjTeutg==} + peerDependencies: + '@automerge/automerge-repo': '*' + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyhive/keyhive@0.0.0-alpha.56': + resolution: {integrity: sha512-hx/MRkRqCSJFIh5jHKs9HkzDQy4tUZzgaRlc85+8t22VciubmZAU1FyugmYboskPUxUGwqZC1GarwXfxhrp5DA==} + + '@keyhive/keyhive@0.0.0-alpha.57g': + resolution: {integrity: sha512-o8u0emy+vQQPmYKS8HqyjAUw9hXEApXv3tt+G9ZUJUPMVTuaor17N4BKFmNdAiH2Jt/TTX7Vsr8QaRNRf0FOIA==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@rollup/rollup-android-arm-eabi@4.63.0': + resolution: {integrity: sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.0': + resolution: {integrity: sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.0': + resolution: {integrity: sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.0': + resolution: {integrity: sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.0': + resolution: {integrity: sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.0': + resolution: {integrity: sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.0': + resolution: {integrity: sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.0': + resolution: {integrity: sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.0': + resolution: {integrity: sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.0': + resolution: {integrity: sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.0': + resolution: {integrity: sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.0': + resolution: {integrity: sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.0': + resolution: {integrity: sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.0': + resolution: {integrity: sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.0': + resolution: {integrity: sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.0': + resolution: {integrity: sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.0': + resolution: {integrity: sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.0': + resolution: {integrity: sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.0': + resolution: {integrity: sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.0': + resolution: {integrity: sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.0': + resolution: {integrity: sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.0': + resolution: {integrity: sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.0': + resolution: {integrity: sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.0': + resolution: {integrity: sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.0': + resolution: {integrity: sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/serviceworker@0.0.153': + resolution: {integrity: sha512-/cg6dFEkNchJLyRCGo4Gb8mF200qr3xskM5dCPgbtK0OzXxcFcXa6BEBdyG7JksRsTrvCR+V6aFPncoOYAwYhQ==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + babel-plugin-jsx-dom-expressions@0.40.10: + resolution: {integrity: sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-preset-solid@1.9.15: + resolution: {integrity: sha512-GBmg1OiPb+OwcH51XbDAKPtvrPfQW7rCJTJxcp8+yhtWwN+kqnbEJk2SgVybd+uhTxTKAvjaFyiQSr/eUZBwzg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.15 + peerDependenciesMeta: + solid-js: + optional: true + + base-x@4.0.1: + resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + baseline-browser-mapping@2.11.19: + resolution: {integrity: sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs58@5.0.0: + resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + + bs58check@3.0.1: + resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} + + bs58check@4.0.0: + resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + cbor-extract@2.2.2: + resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} + hasBin: true + + cbor-x@1.6.6: + resolution: {integrity: sha512-8QiD9PGOxyQHo7s2pzwTBH6lTjqekxPdl9Aq6fXvZgCuCJHOht1puDEA/fTr6mciB76c+M+Gi0qT2i1a4pm4Wg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.415: + resolution: {integrity: sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + happy-dom@15.11.7: + resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} + engines: {node: '>=18.0.0'} + + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + rollup@4.63.0: + resolution: {integrity: sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + solid-js@1.9.15: + resolution: {integrity: sha512-EeiY2xfpZJqPLjXspVEKjAII4yv8NyG//NxZ3IpOFHdUNnnTyL0uJOeS9LWGvA7cFCz5y94cjFwYlmw5Luncsg==} + + solid-refresh@0.6.3: + resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==} + peerDependencies: + solid-js: ^1.3 + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinyargs@0.1.4: + resolution: {integrity: sha512-5OpdhMRRE70j0zT7mrBpx12FJI+y0EGx8zMe8Vl2/aiwGgpW3X70OVHlsHmHeeG0ncnnWdq1o+k/S5d9ONgzGA==} + engines: {node: '>=14'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite-plugin-css-injected-by-js@3.5.2: + resolution: {integrity: sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==} + peerDependencies: + vite: '>2.0.0-0' + + vite-plugin-solid@2.11.14: + resolution: {integrity: sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw==} + peerDependencies: + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0 + solid-js: ^1.7.2 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + '@testing-library/jest-dom': + optional: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xstate@5.32.6: + resolution: {integrity: sha512-WfA8WNrh6r9osuGwVm+aIPM4jmMW2LKHV1Yv9thYpOtL4HVF/kobh95K/O8v2jDCpDmP2EoY+6uvuqHXCZ6ZLw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.3)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.21 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.21 + '@automerge/automerge-subduction': 0.13.0 + '@keyhive/keyhive': 0.0.0-alpha.57g + cbor-x: 1.6.6 + eventemitter3: 5.0.4 + isomorphic-ws: 5.0.0(ws@8.21.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + - ws + + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.29': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.29 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.29': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.29 + debug: 4.4.3 + eventemitter3: 5.0.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.21': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.21 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + isomorphic-ws: 5.0.0(ws@8.21.3) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.29': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.29 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.29': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.29 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo@2.6.0-subduction.21': + dependencies: + '@automerge/automerge': 3.4.1 + '@automerge/automerge-subduction': 0.13.0 + bs58check: 3.0.1 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 9.0.1 + ws: 8.21.3 + xstate: 5.32.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo@2.6.0-subduction.29': + dependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-subduction': 0.15.0 + bs58check: 4.0.0 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 14.0.2 + ws: 8.21.3 + xstate: 5.32.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo@2.6.0-subduction.48': + dependencies: + '@automerge/automerge': 3.3.2 + '@automerge/automerge-subduction': 0.16.1 + bs58check: 4.0.0 + cbor-x: 1.6.6 + debug: 4.4.3 + eventemitter3: 5.0.4 + fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.21.3) + uuid: 14.0.2 + ws: 8.21.3 + xstate: 5.32.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-subduction@0.13.0': {} + + '@automerge/automerge-subduction@0.15.0': {} + + '@automerge/automerge-subduction@0.16.1': {} + + '@automerge/automerge@3.3.0-fragments.1': {} + + '@automerge/automerge@3.3.2': {} + + '@automerge/automerge@3.4.1': {} + + '@automerge/vanillajs@2.6.0-subduction.29': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.29 + '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.29 + '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.29 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.29 + '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.29 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.2': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.2': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@inkandswitch/patchwork-bootloader@0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(ws@8.21.3))(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@automerge/vanillajs@2.6.0-subduction.29)(solid-js@1.9.15)': + dependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo-keyhive': 0.3.0-alpha.sub.1c(ws@8.21.3) + '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.29 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.29 + '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.29 + '@automerge/automerge-subduction': 0.15.0 + '@automerge/vanillajs': 2.6.0-subduction.29 + '@inkandswitch/patchwork-elements': 2.0.0(@automerge/automerge-repo@2.6.0-subduction.48)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.48))(solid-js@1.9.15) + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)) + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.48) + '@keyhive/keyhive': 0.0.0-alpha.56 + '@types/debug': 4.1.13 + debug: 4.4.3 + resolve.exports: 2.0.3 + service-worker-types: '@types/serviceworker@0.0.153' + tinyargs: 0.1.4 + transitivePeerDependencies: + - '@types/react' + - bufferutil + - react + - solid-js + - supports-color + - utf-8-validate + + '@inkandswitch/patchwork-elements@2.0.0(@automerge/automerge-repo@2.6.0-subduction.48)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1))(@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)))(@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.48))(solid-js@1.9.15)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1) + '@inkandswitch/patchwork-plugins': 0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)) + '@inkandswitch/patchwork-providers': 0.3.0(@automerge/automerge-repo@2.6.0-subduction.48) + debug: 4.4.3 + optionalDependencies: + solid-js: 1.9.15 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)': + dependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + debug: 4.4.3 + resolve.exports: 2.0.3 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-plugins@0.0.11(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1)(@inkandswitch/patchwork-filesystem@0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1))': + dependencies: + '@automerge/automerge': 3.3.0-fragments.1 + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@inkandswitch/patchwork-filesystem': 0.0.8(@automerge/automerge-repo@2.6.0-subduction.48)(@automerge/automerge@3.3.0-fragments.1) + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + debug: 4.4.3 + eventemitter3: 5.0.4 + resolve.exports: 2.0.3 + transitivePeerDependencies: + - supports-color + + '@inkandswitch/patchwork-providers@0.3.0(@automerge/automerge-repo@2.6.0-subduction.48)': + dependencies: + '@automerge/automerge-repo': 2.6.0-subduction.48 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyhive/keyhive@0.0.0-alpha.56': {} + + '@keyhive/keyhive@0.0.0-alpha.57g': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@noble/hashes@1.8.0': {} + + '@rollup/rollup-android-arm-eabi@4.63.0': + optional: true + + '@rollup/rollup-android-arm64@4.63.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.0': + optional: true + + '@rollup/rollup-darwin-x64@4.63.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.0': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/ms@2.1.0': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/serviceworker@0.0.153': {} + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@20.19.43))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@20.19.43) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + babel-plugin-jsx-dom-expressions@0.40.10(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-preset-solid@1.9.15(@babel/core@7.29.7)(solid-js@1.9.15): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.10(@babel/core@7.29.7) + optionalDependencies: + solid-js: 1.9.15 + + base-x@4.0.1: {} + + base-x@5.0.1: {} + + baseline-browser-mapping@2.11.19: {} + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.19 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.415 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) + + bs58@5.0.0: + dependencies: + base-x: 4.0.1 + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + + bs58check@3.0.1: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 5.0.0 + + bs58check@4.0.0: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 6.0.0 + + cac@6.7.14: {} + + caniuse-lite@1.0.30001810: {} + + cbor-extract@2.2.2: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.2 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm': 2.2.2 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.2 + '@cbor-extract/cbor-extract-linux-x64': 2.2.2 + '@cbor-extract/cbor-extract-win32-x64': 2.2.2 + optional: true + + cbor-x@1.6.6: + optionalDependencies: + cbor-extract: 2.2.2 + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + detect-libc@2.1.2: + optional: true + + electron-to-chromium@1.5.415: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + fast-sha256@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + happy-dom@15.11.7: + dependencies: + entities: 4.5.0 + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + + html-entities@2.3.3: {} + + is-what@4.1.16: {} + + isomorphic-ws@5.0.0(ws@8.21.3): + dependencies: + ws: 8.21.3 + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + loupe@3.2.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + + ms@2.1.3: {} + + nanoid@3.3.18: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-releases@2.0.53: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + resolve.exports@2.0.3: {} + + rollup@4.63.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.0 + '@rollup/rollup-android-arm64': 4.63.0 + '@rollup/rollup-darwin-arm64': 4.63.0 + '@rollup/rollup-darwin-x64': 4.63.0 + '@rollup/rollup-freebsd-arm64': 4.63.0 + '@rollup/rollup-freebsd-x64': 4.63.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.0 + '@rollup/rollup-linux-arm-musleabihf': 4.63.0 + '@rollup/rollup-linux-arm64-gnu': 4.63.0 + '@rollup/rollup-linux-arm64-musl': 4.63.0 + '@rollup/rollup-linux-loong64-gnu': 4.63.0 + '@rollup/rollup-linux-loong64-musl': 4.63.0 + '@rollup/rollup-linux-ppc64-gnu': 4.63.0 + '@rollup/rollup-linux-ppc64-musl': 4.63.0 + '@rollup/rollup-linux-riscv64-gnu': 4.63.0 + '@rollup/rollup-linux-riscv64-musl': 4.63.0 + '@rollup/rollup-linux-s390x-gnu': 4.63.0 + '@rollup/rollup-linux-x64-gnu': 4.63.0 + '@rollup/rollup-linux-x64-musl': 4.63.0 + '@rollup/rollup-openbsd-x64': 4.63.0 + '@rollup/rollup-openharmony-arm64': 4.63.0 + '@rollup/rollup-win32-arm64-msvc': 4.63.0 + '@rollup/rollup-win32-ia32-msvc': 4.63.0 + '@rollup/rollup-win32-x64-gnu': 4.63.0 + '@rollup/rollup-win32-x64-msvc': 4.63.0 + fsevents: 2.3.3 + + semver@6.3.1: {} + + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + + siginfo@2.0.0: {} + + solid-js@1.9.15: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + + solid-refresh@0.6.3(solid-js@1.9.15): + dependencies: + '@babel/generator': 7.29.8 + '@babel/helper-module-imports': 7.29.7 + '@babel/types': 7.29.8 + solid-js: 1.9.15 + transitivePeerDependencies: + - supports-color + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinyargs@0.1.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.3.1(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + uuid@14.0.2: {} + + uuid@9.0.1: {} + + vite-node@3.2.4(@types/node@20.19.43): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-plugin-css-injected-by-js@3.5.2(vite@7.3.6(@types/node@20.19.43)): + dependencies: + vite: 7.3.6(@types/node@20.19.43) + + vite-plugin-solid@2.11.14(solid-js@1.9.15)(vite@7.3.6(@types/node@20.19.43)): + dependencies: + '@babel/core': 7.29.7 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.15(@babel/core@7.29.7)(solid-js@1.9.15) + merge-anything: 5.1.7 + solid-js: 1.9.15 + solid-refresh: 0.6.3(solid-js@1.9.15) + vite: 7.3.6(@types/node@20.19.43) + vitefu: 1.1.3(vite@7.3.6(@types/node@20.19.43)) + transitivePeerDependencies: + - supports-color + + vite@7.3.6(@types/node@20.19.43): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.26 + rollup: 4.63.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + + vitefu@1.1.3(vite@7.3.6(@types/node@20.19.43)): + optionalDependencies: + vite: 7.3.6(@types/node@20.19.43) + + vitest@3.2.7(@types/debug@4.1.13)(@types/node@20.19.43)(happy-dom@15.11.7): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@20.19.43)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@20.19.43) + vite-node: 3.2.4(@types/node@20.19.43) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 20.19.43 + happy-dom: 15.11.7 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + webidl-conversions@7.0.0: {} + + whatwg-mimetype@3.0.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.3: {} + + xstate@5.32.6: {} + + yallist@3.1.1: {} diff --git a/csv/pnpm-workspace.yaml b/csv/pnpm-workspace.yaml new file mode 100644 index 00000000..07535357 --- /dev/null +++ b/csv/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +# pnpm 11 reads its settings from this file. There is no root workspace, so +# every package carries the settings it needs rather than inheriting them. +minimumReleaseAge: 0 +verifyDepsBeforeRun: false +allowBuilds: + "@swc/core": true + cbor-extract: true + core-js: true + esbuild: true diff --git a/csv/src/index.ts b/csv/src/index.ts new file mode 100644 index 00000000..c0b22a75 --- /dev/null +++ b/csv/src/index.ts @@ -0,0 +1,18 @@ +import type { Plugin } from "@inkandswitch/patchwork-plugins"; + +export const plugins: Plugin[] = [ + // A provenance-aware CSV table view for `file` documents: cells that other + // documents were generated from are marked, the shared focus emphasises + // them, and clicking a cell pushes its linked targets into the shared + // selection. See `tool.tsx`. + { + type: "patchwork:tool", + id: "csv", + name: "CSV", + icon: "Table", + supportedDatatypes: ["file"], + async load() { + return (await import("./tool")).default; + }, + }, +]; diff --git a/csv/src/parse-csv.test.ts b/csv/src/parse-csv.test.ts new file mode 100644 index 00000000..b6e69e63 --- /dev/null +++ b/csv/src/parse-csv.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { parseCsv, sniffDelimiter, looksLikeCsv } from "./parse-csv"; + +const values = (text: string, delimiter?: string) => + parseCsv(text, delimiter).map((row) => row.map((c) => c.value)); + +describe("parseCsv", () => { + it("parses plain rows", () => { + expect(values("a,b,c\n1,2,3")).toEqual([ + ["a", "b", "c"], + ["1", "2", "3"], + ]); + }); + + it("returns no rows for empty input", () => { + expect(parseCsv("")).toEqual([]); + }); + + it("keeps empty cells", () => { + expect(values("a,,c\n,,")).toEqual([ + ["a", "", "c"], + ["", "", ""], + ]); + }); + + it("parses a trailing empty cell", () => { + expect(values("a,b,")).toEqual([["a", "b", ""]]); + }); + + it("ignores a trailing newline", () => { + expect(values("a,b\n")).toEqual([["a", "b"]]); + }); + + it("handles CRLF line endings", () => { + expect(values("a,b\r\nc,d")).toEqual([ + ["a", "b"], + ["c", "d"], + ]); + }); + + it("decodes quoted cells with delimiters, newlines and escaped quotes", () => { + expect(values('"a,b",plain\n"line\nbreak","say ""hi"""')).toEqual([ + ["a,b", "plain"], + ["line\nbreak", 'say "hi"'], + ]); + }); + + it("takes an unterminated quote to the end of input", () => { + expect(values('a,"oops')).toEqual([["a", "oops"]]); + }); + + it("supports tab as delimiter", () => { + expect(values("a\tb\nc\td", "\t")).toEqual([ + ["a", "b"], + ["c", "d"], + ]); + }); + + it("tracks each cell's exact source range", () => { + const text = 'aa,"b,b"\ncc,dd'; + const rows = parseCsv(text); + for (const row of rows) { + for (const cell of row) { + expect(text.slice(cell.start, cell.end)).toContain( + cell.value.replace(/"/g, ""), + ); + } + } + expect(rows[0][0]).toMatchObject({ start: 0, end: 2 }); + expect(rows[0][1]).toMatchObject({ start: 3, end: 8 }); // includes quotes + expect(rows[1][0]).toMatchObject({ start: 9, end: 11 }); + expect(rows[1][1]).toMatchObject({ start: 12, end: 14 }); + }); +}); + +describe("sniffDelimiter", () => { + it("prefers the extension", () => { + expect(sniffDelimiter("tsv", "a,b,c")).toBe("\t"); + }); + it("counts separators in the first line", () => { + expect(sniffDelimiter(undefined, "a\tb\tc")).toBe("\t"); + expect(sniffDelimiter(undefined, "a,b,c")).toBe(","); + }); +}); + +describe("looksLikeCsv", () => { + it("accepts csv/tsv extensions, names and mime types", () => { + expect(looksLikeCsv({ extension: "csv" })).toBe(true); + expect(looksLikeCsv({ name: "data.tsv" })).toBe(true); + expect(looksLikeCsv({ mimeType: "text/csv" })).toBe(true); + expect(looksLikeCsv({ name: "notes.md", mimeType: "text/markdown" })).toBe( + false, + ); + }); +}); diff --git a/csv/src/parse-csv.ts b/csv/src/parse-csv.ts new file mode 100644 index 00000000..b443e295 --- /dev/null +++ b/csv/src/parse-csv.ts @@ -0,0 +1,102 @@ +// An offset-tracking CSV parser: every cell carries the [start, end) character +// range it occupies in the source text (quotes included). Those offsets are +// what connect cells to Patchwork's cursor-anchored provenance refs, which +// address character ranges of the file doc's `content` string. + +export type CsvCell = { + /** The cell's decoded value (quotes stripped, "" unescaped). */ + value: string; + /** Character offset of the cell's first char in the source (inclusive). */ + start: number; + /** Character offset just past the cell's last char (exclusive). */ + end: number; +}; + +/** + * RFC-4180-flavoured: cells separated by `delimiter`, rows by \n or \r\n, + * quoted cells may contain delimiters/newlines and escape quotes by doubling. + * An unterminated quote runs to the end of input. A trailing newline does not + * produce an empty final row. + */ +export function parseCsv(text: string, delimiter = ","): CsvCell[][] { + if (text.length === 0) return []; + const rows: CsvCell[][] = []; + let row: CsvCell[] = []; + let i = 0; + + for (;;) { + const start = i; + let value = ""; + + if (text[i] === '"') { + i++; + for (;;) { + if (i >= text.length) break; + if (text[i] === '"') { + if (text[i + 1] === '"') { + value += '"'; + i += 2; + } else { + i++; + break; + } + } else { + value += text[i]; + i++; + } + } + } else { + while ( + i < text.length && + text[i] !== delimiter && + text[i] !== "\n" && + text[i] !== "\r" + ) { + value += text[i]; + i++; + } + } + row.push({ value, start, end: i }); + + if (i >= text.length) { + rows.push(row); + break; + } + if (text[i] === delimiter) { + i++; + continue; + } + // Row terminator. + if (text[i] === "\r" && text[i + 1] === "\n") i += 2; + else i++; + rows.push(row); + row = []; + if (i >= text.length) break; + } + return rows; +} + +/** The delimiter a file most likely uses, from its name/extension/content. */ +export function sniffDelimiter( + extension: string | undefined, + firstLine: string, +): string { + if (extension?.toLowerCase() === "tsv") return "\t"; + const tabs = (firstLine.match(/\t/g) ?? []).length; + const commas = (firstLine.match(/,/g) ?? []).length; + return tabs > commas ? "\t" : ","; +} + +/** Whether a file doc looks like CSV/TSV (by name, mime type or extension). */ +export function looksLikeCsv(meta: { + name?: string; + extension?: string; + mimeType?: string; +}): boolean { + const ext = (meta.extension || meta.name?.split(".").pop() || "") + .toLowerCase() + .trim(); + if (ext === "csv" || ext === "tsv") return true; + const mime = (meta.mimeType || "").toLowerCase(); + return mime.includes("csv") || mime.includes("tab-separated"); +} diff --git a/csv/src/styles.css b/csv/src/styles.css new file mode 100644 index 00000000..57c1d210 --- /dev/null +++ b/csv/src/styles.css @@ -0,0 +1,61 @@ +.csv-tool { + height: 100%; + overflow: auto; + background: var(--text-editor-fill, #fff); + color: var(--text-primary, #1a1a1a); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 13px; +} + +.csv-empty { + padding: 2rem; + color: var(--text-muted, #888); +} + +.csv-table { + border-collapse: collapse; + min-width: 100%; +} + +.csv-table td { + border: 1px solid var(--border-subtle, #e3e3e3); + padding: 0.3rem 0.6rem; + white-space: pre-wrap; + vertical-align: top; + max-width: 32rem; +} + +/* The first row is the header: sticky, and set apart from the data rows. */ +.csv-table tr:first-child td { + position: sticky; + top: 0; + background: var(--bg-mid, #f5f5f5); + font-weight: 600; + z-index: 1; +} + +/* Same highlighter-over-paper scheme as the codemirror provenance extension: + a tint anchored to the editor surface so it tracks the theme, and a dotted + underline that reads as "this points somewhere". */ +.csv-cell.is-linked { + cursor: pointer; + box-shadow: inset 0 -2px 0 0 var(--studio-link, #3478f6); + background-color: color-mix( + in oklch, + var(--studio-link, #3478f6), + var(--text-editor-fill, #fff) 85% + ); +} + +.csv-cell.is-linked.is-emphasised { + background-color: color-mix( + in oklch, + var(--studio-link, #3478f6), + var(--text-editor-fill, #fff) 56% + ); +} + +.csv-cell.is-selected { + outline: 2px solid var(--studio-link, #3478f6); + outline-offset: -2px; +} diff --git a/csv/src/tool.tsx b/csv/src/tool.tsx new file mode 100644 index 00000000..ad475a33 --- /dev/null +++ b/csv/src/tool.tsx @@ -0,0 +1,328 @@ +import "./styles.css"; +import type { + AutomergeUrl, + DocHandle, + Repo, +} from "@automerge/automerge-repo/slim"; +import type { ToolImplementation } from "@inkandswitch/patchwork-plugins"; +import { subscribe } from "@inkandswitch/patchwork-providers"; +import { createMemo, createSignal, For, onCleanup, Show } from "solid-js"; +import { render } from "solid-js/web"; +import { parseCsv, sniffDelimiter, type CsvCell } from "./parse-csv"; + +// A read-only CSV table view for `file` documents, with provenance: +// +// - subscribes to `patchwork:provenance` for the file and marks every cell +// that overlaps a source range (text another document was generated from); +// - draws the stronger tint when the shared focus selection/highlight +// lands on a source range or one of its targets (e.g. an element selected +// on a Petrinaut canvas); +// - clicking a cell pushes the overlapping source refs and their linked +// targets into the shared focus selection, so views of the generated doc +// highlight what came from that cell. +// +// The offsets that make this work come from the offset-tracking parser in +// `parse-csv.ts`: cursor-anchored provenance refs address character ranges of +// the file's `content` string, and every cell knows the range it occupies. +// Outside a provenance provider the subscription is never answered and the +// tool is just a CSV viewer. + +type FileDoc = { + name?: string; + extension?: string; + mimeType?: string; + content?: unknown; +}; + +type FocusDoc = { + selection: Record; + highlight: Record; +}; + +// Mirrors the corkboard's ProvenanceLink structurally (the packages are +// standalone, so the type is not imported). +type ProvenanceLink = { + sourceUrl: AutomergeUrl; + targetUrl: AutomergeUrl; + entryUrl: AutomergeUrl; +}; + +// A provenance source range in this file, resolved to a live ref handle, +// with the target refs (in other docs) it links to. +type ProvenanceSource = { + handle: DocHandle; + targetUrls: AutomergeUrl[]; +}; + +// A source range materialized to plain offsets for the current doc state. +type SourceSpan = { + start: number; + end: number; + sourceUrl: AutomergeUrl; + targetUrls: AutomergeUrl[]; + emphasised: boolean; +}; + +const mount: ToolImplementation = (handle, element) => { + const repo = (element as HTMLElement & { repo?: Repo }).repo; + return render( + () => ( + } + element={element} + repo={repo} + /> + ), + element, + ); +}; + +export default mount; + +function CsvTool(props: { + handle: DocHandle; + element: HTMLElement; + repo: Repo | undefined; +}) { + let disposed = false; + onCleanup(() => { + disposed = true; + }); + + // --- document text ------------------------------------------------------ + + const [docVersion, setDocVersion] = createSignal(0); + const bump = () => setDocVersion((v) => v + 1); + props.handle.on("change", bump); + onCleanup(() => props.handle.off("change", bump)); + + const text = createMemo(() => { + docVersion(); + return contentText(props.handle.doc()); + }); + + const rows = createMemo(() => { + const t = text(); + if (t == null) return []; + const extension = props.handle.doc()?.extension; + return parseCsv(t, sniffDelimiter(extension, t.split("\n", 1)[0] ?? "")); + }); + + // --- provenance sources (ranges of this file other docs came from) ------- + + const [sources, setSources] = createSignal([]); + + onCleanup( + subscribe( + props.element, + { type: "patchwork:provenance", url: props.handle.url }, + (links) => void applyLinks(links), + ), + ); + + // Scopes links to the ones whose SOURCE lives in this doc, dedupes by + // source ref, and resolves each source to a live handle. + async function applyLinks(links: ProvenanceLink[]) { + const repo = props.repo; + if (!repo) return; + const targetsBySource = new Map>(); + for (const link of links) { + if (!link.sourceUrl.startsWith(props.handle.url)) continue; + let targets = targetsBySource.get(link.sourceUrl); + if (!targets) { + targetsBySource.set(link.sourceUrl, (targets = new Set())); + } + targets.add(link.targetUrl); + } + const next: ProvenanceSource[] = []; + for (const [sourceUrl, targets] of targetsBySource) { + next.push({ + handle: await repo.find(sourceUrl), + targetUrls: [...targets], + }); + } + if (disposed) return; + setSources(next); + } + + // --- shared focus (selection/highlight from other views) ----------------- + + const [focusUrls, setFocusUrls] = createSignal>(new Set()); + const [emphasisHandles, setEmphasisHandles] = createSignal< + DocHandle[] + >([]); + + let focusHandle: DocHandle | undefined; + const onFocusChange = () => void refreshEmphasis(); + + onCleanup( + subscribe( + props.element, + { type: "patchwork:focus" }, + (url) => void attachFocus(url), + ), + ); + onCleanup(() => focusHandle?.off("change", onFocusChange)); + + async function attachFocus(url: AutomergeUrl) { + const repo = props.repo; + if (!repo) return; + focusHandle?.off("change", onFocusChange); + const handle = await repo.find(url); + if (disposed) return; + focusHandle = handle; + handle.on("change", onFocusChange); + await refreshEmphasis(); + } + + // Focus refs (selection ∪ highlight) scoped to this doc, resolved to + // handles so the span memo can test overlap against the source ranges. The + // raw url set is kept too: a source range is also emphasised when the focus + // holds one of its TARGETS, which never resolves into this doc. + async function refreshEmphasis() { + const repo = props.repo; + const doc = focusHandle?.doc(); + if (!repo) return; + const urls = [ + ...Object.keys(doc?.selection ?? {}), + ...Object.keys(doc?.highlight ?? {}), + ] as AutomergeUrl[]; + const refs: DocHandle[] = []; + for (const url of urls) { + if (url.startsWith(props.handle.url)) { + refs.push(await repo.find(url)); + } + } + if (disposed) return; + setFocusUrls(new Set(urls)); + setEmphasisHandles(refs); + } + + // --- spans: source ranges as plain offsets, with emphasis resolved ------- + + // Ranges are re-read from the ref handles on every doc change (edits move + // them) and whenever sources or focus change. + const spans = createMemo(() => { + docVersion(); + const urls = focusUrls(); + const emphasis = emphasisHandles(); + const out: SourceSpan[] = []; + for (const source of sources()) { + const positions = source.handle.rangePositions(); + if (!positions) continue; + const [start, end] = positions; + if (start === end) continue; + const emphasised = + source.targetUrls.some((url) => urls.has(url)) || + emphasis.some((ref) => { + const p = ref.rangePositions(); + return p != null && p[0] < end && p[1] > start; + }); + out.push({ + start, + end, + sourceUrl: source.handle.url, + targetUrls: source.targetUrls, + emphasised, + }); + } + return out; + }); + + const spansFor = (cell: CsvCell): SourceSpan[] => + spans().filter((s) => s.start < cell.end && s.end > cell.start); + + // --- outbound: cell click → shared focus selection ----------------------- + + const [selected, setSelected] = createSignal(); + + // Our contribution to the shared selection, replaced wholesale on every + // click and withdrawn on unmount, so stale entries never accumulate. + let pushedUrls: AutomergeUrl[] = []; + const pushFocus = (urls: AutomergeUrl[]) => { + if (!focusHandle) return; + if (urls.length === 0 && pushedUrls.length === 0) return; + focusHandle.change((doc) => { + if (!doc.selection) doc.selection = {}; + for (const url of pushedUrls) delete doc.selection[url]; + for (const url of urls) doc.selection[url] = true; + }); + pushedUrls = urls; + }; + onCleanup(() => pushFocus([])); + + const onCellClick = (key: string, cell: CsvCell) => { + setSelected(key); + const urls = new Set(); + for (const span of spansFor(cell)) { + urls.add(span.sourceUrl); + for (const target of span.targetUrls) urls.add(target); + } + pushFocus([...urls]); + }; + + // --- render --------------------------------------------------------------- + + const cellView = (rowIndex: number, colIndex: number, cell: CsvCell) => { + const key = `${rowIndex}:${colIndex}`; + const overlapping = createMemo(() => spansFor(cell)); + return ( + 0, + "is-emphasised": overlapping().some((s) => s.emphasised), + "is-selected": selected() === key, + }} + onClick={() => onCellClick(key, cell)} + > + {cell.value} + + ); + }; + + return ( +
+ + This file has binary content — nothing to show as CSV. +
+ } + > + 0} + fallback={
Empty file.
} + > + + + + {(row, rowIndex) => ( + + + {(cell, colIndex) => + cellView(rowIndex(), colIndex(), cell) + } + + + )} + + +
+
+ +
+ ); +} + +// A file's content as text, or null when it is binary. Mirrors the file +// datatype's getFileContents: content is a string, a Uint8Array, or an +// automerge ImmutableString (which stringifies). +function contentText(doc: FileDoc | undefined): string | null { + const content = doc?.content; + if (typeof content === "string") return content; + if (content instanceof Uint8Array) return null; + if (content != null && typeof content === "object") return String(content); + return null; +} diff --git a/csv/tsconfig.json b/csv/tsconfig.json new file mode 100644 index 00000000..e466053a --- /dev/null +++ b/csv/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "outDir": "dist", + "declaration": true, + "lib": ["ESNext", "DOM"], + "types": ["vite/client"], + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "jsx": "preserve", + "jsxImportSource": "solid-js" + }, + "include": ["src"] +} diff --git a/csv/vite.config.ts b/csv/vite.config.ts new file mode 100644 index 00000000..b4f67118 --- /dev/null +++ b/csv/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vite"; +import solid from "vite-plugin-solid"; +import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; +import externals from "@inkandswitch/patchwork-bootloader/externals"; + +export default defineConfig({ + base: "./", + plugins: [solid(), cssInjectedByJsPlugin({ relativeCSSInjection: true })], + + build: { + sourcemap: true, + cssCodeSplit: true, + emptyOutDir: true, + minify: false, + rollupOptions: { + external: externals, + input: "./src/index.ts", + output: { + format: "es", + entryFileNames: "[name].js", + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: "assets/[name][extname]", + }, + preserveEntrySignatures: "strict", + }, + }, +}); diff --git a/csv/vitest.config.ts b/csv/vitest.config.ts new file mode 100644 index 00000000..f69a7dec --- /dev/null +++ b/csv/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import solid from "vite-plugin-solid"; + +export default defineConfig({ + plugins: [solid()], + test: { + environment: "happy-dom", + globals: true, + passWithNoTests: true, + }, + resolve: { + conditions: ["development", "browser"], + }, +}); diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index 9048f17d..5c37272a 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -4,8 +4,10 @@ import { createMemo, createSignal, For, + Match, onCleanup, Show, + Switch, type Accessor, } from "solid-js"; import { createDocSignal } from "solid-automerge"; @@ -27,6 +29,7 @@ import { } from "@inkandswitch/patchwork-providers-solid"; import type { ActorAttributionDoc, + AgentTag, ChangeGroup, ChangeGroupDoc, CheckedOutDraft, @@ -42,8 +45,11 @@ import { computeEditCounts, computeRangeEditCounts, getDocCreationTime, + parseAgentTag, sameHeads, + splitIntoGroups, } from "./change-group-cache"; +import { attributedHashes, frontierHashes } from "./merge-attribution"; import { ensureMainDraft } from "./draft-docs"; // Seed for the read-only `draft:list` subscription until the provider answers. @@ -63,7 +69,7 @@ const EMPTY_DRAFT_LIST: DraftList = { // Shown in the panel footer, logged on load, and stamped into fork // diagnostics; bump on deploy to tell builds apart. -const DRAFTS_VERSION = "0.0.45"; +const DRAFTS_VERSION = "0.0.55"; // Logged at module load so the console shows which build is running even // before the panel renders. @@ -79,6 +85,56 @@ export function DraftsSidebar(props: { element: HTMLElement }) { type: "draft:checked-out", }); + // The shared focus doc (served by the shell's focus provider). Writing an + // `openThread` request on it asks the comments panel to reveal that thread + // (see `openComment`); `openAgentChat` asks the agent tool to select a + // chat tab (see `openAgentChat`). Unresolved when no focus provider is + // around, in which case the write half is skipped. + const [, focusHandle] = subscribeDoc<{ + openThread?: { url: AutomergeUrl; at: number }; + openAgentChat?: { url: AutomergeUrl; at: number }; + }>(props.element, { type: "patchwork:focus" }); + + // Open a timeline comment in the comments panel: leave an `openThread` + // request on the focus doc for the panel to consume (pin + select the + // thread and scroll it into view), and ask the shell to switch the context + // sidebar to the comments tab via the bubbling `patchwork:open-context-tool` + // event. Both halves are late-bound and degrade to nothing when the + // comments tool (or a shell that handles the event) isn't installed. + const openComment = (comment: TimelineComment) => { + const threadUrl = comment.threadUrl; + if (threadUrl) { + focusHandle()?.change((d) => { + d.openThread = { url: threadUrl, at: Date.now() }; + }); + } + props.element.dispatchEvent( + new CustomEvent("patchwork:open-context-tool", { + detail: { toolId: "comments-view" }, + bubbles: true, + composed: true, + }) + ); + }; + + // Open the chat behind a "via agent" badge in the AGENT TAB (not as a + // document): the same two-part move as `openComment` — a one-shot + // `openAgentChat` request on the focus doc for the agent tool to consume + // (select that chat tab), plus the bubbling tab-switch event. Late-bound; + // degrades to nothing without the agent tool or a shell handling the event. + const openAgentChat = (agent: AgentTag) => { + focusHandle()?.change((d) => { + d.openAgentChat = { url: agent.chatUrl, at: Date.now() }; + }); + props.element.dispatchEvent( + new CustomEvent("patchwork:open-context-tool", { + detail: { toolId: "agent" }, + bubbles: true, + composed: true, + }) + ); + }; + // Read the checkout doc coarsely from the live handle (handle.doc()) rather // than a fine-grained patch-replay projection: the projection can render a // whole-value write doubled, whereas handle.doc() is always the correct @@ -202,7 +258,10 @@ export function DraftsSidebar(props: { element: HTMLElement }) { const base: CheckpointBase = bl ? { beforeTime: bl.time } : "none"; const seq = ++scrubSeq; void (async () => { - const checkpoint = await computeCheckpoint(repo, members, head.head, base); + const checkpoint = await computeCheckpoint(repo, members, head.head, base, { + to: head.memberHeads, + from: bl?.memberHeads, + }); // A newer scrub landed while this one was computing; drop it. if (seq !== scrubSeq) return; handle.change((d) => { @@ -313,9 +372,16 @@ export function DraftsSidebar(props: { element: HTMLElement }) { const members = membersFor(draftUrl); const seq = ++scrubSeq; void (async () => { - const checkpoint = await computeCheckpoint(repo, members, s.head, { - beforeTime: s.groupStartTime, - }); + const checkpoint = await computeCheckpoint( + repo, + members, + s.head, + { beforeTime: s.groupStartTime }, + // The head map is exact; the baseline `from`s use the time + // fallback until the changes list fills the seeded baseline's map + // in and recomputes (see the normalization effect there). + { to: s.memberHeads } + ); if (seq !== scrubSeq) return; handle.change((d) => { d.at = checkpoint; @@ -401,13 +467,16 @@ export function DraftsSidebar(props: { element: HTMLElement }) { const parentUrl = selected(); // null = main const members = membersFor(parentUrl); - const head = atVersion ? (scrubber()?.head ?? null) : null; + const scrub = atVersion ? scrubber() : null; + const head = scrub?.head ?? null; const clones: Record = {}; if (head) { // Reuse the scrub machinery to resolve per-doc heads at this version // (only the `to`s are read, so no diff baseline). - const checkpoint = await computeCheckpoint(repo, members, head, "none"); + const checkpoint = await computeCheckpoint(repo, members, head, "none", { + to: scrub?.memberHeads, + }); for (const member of members) { const to = checkpoint[member.url]?.to; if (!to) continue; @@ -613,6 +682,8 @@ export function DraftsSidebar(props: { element: HTMLElement }) { checkpoint={() => (isMainSelected() ? (checkedOut()?.at ?? null) : null)} hasCheckpoint={isMainSelected() && isPinned()} onReturnToLatest={clearCheckpoint} + onOpenComment={openComment} + onOpenAgentChat={openAgentChat} eyeOpen={isMainSelected() && eyeOpen()} eyeDisabled={!isPinned()} onToggleEye={toggleEye} @@ -653,6 +724,8 @@ export function DraftsSidebar(props: { element: HTMLElement }) { } hasCheckpoint={selected() === summary.url && isPinned()} onReturnToLatest={clearCheckpoint} + onOpenComment={openComment} + onOpenAgentChat={openAgentChat} eyeOpen={selected() === summary.url && eyeOpen()} eyeDisabled={false} onToggleEye={toggleEye} @@ -677,39 +750,78 @@ export function DraftsSidebar(props: { element: HTMLElement }) { } // Merges every cloned doc back into the parent draft's copy of it — the -// parent's clone when it has one, the original otherwise (the main draft's -// identity clones make those the same thing, so a top-level draft merges -// into the originals) — recording per-clone merge heads for auditing, and -// marks the draft as merged (which hides it from the list). The merged -// draft's children are handed up to the merge target, so they never dangle -// under a hidden draft. +// parent's clone when it has one, the original for a top-level draft (the +// main draft's identity clones make those the same thing). A member a real +// (non-main) target never forked is ADOPTED instead: the target takes over +// the clone as its own copy, so the changes stay scoped to the target until +// it merges in turn, rather than leaking straight into the original. +// +// Alongside the merge, provenance is recorded for attribution: per clone the +// clone's own heads at merge time (`mergedFrom` — with `clonedAt` this +// brackets exactly what the draft contributed), and on the draft which +// timeline the merge landed in (`mergedInto`). Finally the draft is marked +// merged (which hides it from the list) and its children are handed up to +// the merge target, so they never dangle under a hidden draft. async function mergeDraft( repo: Repo, draftHandle: DocHandle ): Promise { const doc = draftHandle.doc(); const parentHandle = await findMergeTarget(repo, doc?.parent); - const parentClones = parentHandle?.doc()?.clones ?? {}; + const parentIsMain = parentHandle?.doc()?.isMain === true; const entries = Object.entries(doc?.clones ?? {}) as [ AutomergeUrl, CloneEntry, ][]; for (const [originalUrl, entry] of entries) { + // A member the target never forked: a real draft adopts the clone (no + // data moves); main gets the identity entry `syncMainDraftClones` would + // eventually add, so its timeline is guaranteed to include the member. + if (parentHandle && !parentHandle.doc()?.clones[originalUrl]) { + // Copy the heads array: it was read out of the draft's doc, and a live + // Automerge object must not be assigned into another document. + const adopted: CloneEntry = parentIsMain + ? { cloneUrl: originalUrl, clonedAt: encodeHeads([]) } + : { + cloneUrl: entry.cloneUrl, + clonedAt: [...entry.clonedAt] as UrlHeads, + }; + parentHandle.change((d) => { + if (!d.clones[originalUrl]) d.clones[originalUrl] = adopted; + }); + } + // Re-read the target's clones: the adoption above (or a concurrent + // creator winning its guard) may have just changed the mapping. + const parentClones = parentHandle?.doc()?.clones ?? {}; const targetUrl = parentClones[originalUrl]?.cloneUrl ?? originalUrl; - if (entry.cloneUrl === targetUrl) continue; - const [target, clone] = await Promise.all([ - repo.find(targetUrl), - repo.find(entry.cloneUrl), - ]); + const clone = await repo.find(entry.cloneUrl); + const mergedFrom = clone.heads(); + if (entry.cloneUrl === targetUrl) { + // The clone IS the target's copy (adopted above, or an identity + // entry); nothing to merge — just record the join point. + draftHandle.change((d) => { + const e = d.clones[originalUrl]; + if (e) { + e.mergedAt = mergedFrom; + e.mergedFrom = mergedFrom; + } + }); + continue; + } + const target = await repo.find(targetUrl); target.merge(clone); const mergedAt = target.heads(); draftHandle.change((d) => { const e = d.clones[originalUrl]; - if (e) e.mergedAt = mergedAt; + if (e) { + e.mergedAt = mergedAt; + e.mergedFrom = mergedFrom; + } }); } draftHandle.change((d) => { d.mergedAt = Date.now(); + if (parentHandle) d.mergedInto = parentHandle.url; }); // Re-parent the merged draft's children onto the merge target: they list @@ -1080,6 +1192,9 @@ function MainCard(props: { checkpoint: Accessor; hasCheckpoint: boolean; onReturnToLatest: () => void; + onOpenComment: (comment: TimelineComment) => void; + // Reveal an agent group's chat in the agent tab (see `openAgentChat`). + onOpenAgentChat: (agent: AgentTag) => void; eyeOpen: boolean; eyeDisabled: boolean; onToggleEye: () => void; @@ -1158,6 +1273,8 @@ function MainCard(props: { eyeOpen={() => props.eyeOpen} checkpoint={props.checkpoint} onReturnToLatest={props.onReturnToLatest} + onOpenComment={props.onOpenComment} + onOpenAgentChat={props.onOpenAgentChat} />
@@ -1185,6 +1302,9 @@ function DraftCard(props: { checkpoint: Accessor; hasCheckpoint: boolean; onReturnToLatest: () => void; + onOpenComment: (comment: TimelineComment) => void; + // Reveal an agent group's chat in the agent tab (see `openAgentChat`). + onOpenAgentChat: (agent: AgentTag) => void; eyeOpen: boolean; eyeDisabled: boolean; onToggleEye: () => void; @@ -1273,6 +1393,8 @@ function DraftCard(props: { eyeOpen={() => props.eyeOpen} checkpoint={props.checkpoint} onReturnToLatest={props.onReturnToLatest} + onOpenComment={props.onOpenComment} + onOpenAgentChat={props.onOpenAgentChat} /> @@ -1660,17 +1782,28 @@ type ChangeRef = { time: number; }; +// Per-member heads for a scrub boundary, resolved from the RENDERED row +// order rather than raw timestamps (see `boundaryHeads` in +// DraftChangesList). Merge groups pull a draft's changes out of the time +// sort, so a boundary between two rendered groups is not a time cutoff — +// these maps carry the exact frontier the checkpoint should pin each member +// to. Keyed by original member url, like the checkpoint itself. +type MemberBoundaryHeads = Record; + // Where the scrubber sits: the change whose heads the view displays, // anchored to its persisted group. `offset` is the change's position within the // group, 0 = the group's newest change (what the scrubber geometry snaps // to); `head` identifies the exact change for the checkpoint machinery. // `groupStartTime` is the group's span start, carried so the checkpoint's // diff baseline can anchor at the group's beginning while the eye is on. +// `memberHeads` is the row-order boundary map (`to`s), absent while the +// member docs are still resolving. type ScrubberState = { groupId: string; offset: number; head: ChangeRef; groupStartTime: number; + memberHeads?: MemberBoundaryHeads; }; // Where the diff baseline handle sits: an absolute point in history the diff @@ -1679,10 +1812,13 @@ type ScrubberState = { // track (same geometry as the head). `offset` of `BASELINE_GROUP_START` means // "the start of the group" — the changes list resolves it to the group's // oldest change once it knows the group's size. Ephemeral like `ScrubberState`. +// `memberHeads` is the row-order boundary map (`from`s); when the parent +// seeds the baseline without it (toggleEye), the changes list fills it in. type BaselineState = { groupId: string; offset: number; time: number; + memberHeads?: MemberBoundaryHeads; }; // Sentinel `BaselineState.offset` meaning "the group's start" (its oldest @@ -1701,6 +1837,66 @@ type ScanChange = { time: number; deps: string[]; seq: number; + // Who wrote the change: raw actor id, plus the agent tag when the change + // message carries one — feeds the contributor runs a merge group unfolds + // into. + actor: string; + agent?: AgentTag; +}; + +// A ScanChange before it's tied to its member doc: the per-member metadata +// list the scan and the attribution walk share. +type MemberScanRow = Omit; + +// One contributor's consecutive slice of a merge group's changes — what an +// unfolded merge row lists: a merged draft deliberately keeps all its +// changes in ONE timeline row, so this is where its per-contributor +// structure (the user's manual edits vs each chat's agent runs) becomes +// visible. Offsets index into the group's resolved rows (0 = newest), the +// same coordinates the scrubber speaks. +type ContributorRun = { + offset: number; // newest row of the run + endOffset: number; // oldest row of the run + actors: string[]; // deduped, newest contributor first + agent?: AgentTag; // set when the run is one chat's agent edits + additions: number; + deletions: number; + time: number; // newest change's time, Unix seconds + changeCount: number; +}; + +// The `@comments` shape the timeline reads off the member docs — structurally +// matches the comments tools' schema (see comments-view) without a build-time +// dependency on them. A comment whose `@patchwork` marker is set carries a +// document reference in `content` instead of text. +type DocWithCommentThreads = { + "@comments"?: { + threads?: { + id?: string; + comments?: { + id?: string; + content?: string; + contactUrl?: AutomergeUrl; + timestamp?: number; + "@patchwork"?: { type?: string }; + }[]; + }[]; + }; +}; + +// One comment rendered as its own timeline entry, slotted between the change +// groups at the moment it was made (the ChangeGrouper splits groups at +// comment timestamps, so a comment never falls mid-group once grouping has +// caught up). `timestamp` is wall-clock ms, unlike change times (seconds). +// `threadUrl` addresses the comment's thread subdocument (null when the +// thread has no id to build it from), used to reveal the thread in the +// comments panel on click. +type TimelineComment = { + key: string; + contactUrl: AutomergeUrl | null; + content: string; + timestamp: number; + threadUrl: AutomergeUrl | null; }; // Renders a draft's (or main's) timeline straight from its ChangeGroupDoc. @@ -1730,6 +1926,10 @@ function DraftChangesList(props: { // heads, read for the sticker's whole-range diff counts. checkpoint: Accessor; onReturnToLatest: () => void; + // Reveal a comment in the comments panel (see `openComment` in the parent). + onOpenComment: (comment: TimelineComment) => void; + // Reveal an agent group's chat in the agent tab (see `openAgentChat`). + onOpenAgentChat: (agent: AgentTag) => void; }) { const repo = "repo" in window ? window.repo : undefined; @@ -1765,7 +1965,15 @@ function DraftChangesList(props: { // Member doc handles (plus the creation-time cutoff), resolved once per // member set — only needed to *scrub*, never to render the rows. - type MemberSource = { member: DraftMemberDoc; handle: DocHandle }; + // `originalHandle` sits at the member's ORIGINAL url (same as `handle` on + // main, where the clone is an identity mapping): thread sub-urls are built + // on it so they match the comments panel's, which addresses threads by the + // presented url while the draft overlay re-points resolution to the clone. + type MemberSource = { + member: DraftMemberDoc; + handle: DocHandle; + originalHandle: DocHandle; + }; const [sources, setSources] = createSignal(null); const [createdAt, setCreatedAt] = createSignal( undefined @@ -1783,7 +1991,11 @@ function DraftChangesList(props: { const handle = await repo.find( member.cloneUrl ?? member.url ); - next.push({ member, handle }); + const originalHandle = + member.cloneUrl && member.cloneUrl !== member.url + ? await repo.find(member.url) + : handle; + next.push({ member, handle, originalHandle }); } catch (err) { console.warn( "[drafts] failed to resolve member for scrubbing:", @@ -1802,6 +2014,100 @@ function DraftChangesList(props: { }); }); + // Comments live in the same docs the changes come from (clones for drafts), + // read live off the member handles so new comments, replies, and edits show + // up as they sync; `commentsTick` invalidates the memo below on any member + // change. + const [commentsTick, setCommentsTick] = createSignal(0); + createEffect(() => { + const srcs = sources(); + if (!srcs) return; + const bump = () => setCommentsTick((t) => t + 1); + for (const { handle } of srcs) handle.on("change", bump); + onCleanup(() => { + for (const { handle } of srcs) handle.off("change", bump); + }); + }); + + // Every sent comment across the member docs, newest first. Draft-only + // comments (`draftContent`, not yet sent) are skipped; a document-reference + // comment (its `@patchwork` marker set) gets a generic label instead of the + // raw url. + const allComments = createMemo(() => { + commentsTick(); + const srcs = sources(); + if (!srcs) return []; + const out: TimelineComment[] = []; + for (const { member, handle, originalHandle } of srcs) { + const doc = handle.doc() as DocWithCommentThreads | undefined; + const threads = doc?.["@comments"]?.threads; + if (!threads) continue; + threads.forEach((thread, threadIndex) => { + const threadUrl = thread.id + ? originalHandle.sub("@comments", "threads", { id: thread.id }).url + : null; + thread.comments?.forEach((comment, commentIndex) => { + if (typeof comment.timestamp !== "number") return; + if (!comment.content) return; + out.push({ + key: `${member.url}:${thread.id ?? threadIndex}:${comment.id ?? commentIndex}`, + contactUrl: comment.contactUrl ?? null, + content: comment["@patchwork"] + ? "Attached document" + : comment.content, + timestamp: comment.timestamp, + threadUrl, + }); + }); + }); + } + return out.sort((a, b) => b.timestamp - a.timestamp); + }); + + // Only comments within the rendered timeline: a draft's clones carry the + // original's comments from before the fork, and main may carry comments + // predating the host doc's creation cutoff — both would pile up below the + // oldest group as noise. (A comment made in the gap between a draft's fork + // and its first change is dropped too; acceptable.) + const timelineComments = createMemo(() => { + const groups = timeGroups(); + if (groups.length === 0) return []; + const oldestMs = groups[groups.length - 1].startTime * 1000; + const cutoff = createdAt(); + return allComments().filter( + (c) => + c.timestamp >= oldestMs && + (cutoff === undefined || c.timestamp >= cutoff * 1000) + ); + }); + + // Groups and comments merged newest-first for rendering. A comment sorts by + // its timestamp against each group's END time — groups split at comment + // times, so a comment lands between the group made after it and the group + // (holding its own write) made before it. On a timestamp tie the comment + // renders above the group (it was made at or after the group's last + // change). A comment inside a not-yet-split group's span renders just below + // that group and settles once the grouper catches up. + type TimelineEntry = + | { kind: "group"; group: ChangeGroup } + | { kind: "comment"; comment: TimelineComment }; + const timelineEntries = createMemo(() => { + const entries: TimelineEntry[] = [ + ...timeGroups().map((group) => ({ kind: "group" as const, group })), + ...timelineComments().map((comment) => ({ + kind: "comment" as const, + comment, + })), + ]; + const timeOf = (e: TimelineEntry) => + e.kind === "group" ? e.group.endTime * 1000 : e.comment.timestamp; + return entries.sort( + (a, b) => + timeOf(b) - timeOf(a) || + (a.kind === b.kind ? 0 : a.kind === "comment" ? -1 : 1) + ); + }); + // Grouping is caught up when every member's live heads match the group // doc's consumed marker (`computedThrough` — written only when a pass // completes, while groups flush incrementally during it). A running build @@ -1819,14 +2125,18 @@ function DraftChangesList(props: { }); }); - // Recover a group's member changes on demand: scan each member's post-fork - // change metadata filtered to the group's span (spans are disjoint — groups - // are separated by >gap lulls, so time containment recovers exactly the - // group's changes) and interleave with the ChangeGrouper's ordering. - // Metadata only, no diffs. Memoized per group identity so dragging - // stays snappy; returns null until the member handles resolve. It must apply - // the same filters as grouping (notably the pre-creation cutoff), or the - // scrubber's index math drifts from the persisted changeCount. + // Recover a group's member changes on demand and interleave with the + // ChangeGrouper's ordering. Metadata only, no diffs. Memoized per group + // identity so dragging stays snappy; returns null until the member handles + // resolve. It must apply the same filters as grouping (the pre-creation + // cutoff, and merged-draft attribution), or the scrubber's index math + // drifts from the persisted changeCount. + // + // Regular groups scan by time containment (TIME groups' spans are disjoint + // — they're separated by >gap lulls) minus the hashes attributed to any + // merged draft, whose changes interleave in time with everything else. + // Merge groups resolve directly through the attribution walk over the head + // ranges persisted on the group. const scanCache = new Map(); const resolveGroupChanges = (group: ChangeGroup): ScanChange[] | null => { const key = `${group.id}:${group.changeCount}`; @@ -1834,26 +2144,34 @@ function DraftChangesList(props: { if (hit) return hit; const srcs = sources(); if (!srcs) return null; - const cutoff = createdAt(); + const excluded = group.merge ? null : excludedHashes(); const rows: ScanChange[] = []; for (const { member, handle } of srcs) { const doc = handle.doc() as Automerge.Doc | undefined; if (!doc) continue; try { - const since = member.clonedAt ? decodeHeads(member.clonedAt) : []; - const metas = Automerge.getChangesMetaSince(doc, since); - metas.forEach((meta, seq) => { - if (cutoff !== undefined && meta.time && meta.time < cutoff) return; - if (meta.time < group.startTime || meta.time > group.endTime) return; - rows.push({ - docUrl: member.url, - doc, - hash: meta.hash, - time: meta.time, - deps: meta.deps, - seq, - }); - }); + const metas = memberScanRows(member, doc); + if (group.merge) { + const range = group.merge.members[member.url]; + if (!range) continue; + const set = attributedHashes( + metas, + decodeHeads(range.mergeHeads), + decodeHeads(range.baseHeads) + ); + for (const meta of metas) { + if (!set.has(meta.hash)) continue; + rows.push({ ...meta, docUrl: member.url, doc }); + } + } else { + const excludedSet = excluded?.get(member.url); + for (const meta of metas) { + if (meta.time < group.startTime || meta.time > group.endTime) + continue; + if (excludedSet?.has(meta.hash)) continue; + rows.push({ ...meta, docUrl: member.url, doc }); + } + } } catch (err) { console.warn( "[drafts] failed to scan changes for member:", @@ -1867,6 +2185,221 @@ function DraftChangesList(props: { return rows; }; + // A member's post-fork change metadata with the same filters the + // ChangeGrouper applies (notably the pre-creation cutoff); `seq` preserves + // each change's index in the raw metas so tie-breaks order identically. + const memberScanRows = ( + member: DraftMemberDoc, + doc: Automerge.Doc + ): MemberScanRow[] => { + const cutoff = createdAt(); + const since = member.clonedAt ? decodeHeads(member.clonedAt) : []; + const metas = Automerge.getChangesMetaSince(doc, since); + const out: MemberScanRow[] = []; + metas.forEach((meta, seq) => { + if (cutoff !== undefined && meta.time && meta.time < cutoff) return; + const row: MemberScanRow = { + hash: meta.hash, + time: meta.time, + deps: meta.deps, + seq, + actor: meta.actor, + }; + const agent = parseAgentTag(meta.message); + if (agent) row.agent = agent; + out.push(row); + }); + return out; + }; + + // Which merge groups are unfolded into their contributor runs. Keyed by + // group id — stable for merge groups (`tg-merge-${draftUrl}`), so the + // state survives grouping rebuilds. + const [expandedMerges, setExpandedMerges] = createSignal>( + new Set() + ); + const toggleMergeExpanded = (id: string) => + setExpandedMerges((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + // Split a merge group's resolved rows into contributor runs, the same way + // the main timeline splits groups: same key (one chat's agent edits / + // one contact / one raw actor) and the same inactivity-gap rule, via the + // shared splitIntoGroups. Diffs each change for the run's +/- counts, so + // it only runs for groups the user actually unfolds; cached per group + // identity and attribution generation. Null while member docs resolve. + const runCache = new Map(); + const runsForGroup = (group: ChangeGroup): ContributorRun[] | null => { + const attribution = actorContacts(); + const cacheKey = `${group.id}:${group.changeCount}:${ + Object.keys(attribution).length + }`; + const hit = runCache.get(cacheKey); + if (hit) return hit; + const rows = resolveGroupChanges(group); + if (!rows || rows.length === 0) return null; + const keyOf = (row: ScanChange): string => + row.agent + ? `agent:${row.agent.chatUrl}` + : (attribution[row.actor] ?? `actor:${row.actor}`); + const runs: ContributorRun[] = []; + let offset = 0; + for (const runRows of splitIntoGroups(rows, [], keyOf)) { + let additions = 0; + let deletions = 0; + const actors: string[] = []; + for (const row of runRows) { + const counts = computeEditCounts(row.doc, row.hash, row.deps); + additions += counts.additions; + deletions += counts.deletions; + if (!actors.includes(row.actor)) actors.push(row.actor); + } + runs.push({ + offset, + endOffset: offset + runRows.length - 1, + actors, + // Uniform within an agent-keyed run; the newest row's tag carries + // the freshest chatHeads. + agent: runRows[0].agent, + additions, + deletions, + time: runRows[0].time, + changeCount: runRows.length, + }); + offset += runRows.length; + } + runCache.set(cacheKey, runs); + return runs; + }; + + // Hashes attributed to ANY merged draft, per member: the union of the + // attribution walks over every merge group persisted in the group doc. + // Cached by the set of merge groups — attribution is a pure function of + // immutable history, so a cached union never goes stale — but not cached + // while a member doc is still loading, so a late doc can't pin an + // incomplete union. + let excludedCacheKey: string | null = null; + let excludedCacheValue: Map> | null = null; + const excludedHashes = (): Map> | null => { + const srcs = sources(); + if (!srcs) return null; + const mergeGroups = Object.values(changeGroupDoc()?.groups ?? {}).filter( + (g) => g.merge + ); + if (mergeGroups.length === 0) return null; + const cacheKey = mergeGroups + .map((g) => g.id) + .sort() + .join("|"); + if (excludedCacheValue && excludedCacheKey === cacheKey) { + return excludedCacheValue; + } + const byMember = new Map>(); + let complete = true; + for (const { member, handle } of srcs) { + const ranges = mergeGroups + .map((g) => g.merge!.members[member.url]) + .filter((r) => r !== undefined); + if (ranges.length === 0) continue; + const doc = handle.doc() as Automerge.Doc | undefined; + if (!doc) { + complete = false; + continue; + } + try { + const metas = memberScanRows(member, doc); + const set = new Set(); + for (const range of ranges) { + const hashes = attributedHashes( + metas, + decodeHeads(range.mergeHeads), + decodeHeads(range.baseHeads) + ); + for (const hash of hashes) set.add(hash); + } + if (set.size > 0) byMember.set(member.url, set); + } catch (err) { + complete = false; + console.warn( + "[drafts] failed to resolve merged-draft hashes for member:", + member, + err + ); + } + } + if (complete) { + excludedCacheKey = cacheKey; + excludedCacheValue = byMember; + } + return byMember; + }; + + // Per-member frontier heads for a scrub boundary at (`group`, `offset`), + // resolved from the SAME ordered rows the timeline renders — the rendered + // group order with merge groups pulled out of the time sort — rather than + // raw timestamps. The boundary's state is exactly the rows drawn below it: + // every row of every group sorted below `group`, plus `group`'s own rows + // from the anchor down (`includeAnchor` — the head displays its change, the + // baseline diffs it away). Each member's heads are the frontier of its + // included rows (rows no other included row depends on), so concurrent + // contributions — a merge group interleaved in time with regular edits — + // pin as a multi-head state instead of collapsing onto whichever change is + // newest by wall clock. Returns null while the member docs are still + // resolving; callers then fall back to the time-based approximation. + const boundaryHeads = ( + group: ChangeGroup, + offset: number, + includeAnchor: boolean + ): MemberBoundaryHeads | null => { + const groups = timeGroups(); + const idx = groups.findIndex((g) => g.id === group.id); + if (idx < 0) return null; + const anchorRows = resolveGroupChanges(group); + if (!anchorRows) return null; + const anchor = Math.min( + Math.max(0, resolveOffset(group, offset)), + Math.max(0, anchorRows.length - 1) + ); + const included: ScanChange[] = anchorRows.slice( + includeAnchor ? anchor : anchor + 1 + ); + for (let i = idx + 1; i < groups.length; i++) { + const rows = resolveGroupChanges(groups[i]); + if (!rows) return null; + included.push(...rows); + } + const byMember = new Map(); + for (const row of included) { + let list = byMember.get(row.docUrl); + if (!list) byMember.set(row.docUrl, (list = [])); + list.push(row); + } + const heads: MemberBoundaryHeads = {}; + for (const [url, rows] of byMember) { + const frontier = frontierHashes(rows); + if (frontier.length > 0) heads[url] = encodeHeads(frontier); + } + return heads; + }; + + // The parent seeds the baseline (toggleEye) without the row-order boundary + // map — it lacks the scan context. Fill the map in as soon as the rows + // resolve and re-emit, so the checkpoint's `from`s match the rendered + // order instead of the time-based fallback. + createEffect(() => { + const b = props.baseliner(); + if (!b || b.memberHeads) return; + const group = timeGroups().find((g) => g.id === b.groupId); + if (!group) return; + const memberHeads = boundaryHeads(group, b.offset, false); + if (!memberHeads) return; + props.onBaselineScrub({ ...b, memberHeads }); + }); + // Build the head scrub state for `offset` within `group` (0 = the group's // newest change, which the group doc anchors directly; deeper offsets resolve // through the on-demand scan). Null while the scan is still resolving. @@ -1884,6 +2417,7 @@ function DraftChangesList(props: { time: group.endTime, }, groupStartTime: group.startTime, + memberHeads: boundaryHeads(group, 0, true) ?? undefined, }; } const rows = resolveGroupChanges(group); @@ -1894,6 +2428,7 @@ function DraftChangesList(props: { offset, head: { docUrl: row.docUrl, hash: row.hash, time: row.time }, groupStartTime: group.startTime, + memberHeads: boundaryHeads(group, offset, true) ?? undefined, }; }; @@ -1946,6 +2481,7 @@ function DraftChangesList(props: { groupId: group.id, offset: head.offset, time: head.head.time, + memberHeads: boundaryHeads(group, head.offset, false) ?? undefined, }); } } @@ -1953,20 +2489,71 @@ function DraftChangesList(props: { }; // Select a group (click on its row): the head pins to the group's newest - // change, and — with the eye open — the baseline re-anchors to the group's - // start, so the diff reads "everything this group changed". Dragging the - // head, by contrast, leaves the baseline where it is (see `scrubTo`). + // change and the baseline anchors to the group's start, so the diff reads + // "everything this group changed" — the eye opens by itself (its state is + // derived from the checkpoint's `from`s). Dragging the head, by contrast, + // leaves the baseline where it is (see `scrubTo`). const selectGroup = (group: ChangeGroup) => { - if (props.eyeOpen()) { - props.onBaselineScrub({ - groupId: group.id, - offset: BASELINE_GROUP_START, - time: group.startTime, - }); - } + props.onBaselineScrub({ + groupId: group.id, + offset: BASELINE_GROUP_START, + time: group.startTime, + memberHeads: + boundaryHeads(group, BASELINE_GROUP_START, false) ?? undefined, + }); scrubTo(group, 0); }; + // Select a comment (click on its row): pin the view to the doc as of the + // moment the comment was made — the newest change at or before its + // timestamp. Groups split at comment times, so that is normally the top of + // the group right below the comment; a comment still inside a not-yet-split + // group resolves to the right change through the scan. No baseline seeding: + // a comment click means "show me what it looked like", unlike a group click + // which shows what the group changed. + const selectComment = (comment: TimelineComment) => { + const tsSeconds = comment.timestamp / 1000; + // Groups are newest-first: the first whose span starts at or before the + // comment is the newest group not entirely newer than it. + const group = timeGroups().find((g) => g.startTime <= tsSeconds); + if (!group) return; + if (group.endTime <= tsSeconds) { + scrubTo(group, 0); + return; + } + const rows = resolveGroupChanges(group); + const offset = rows?.findIndex((r) => r.time <= tsSeconds) ?? -1; + scrubTo(group, Math.max(0, offset)); + }; + + // Select a contributor run inside an unfolded merge group: head at the + // run's newest change, baseline anchored at its oldest (diffed away, like + // selectGroup's BASELINE_GROUP_START), so the diff reads "exactly what + // this contributor changed here". + const selectRun = (group: ChangeGroup, run: ContributorRun) => { + props.onBaselineScrub({ + groupId: group.id, + offset: run.endOffset, + time: timeAt(group, run.endOffset), + memberHeads: boundaryHeads(group, run.endOffset, false) ?? undefined, + }); + scrubTo(group, run.offset); + }; + + // A run row is "selected" when the head sits on its newest change AND the + // baseline on its oldest — exactly the selectRun shape. The head check + // alone isn't enough: a whole-group selection also parks the head at + // offset 0, which is the first run's newest change, and the first run + // shouldn't light up for it (its baseline differs while the group has + // more than one run). + const runSelected = (group: ChangeGroup, run: ContributorRun): boolean => { + const s = props.scrubber(); + if (!s || s.groupId !== group.id || s.offset !== run.offset) return false; + const b = props.baseliner(); + if (!b || b.groupId !== group.id) return false; + return resolveOffset(group, b.offset) === run.endOffset; + }; + // Move the baseline to `offset` within `group`, clamped so it never crosses // above (newer than) the head — the diff always reads old -> new. When the // clamp bites, the baseline snaps to the head (an empty diff). @@ -1974,10 +2561,14 @@ function DraftChangesList(props: { const head = props.scrubber(); if (!head) return; if (linearIndex(group.id, offset) < linearIndex(head.groupId, head.offset)) { + const headGroup = timeGroups().find((g) => g.id === head.groupId); props.onBaselineScrub({ groupId: head.groupId, offset: head.offset, time: head.head.time, + memberHeads: headGroup + ? (boundaryHeads(headGroup, head.offset, false) ?? undefined) + : undefined, }); return; } @@ -1985,6 +2576,7 @@ function DraftChangesList(props: { groupId: group.id, offset, time: timeAt(group, offset), + memberHeads: boundaryHeads(group, offset, false) ?? undefined, }); }; @@ -2004,6 +2596,9 @@ function DraftChangesList(props: { // every individual change — including ones in the middle of a group — is a // valid stop for the token, not just group boundaries. const rowEls = new Map(); + // Run rows of expanded merge groups, keyed `${groupId}:${runOffset}` — the + // indicator mapping snaps to their edges (see runBands). + const runRowEls = new Map(); const [rowsEl, setRowsEl] = createSignal(); // Bumped after layout changes so `bands` re-measures the rendered rows. const [measureTick, setMeasureTick] = createSignal(0); @@ -2016,10 +2611,11 @@ function DraftChangesList(props: { onCleanup(() => observer.disconnect()); }); - // Rows render after the groups memo recomputes, so measure again on the - // next frame once the DOM has settled. + // Rows render after the entries memo recomputes (groups AND interleaved + // comment rows shift the group rows' offsets), so measure again on the next + // frame once the DOM has settled. createEffect(() => { - timeGroups(); + timelineEntries(); requestAnimationFrame(() => setMeasureTick((t) => t + 1)); }); @@ -2040,16 +2636,72 @@ function DraftChangesList(props: { return out; }); + // The rendered run rows of an expanded merge group, measured like `bands`. + // The indicator's mapping snaps to these instead of interpolating linearly + // across the block, so a head parked on a run sits exactly on its row's + // top edge (and a baseline on its bottom edge) rather than striking + // through rows — the block's header row skews any linear share. Null when + // the group is folded or the rows aren't all mounted yet; callers fall + // back to the linear band mapping. + type RunBand = { run: ContributorRun; top: number; height: number }; + const runBands = (group: ChangeGroup): RunBand[] | null => { + if (!group.merge || !expandedMerges().has(group.id)) return null; + const runs = runsForGroup(group); + if (!runs || runs.length === 0) return null; + const out: RunBand[] = []; + for (const run of runs) { + const el = runRowEls.get(`${group.id}:${run.offset}`); + if (!el || !el.isConnected) return null; + out.push({ run, top: el.offsetTop, height: el.offsetHeight }); + } + return out; + }; + // A scrub position's y in the track: offsets interpolate across their // group's band, sized by the persisted changeCount (the flat change list is - // never materialized). + // never materialized). Each change owns the band slice + // [offset/count, (offset+1)/count): the head marks a change and sits at + // its slice's top; the baseline marks the boundary BELOW a change (that + // change is the oldest one in the diff) and sits at its slice's bottom. + // Expanded merge groups snap to their run rows instead — except offset 0, + // which stays at the band's top so a whole-group selection still brackets + // the header row. const yForPosition = (band: Band, offset: number): number => { + if (offset > 0) { + const rbs = runBands(band.group); + const rb = rbs?.find((r) => offset <= r.run.endOffset); + if (rb) { + const within = + Math.max(0, offset - rb.run.offset) / rb.run.changeCount; + return rb.top + within * rb.height; + } + } const count = Math.max(1, band.group.changeCount); return band.top + (Math.min(offset, count - 1) / count) * band.height; }; + // The baseline's y: the bottom of its change's slice, so a baseline at a + // group's start sits on the band's bottom edge — the whole group reads as + // selected — instead of striking through the row of a small group. In an + // expanded merge group, a baseline at a run's oldest change sits on that + // run row's bottom edge. + const yForBoundary = (band: Band, offset: number): number => { + const rbs = runBands(band.group); + const rb = rbs?.find((r) => offset <= r.run.endOffset); + if (rb) { + const within = + (Math.max(0, offset - rb.run.offset) + 1) / rb.run.changeCount; + return rb.top + within * rb.height; + } + const count = Math.max(1, band.group.changeCount); + return ( + band.top + ((Math.min(offset, count - 1) + 1) / count) * band.height + ); + }; + // Inverse: the (group, offset) position nearest a pointer y (in track - // coordinates). + // coordinates). Mirrors yForPosition's run-row snapping inside expanded + // merge bands, so a drag lands where the line will draw. const positionForY = ( y: number ): { group: ChangeGroup; offset: number } | null => { @@ -2058,6 +2710,27 @@ function DraftChangesList(props: { for (const b of bs) { if (y < b.top) return { group: b.group, offset: 0 }; if (y < b.top + b.height) { + const rbs = runBands(b.group); + if (rbs) { + for (const rb of rbs) { + // Above this row (the header, or a gap between rows): snap to + // the row's newest change. + if (y < rb.top) return { group: b.group, offset: rb.run.offset }; + if (y < rb.top + rb.height) { + const offset = + rb.run.offset + + Math.min( + Math.round(((y - rb.top) / rb.height) * rb.run.changeCount), + rb.run.changeCount - 1 + ); + return { group: b.group, offset }; + } + } + return { + group: b.group, + offset: Math.max(0, b.group.changeCount - 1), + }; + } const count = Math.max(1, b.group.changeCount); const offset = Math.min( Math.round(((y - b.top) / b.height) * count), @@ -2073,6 +2746,69 @@ function DraftChangesList(props: { }; }; + // Inverse of `yForBoundary` for baseline drags: the boundary stop nearest + // a pointer y. Boundaries render below their change's slice, so the top + // sliver of a band maps to the boundary between it and the band above — + // the above band's last stop, which renders at the same pixel. + const boundaryPositionForY = ( + y: number + ): { group: ChangeGroup; offset: number } | null => { + const bs = bands(); + if (bs.length === 0) return null; + let above: { group: ChangeGroup; offset: number } | null = null; + for (const b of bs) { + const count = Math.max(1, b.group.changeCount); + if (y < b.top + b.height) { + const rbs = runBands(b.group); + if (rbs) { + // Between run rows the boundary snaps to the run above's oldest + // change; in the header zone it behaves like a band's top sliver + // (the boundary with the group above). + let prevEnd: number | null = null; + for (const rb of rbs) { + const boundaryAbove = () => + prevEnd === null + ? (above ?? { group: b.group, offset: 0 }) + : { group: b.group, offset: prevEnd }; + if (y < rb.top) return boundaryAbove(); + if (y < rb.top + rb.height) { + const offset = + Math.round(((y - rb.top) / rb.height) * rb.run.changeCount) - + 1; + if (offset < 0) return boundaryAbove(); + return { + group: b.group, + offset: + rb.run.offset + Math.min(offset, rb.run.changeCount - 1), + }; + } + prevEnd = rb.run.endOffset; + } + return { group: b.group, offset: Math.max(0, count - 1) }; + } + const offset = Math.round(((y - b.top) / b.height) * count) - 1; + if (offset < 0) return above ?? { group: b.group, offset: 0 }; + return { group: b.group, offset: Math.min(offset, count - 1) }; + } + above = { group: b.group, offset: count - 1 }; + } + const last = bs[bs.length - 1]; + return { + group: last.group, + offset: Math.max(0, last.group.changeCount - 1), + }; + }; + + // Where the idle line ("you're looking at the live latest") sits: above the + // FIRST rendered row, whatever it is — comment rows can sit above the newest + // group when comments are newer than the last change, and the live latest + // includes them, so the line must not sink below them to the first group + // band. Reads the DOM like `bands` does; callers re-run via `bands()`. + const idleTop = (bs: Band[]): number => { + const first = rowsEl()?.firstElementChild as HTMLElement | null; + return first ? first.offsetTop : bs[0].top; + }; + // The indicator's pixel position: the head line's y in the track. The // zero-height box is fine — the dot and line overflow it and stay // grabbable. With nothing pinned it idles at the very top — you're looking @@ -2081,10 +2817,10 @@ function DraftChangesList(props: { const bs = bands(); if (bs.length === 0) return null; const s = props.scrubber(); - if (!s) return { top: bs[0].top }; + if (!s) return { top: idleTop(bs) }; const group = groupForScrub(s); const band = group ? bs.find((b) => b.group.id === group.id) : undefined; - if (!band) return { top: bs[0].top }; + if (!band) return { top: idleTop(bs) }; return { top: yForPosition(band, s.offset) }; }); @@ -2100,7 +2836,7 @@ function DraftChangesList(props: { bs.find((x) => x.group.id === b.groupId) ?? bs.find((x) => b.time >= x.group.startTime && b.time <= x.group.endTime); if (!band) return null; - return { top: yForPosition(band, resolveOffset(band.group, b.offset)) }; + return { top: yForBoundary(band, resolveOffset(band.group, b.offset)) }; }); let trackEl: HTMLDivElement | undefined; @@ -2170,7 +2906,7 @@ function DraftChangesList(props: { let last: string | null = null; const onMove = (e: PointerEvent) => { - const pos = positionForY(yInTrack(e) - grabOffset); + const pos = boundaryPositionForY(yInTrack(e) - grabOffset); if (!pos) return; const key = `${pos.group.id}:${pos.offset}`; if (key === last) return; @@ -2193,12 +2929,18 @@ function DraftChangesList(props: { // The exact change the scrubber head sits on, recovered through the // on-demand scan; feeds the sticker that overlays the group row with the // version being looked at. It is suppressed when the head sits exactly on - // a group's newest change (the row already shows that version). + // a group's newest change (the row already shows that version), and + // likewise on a contributor run's newest change while its merge group is + // unfolded — the run row shows that version and highlights itself. const headChange = createMemo(() => { const s = props.scrubber(); if (!s || s.offset === 0) return null; const group = groupForScrub(s); if (!group || s.head.hash === group.newestHash) return null; + if (group.merge && expandedMerges().has(group.id)) { + const runs = runsForGroup(group); + if (runs?.some((r) => r.offset === s.offset)) return null; + } const rows = resolveGroupChanges(group); if (!rows || rows.length === 0) return null; return ( @@ -2269,13 +3011,94 @@ function DraftChangesList(props: { onPointerDown={(ev) => beginDrag(ev, true)} />
- - {(group) => ( - rowEls.set(group.id, el)} - onSelect={() => selectGroup(group)} - /> + + {(entry) => ( + + + {(group) => ( + rowEls.set(group().id, el)} + onSelect={() => selectGroup(group())} + onOpenAgent={props.onOpenAgentChat} + /> + } + > + {/* Merge rows wrap in a block that also holds the + unfolded contributor runs; the block registers as + the group's row element, so its scrubber band + stretches over the runs and per-change stops line + up with them. */} +
rowEls.set(group().id, el)} + > + selectGroup(group())} + onOpenAgent={props.onOpenAgentChat} + expanded={expandedMerges().has(group().id)} + onToggleExpand={() => + toggleMergeExpanded(group().id) + } + /> + + + Resolving changes… +
+ } + > + {(runs) => ( +
+ + {(run) => ( + + runRowEls.set( + `${group().id}:${run.offset}`, + el + ) + } + selected={runSelected(group(), run)} + onSelect={() => + selectRun(group(), run) + } + onOpenAgent={props.onOpenAgentChat} + /> + )} + +
+ )} +
+ +
+ + )} + + + {(comment) => ( + { + // Pin first: the panel switch unmounts this list, + // but the checkpoint write survives (async, guarded + // by scrubSeq, persisted on the checked-out doc). + selectComment(comment()); + props.onOpenComment(comment()); + }} + /> + )} + + )} {/* Rebuilds backfill oldest history last, so the gap sits below @@ -2363,13 +3186,20 @@ function DraftChangesList(props: { } // One time group, rendered as a single non-expandable row: author avatars, -// the group's newest timestamp, and the aggregated +/- counts. Clicking the -// row parks the scrubber at the top of the group (the scrubber token is the -// selection indicator — the row itself doesn't highlight). +// the group's newest timestamp, and the aggregated +/- counts. A merged +// draft's group additionally carries a badge naming the draft it came from. +// Clicking the row parks the scrubber at the top of the group (the scrubber +// token is the selection indicator — the row itself doesn't highlight). function TimeGroupRow(props: { group: ChangeGroup; - rowRef: (el: HTMLElement) => void; + rowRef?: (el: HTMLElement) => void; onSelect: () => void; + onOpenAgent: (agent: AgentTag) => void; + // Present only on merge rows: unfold the group into contributor runs. + // The chevron's click doesn't bubble into the row's select — toggling the + // disclosure must never move the scrubber. + expanded?: boolean; + onToggleExpand?: () => void; }) { return ( + ); +} + +// One comment, slotted between the group rows at the moment it was made, +// reading " left a comment “…”" (the avatar plays the who — comments +// carry a contact url directly, no actor attribution needed). Clicking pins +// the view to the doc as of that moment and opens the comment in the +// comments panel. +function CommentRow(props: { + comment: TimelineComment; + onSelect: () => void; +}) { + return ( + + ); +} + // A stack of author avatars, newest-contributor first. Actors with a known // contact embed the contact tool's own avatar view (image or name initials — // see contact/src/components/InlineContactAvatar.ts) and are deduped by @@ -2457,24 +3413,63 @@ function EditCounts(props: { additions: number; deletions: number }) { // and only clamped so the baseline stays at or older than the head. type CheckpointBase = "none" | { beforeTime: number }; +// Boundary maps resolved by the changes list from the rendered row order +// (see `boundaryHeads`): `to` for the head, `from` for the baseline. Either +// may be absent — while the member docs still resolve, or when the sidebar +// remounted and only the persisted checkpoint survives — in which case +// `computeCheckpoint` falls back to its time-based approximation. +type ResolvedBoundaries = { + to?: MemberBoundaryHeads; + from?: MemberBoundaryHeads; +}; + // Build the checkpoint map for a scrub position. Each member's displayed -// version (`to`) is its heads as of `head`: the doc that owns that change is -// pinned exactly to it, every other member to its latest change at or before -// it (approximate but good enough). The diff baseline (`from`) follows -// `base`: omitted for `"none"`, or the member's heads just before -// `beforeTime` (falling back to the fork point — empty heads on main — when -// no post-fork change precedes it). Members with no change at or before -// `head` are omitted entirely: they didn't exist yet, so they fall through to -// live. +// version (`to`) and diff baseline (`from`) come from the resolved boundary +// maps when available: the exact frontier of the rows the timeline renders +// at/below the head and below the baseline — attribution-aware, since merge +// groups pull changes out of the time order. Without a map the boundary is +// approximated by time: `to` pins the head's own doc exactly to the head +// change and every other member to its latest change at or before it; +// `from` is the member's heads just before `base.beforeTime`. A member in no +// `to` map entry and with no change at or before `head` is omitted entirely: +// it didn't exist yet, so it falls through to live. A `from` that resolves +// to nothing falls back to the fork point (empty heads on main — the whole +// doc reads as added). async function computeCheckpoint( repo: Repo, members: DraftMemberDoc[], head: ChangeRef, - base: CheckpointBase + base: CheckpointBase, + resolved: ResolvedBoundaries = {} ): Promise { const checkpoint: DraftCheckpoint = {}; for (const member of members) { try { + // Boundaries resolved from the rendered rows, when the maps are there. + let to: UrlHeads | undefined; + let toResolved = false; + if (resolved.to) { + to = resolved.to[member.url]; + toResolved = true; + // Not in the map: no rows at or below the head — the member didn't + // exist yet at that version, so it falls through to live. + if (!to) continue; + } + let from: UrlHeads | undefined; + let fromResolved = base === "none"; + if (!fromResolved && resolved.from) { + from = resolved.from[member.url] ?? member.clonedAt ?? encodeHeads([]); + fromResolved = true; + } + if (toResolved && fromResolved) { + checkpoint[member.url] = + base === "none" + ? { to: [...to!] as UrlHeads } + : { from: [...from!] as UrlHeads, to: [...to!] as UrlHeads }; + continue; + } + + // Time-based fallback for whichever boundary lacks a resolved map. const handle = await repo.find(member.cloneUrl ?? member.url); const doc = handle.doc(); if (!doc) continue; @@ -2483,26 +3478,27 @@ async function computeCheckpoint( // Displayed version: exactly the head change for the doc that owns it, // otherwise the member's latest change at or before it. - let to: UrlHeads; - if (member.url === head.docUrl) { - // Pin the head's doc exactly even if it falls outside the metas - // window (robust against a mismatched fork point). - to = encodeHeads([head.hash]); - } else { - let pinnedIndex = -1; - let bestTime = -Infinity; - metas.forEach((m, i) => { - if (m.time <= head.time && m.time >= bestTime) { - bestTime = m.time; - pinnedIndex = i; - } - }); - if (pinnedIndex < 0) continue; - to = encodeHeads([metas[pinnedIndex].hash]); + if (!toResolved) { + if (member.url === head.docUrl) { + // Pin the head's doc exactly even if it falls outside the metas + // window (robust against a mismatched fork point). + to = encodeHeads([head.hash]); + } else { + let pinnedIndex = -1; + let bestTime = -Infinity; + metas.forEach((m, i) => { + if (m.time <= head.time && m.time >= bestTime) { + bestTime = m.time; + pinnedIndex = i; + } + }); + if (pinnedIndex < 0) continue; + to = encodeHeads([metas[pinnedIndex].hash]); + } } if (base === "none") { - checkpoint[member.url] = { to }; + checkpoint[member.url] = { to: [...to!] as UrlHeads }; continue; } @@ -2510,19 +3506,24 @@ async function computeCheckpoint( // baseline's time. None post-fork means the baseline sits at or before // the start of the member's history in this timeline, so diff against // the fork point (empty heads on main — the whole doc reads as added). - let fromIndex = -1; - let fromTime = -Infinity; - metas.forEach((m, i) => { - if (m.time < base.beforeTime && m.time >= fromTime) { - fromTime = m.time; - fromIndex = i; - } - }); - const from = - fromIndex >= 0 - ? encodeHeads([metas[fromIndex].hash]) - : (member.clonedAt ?? encodeHeads([])); - checkpoint[member.url] = { from, to }; + if (!fromResolved) { + let fromIndex = -1; + let fromTime = -Infinity; + metas.forEach((m, i) => { + if (m.time < base.beforeTime && m.time >= fromTime) { + fromTime = m.time; + fromIndex = i; + } + }); + from = + fromIndex >= 0 + ? encodeHeads([metas[fromIndex].hash]) + : (member.clonedAt ?? encodeHeads([])); + } + checkpoint[member.url] = { + from: [...from!] as UrlHeads, + to: [...to!] as UrlHeads, + }; } catch (err) { console.warn( "[drafts] failed to compute checkpoint for member:", diff --git a/drafts/src/actor-attribution.ts b/drafts/src/actor-attribution.ts index 079098cb..f8a2d5f6 100644 --- a/drafts/src/actor-attribution.ts +++ b/drafts/src/actor-attribution.ts @@ -50,6 +50,9 @@ export function createActorRecorder(element: HTMLElement): ActorRecorder { attributionHandle = handle; flushPendingActors(); }, + contactFor(actorId) { + return attributionHandle?.doc()?.actors?.[actorId] ?? null; + }, dispose() { disposed = true; pendingActorIds.clear(); @@ -105,6 +108,10 @@ export async function ensureActorAttribution( export type ActorRecorder = { recordLocalChange: (doc: Automerge.Doc) => void; setAttributionHandle: (handle: DocHandle) => void; + // The contact an actor id is attributed to — ANY writer's, not just this + // client's (the attribution doc syncs). Null while unknown (attribution + // pending, or the handle not resolved yet). + contactFor: (actorId: string) => AutomergeUrl | null; dispose: () => void; }; diff --git a/drafts/src/change-group-cache.test.ts b/drafts/src/change-group-cache.test.ts new file mode 100644 index 00000000..543c36ba --- /dev/null +++ b/drafts/src/change-group-cache.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import type { Doc } from "@automerge/automerge/slim"; + +import { + collectCommentTimes, + INACTIVITY_GAP_MS, + parseAgentTag, + splitIntoGroups, +} from "./change-group-cache"; + +// Newest-first rows in Unix seconds; splitIntoGroups only reads `time`. +const rows = (...timesNewestFirst: number[]) => + timesNewestFirst.map((time) => ({ time })); + +const times = (groups: { time: number }[][]) => + groups.map((group) => group.map((row) => row.time)); + +const GAP_S = INACTIVITY_GAP_MS / 1000; + +describe("splitIntoGroups", () => { + it("keeps a continuous burst together and splits at an inactivity lull", () => { + const input = rows(3000 + GAP_S + 1, 3000, 2990, 2980); + expect(times(splitIntoGroups(input))).toEqual([ + [3000 + GAP_S + 1], + [3000, 2990, 2980], + ]); + }); + + it("splits a burst where a comment was made", () => { + const input = rows(1200, 1100, 1000); + expect(times(splitIntoGroups(input, [1_050_000]))).toEqual([ + [1200, 1100], + [1000], + ]); + }); + + it("does not split on a comment newer than every change", () => { + const input = rows(1100, 1000); + expect(times(splitIntoGroups(input, [2_000_000]))).toEqual([ + [1100, 1000], + ]); + }); + + it("does not split on a comment older than every change", () => { + const input = rows(1100, 1000); + expect(times(splitIntoGroups(input, [500_000]))).toEqual([[1100, 1000]]); + }); + + it("groups a change made in the comment's own second with the older side", () => { + // The comment's own write is stamped in the same second as the comment, + // so the boundary is half-open: rows at or before the comment go below. + const input = rows(1100, 1000, 990); + expect(times(splitIntoGroups(input, [1_000_500]))).toEqual([ + [1100], + [1000, 990], + ]); + }); + + it("does not split between changes in the same second", () => { + const input = rows(1000, 1000, 1000); + expect(times(splitIntoGroups(input, [1_000_500]))).toEqual([ + [1000, 1000, 1000], + ]); + }); + + it("splits a boundary once however many comments fall in it", () => { + const input = rows(1200, 1000); + expect( + times(splitIntoGroups(input, [1_150_000, 1_100_000, 1_050_000])) + ).toEqual([[1200], [1000]]); + }); + + it("splits several boundaries for several comments", () => { + const input = rows(1400, 1300, 1200, 1100); + expect(times(splitIntoGroups(input, [1_350_000, 1_150_000]))).toEqual([ + [1400], + [1300, 1200], + [1100], + ]); + }); +}); + +describe("splitIntoGroups with a contributor key", () => { + // Newest-first rows carrying the key splitIntoGroups groups by. + const keyed = (...rowsNewestFirst: [number, string][]) => + rowsNewestFirst.map(([time, key]) => ({ time, key })); + const keyOf = (row: { key: string }) => row.key; + + it("splits when the contributor changes, even within the same second", () => { + const input = keyed([1000, "paul"], [1000, "agent:chat1"], [990, "paul"]); + expect(times(splitIntoGroups(input, [], keyOf))).toEqual([ + [1000], + [1000], + [990], + ]); + }); + + it("keeps one contributor's burst together across the gap threshold rules", () => { + const input = keyed([1000, "paul"], [990, "paul"], [980, "paul"]); + expect(times(splitIntoGroups(input, [], keyOf))).toEqual([ + [1000, 990, 980], + ]); + }); + + it("splits agent runs from different chats", () => { + const input = keyed( + [1000, "agent:chat2"], + [990, "agent:chat2"], + [980, "agent:chat1"] + ); + expect(times(splitIntoGroups(input, [], keyOf))).toEqual([ + [1000, 990], + [980], + ]); + }); + + it("still splits at an inactivity lull within one contributor", () => { + const input = keyed( + [2000 + GAP_S + 1, "paul"], + [2000, "paul"], + [1990, "paul"] + ); + expect(times(splitIntoGroups(input, [], keyOf))).toEqual([ + [2000 + GAP_S + 1], + [2000, 1990], + ]); + }); + + it("still splits at a comment within one contributor", () => { + const input = keyed([1200, "paul"], [1100, "paul"], [1000, "paul"]); + expect(times(splitIntoGroups(input, [1_050_000], keyOf))).toEqual([ + [1200, 1100], + [1000], + ]); + }); +}); + +describe("parseAgentTag", () => { + it("reads a well-formed tag, dropping unknown fields", () => { + const message = JSON.stringify({ + "@patchwork": { + agent: { + chatUrl: "automerge:chat", + chatHeads: ["h1", "h2"], + toolCallId: "call_1", + futureField: true, + }, + }, + }); + expect(parseAgentTag(message)).toEqual({ + chatUrl: "automerge:chat", + chatHeads: ["h1", "h2"], + toolCallId: "call_1", + }); + }); + + it("keeps only chatUrl when the optional fields are absent or malformed", () => { + const message = JSON.stringify({ + "@patchwork": { + agent: { chatUrl: "automerge:chat", chatHeads: [42], toolCallId: 7 }, + }, + }); + expect(parseAgentTag(message)).toEqual({ chatUrl: "automerge:chat" }); + }); + + it("returns undefined for absent, plain-text, and foreign messages", () => { + expect(parseAgentTag(undefined)).toBeUndefined(); + expect(parseAgentTag(null)).toBeUndefined(); + expect(parseAgentTag("")).toBeUndefined(); + expect(parseAgentTag("fixed a typo")).toBeUndefined(); + expect(parseAgentTag("{not json")).toBeUndefined(); + expect(parseAgentTag('{"other":"shape"}')).toBeUndefined(); + expect(parseAgentTag('{"@patchwork":{}}')).toBeUndefined(); + expect(parseAgentTag('{"@patchwork":{"agent":{}}}')).toBeUndefined(); + }); +}); + +describe("collectCommentTimes", () => { + it("flattens every comment timestamp across docs, newest first", () => { + const docA = { + "@comments": { + threads: [ + { comments: [{ timestamp: 1000 }, { timestamp: 3000 }] }, + { comments: [{ timestamp: 2000 }] }, + ], + }, + }; + const docB = { title: "no comments" }; + const docC = { + "@comments": { threads: [{ comments: [{ timestamp: 4000 }, {}] }] }, + }; + expect( + collectCommentTimes([docA, docB, docC] as unknown as Doc[]) + ).toEqual([4000, 3000, 2000, 1000]); + }); +}); diff --git a/drafts/src/change-group-cache.ts b/drafts/src/change-group-cache.ts index d71187db..35ff2ae6 100644 --- a/drafts/src/change-group-cache.ts +++ b/drafts/src/change-group-cache.ts @@ -11,14 +11,16 @@ import { import * as Automerge from "@automerge/automerge/slim"; import type { + AgentTag, ChangeGroup, ChangeGroupDoc, DraftDoc, DraftMemberDoc, } from "./draft-types.js"; +import { partitionRows, type MergedDraftSpec } from "./merge-attribution.js"; // Bump to discard every existing group doc's contents (they self-rebuild). -export const CHANGE_GROUP_DOC_VERSION = 1; +export const CHANGE_GROUP_DOC_VERSION = 3; // A pause between consecutive changes longer than this starts a new group: // bursts of continuous editing read as a single row, however long they run, @@ -44,6 +46,10 @@ export type TimelineGroupingSpec = { draftHandle: DocHandle; members: DraftMemberDoc[]; rootDocUrl: AutomergeUrl; + // Drafts merged into this timeline (`DraftDoc.mergedInto` points here), + // with the head ranges their contributions span. Each one's changes are + // pulled out of the inactivity-gap grouping into one dedicated group. + mergedDrafts: MergedDraftSpec[]; }; export type ChangeGrouper = { @@ -59,6 +65,11 @@ export type ChangeGrouperOptions = { // current user just wrote with that doc instance's actor id. Feeds the // ActorRecorder (see actor-attribution.ts). onLocalChange?: (doc: Automerge.Doc) => void; + // The contact a raw actor id is attributed to (the shared + // ActorAttributionDoc), or null while unknown. Contributor keys resolve + // through this so one person's many actor ids (per doc, per session) read + // as one contributor instead of splitting groups at every actor change. + resolveContact?: (actorId: string) => AutomergeUrl | null; }; // Resolve a draft's change-group doc, creating it and stamping @@ -179,7 +190,10 @@ function countPatches(patches: Automerge.Patch[]): { let additions = 0; let deletions = 0; for (const patch of patches) { - if (patch.path[0] === "@patchwork") continue; + // Comment writes are surfaced as their own timeline entries (and split + // groups), so they don't count as edits either. + if (patch.path[0] === "@patchwork" || patch.path[0] === "@comments") + continue; if (patch.action === "splice") { additions += (patch.value as string).length; } else if (patch.action === "insert") { @@ -228,8 +242,41 @@ type PendingChange = { time: number; actor: string; seq: number; + agent?: AgentTag; }; +// Read an agent tag out of an Automerge change message. The chat tool writes +// `{"@patchwork":{"agent":{chatUrl,...}}}` as JSON (see chat's +// agent-change.ts); anything else — no message, free text, foreign JSON — +// is simply not an agent edit. Never throws. +export function parseAgentTag( + message: string | null | undefined +): AgentTag | undefined { + if (!message || message[0] !== "{") return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(message); + } catch { + return undefined; + } + const agent = ( + parsed as { "@patchwork"?: { agent?: Record } } + )?.["@patchwork"]?.agent; + if (!agent || typeof agent.chatUrl !== "string") return undefined; + // Rebuild the tag field-by-field: only known, well-typed fields survive + // (the object gets persisted into the group doc, where undefined values + // and foreign shapes are unwelcome). + const tag: AgentTag = { chatUrl: agent.chatUrl as AutomergeUrl }; + if ( + Array.isArray(agent.chatHeads) && + agent.chatHeads.every((h) => typeof h === "string") + ) { + tag.chatHeads = agent.chatHeads as string[]; + } + if (typeof agent.toolCallId === "string") tag.toolCallId = agent.toolCallId; + return tag; +} + // Newest first by timestamp, per-doc causal order breaking same-second ties. // The sidebar's on-demand scrub resolution MUST order identically, or the // scrubber's index math drifts from `changeCount`. @@ -237,28 +284,86 @@ function newestFirst(a: PendingChange, b: PendingChange): number { return b.time - a.time || b.seq - a.seq; } +// The `@comments` shape the grouper reads: just enough to reach every +// comment's wall-clock timestamp (ms). Structurally matches the comments +// tools' schema without a build-time dependency on them. +type DocWithCommentTimes = { + "@comments"?: { + threads?: { comments?: { timestamp?: number }[] }[]; + }; +}; + +// Every comment timestamp (ms) across the given member docs, newest first. +// These are extra group boundaries: a group never spans across the moment a +// comment was made, so the sidebar can slot the comment in between rows. +export function collectCommentTimes( + docs: Automerge.Doc[] +): number[] { + const times: number[] = []; + for (const doc of docs) { + const threads = (doc as DocWithCommentTimes)["@comments"]?.threads; + if (!threads) continue; + for (const thread of threads) { + for (const comment of thread.comments ?? []) { + if (typeof comment.timestamp === "number") { + times.push(comment.timestamp); + } + } + } + } + return times.sort((a, b) => b - a); +} + // Fold a flat, newest-first list of changes into groups: consecutive changes -// stay together while the pause between them is at most the inactivity gap. -function splitIntoGroups( - rowsNewestFirst: PendingChange[] -): PendingChange[][] { - const groups: PendingChange[][] = []; - let window: PendingChange[] = []; +// stay together while the pause between them is at most the inactivity gap +// AND no comment was made in between (`commentTimesMs`, newest first) AND +// they belong to the same contributor (`keyOf`, when given — so a row is one +// person's manual edits or one chat's agent edits, never a mix). A comment +// reads as its own timeline entry, so the changes before and after it must +// not aggregate into one row. The comment boundary is half-open — a comment +// at millisecond c splits rows older-or-equal from rows strictly newer — so +// the comment's own write (stamped in the same second as c) groups with the +// OLDER side, where it aggregates to 0/0 and stays hidden. Generic over the +// row shape (only `time`, Unix seconds, is read) so tests can drive it +// directly. +export function splitIntoGroups( + rowsNewestFirst: T[], + commentTimesMs: number[] = [], + keyOf?: (row: T) => string +): T[][] { + const groups: T[][] = []; + let window: T[] = []; let prevTimeMs: number | null = null; + let prevKey: string | undefined; + let ci = 0; for (const row of rowsNewestFirst) { const timeMs = row.time * 1000; + const key = keyOf?.(row); // Rows arrive newest-first, so the previous row is this change's newer // neighbour; a gap larger than the threshold between them is a lull. - if ( - prevTimeMs !== null && - prevTimeMs - timeMs > INACTIVITY_GAP_MS && - window.length > 0 - ) { - groups.push(window); - window = []; + if (prevTimeMs !== null && window.length > 0) { + // Comments at or after the newer neighbour can't split this boundary, + // nor any older one below — skip them once (both lists descend). + while ( + ci < commentTimesMs.length && + commentTimesMs[ci] >= prevTimeMs + ) { + ci++; + } + const commentBetween = + ci < commentTimesMs.length && commentTimesMs[ci] >= timeMs; + if ( + prevTimeMs - timeMs > INACTIVITY_GAP_MS || + commentBetween || + key !== prevKey + ) { + groups.push(window); + window = []; + } } window.push(row); prevTimeMs = timeMs; + prevKey = key; } if (window.length > 0) groups.push(window); return groups; @@ -268,6 +373,12 @@ function groupId(rowsNewestFirst: PendingChange[]): string { return `tg-${rowsNewestFirst[0].hash}`; } +// Stable id for a merged draft's dedicated group — keyed by the draft, not +// its newest hash, so rebuilds can cheaply match it against the stored one. +function mergeGroupId(draftUrl: AutomergeUrl): string { + return `tg-merge-${draftUrl}`; +} + // Append `member`'s changes since `since` onto `out`, dropping anything from // before the root document was created (a member dragged in after the fact // would otherwise contribute pre-existing history that reads as noise). `seq` @@ -293,7 +404,7 @@ function collectMemberRows( } metas.forEach((meta, seq) => { if (createdAt !== undefined && meta.time && meta.time < createdAt) return; - out.push({ + const row: PendingChange = { memberUrl: member.url, doc, hash: meta.hash, @@ -301,7 +412,24 @@ function collectMemberRows( time: meta.time, actor: meta.actor, seq, - }); + }; + const agent = parseAgentTag(meta.message); + if (agent) row.agent = agent; + if (meta.message) { + // Debug: every change that carries a message, and whether it parsed as + // an agent tag — the first place to look when attribution seems dead. + console.log( + "[drafts] change", + meta.hash.slice(0, 8), + "on", + member.url, + "message:", + meta.message, + "→ agent:", + agent ? agent.chatUrl : "NO (not a valid agent tag)" + ); + } + out.push(row); }); } @@ -313,6 +441,19 @@ function dedupedActors(rowsNewestFirst: PendingChange[]): string[] { return actors; } +// The group's agent tag: the newest row's, but only when EVERY row is an +// agent edit from the same chat. Contributor-keyed groups satisfy that by +// construction; merged-draft groups (never split) earn the tag only when the +// whole contribution came from one chat. +function agentForRows(rowsNewestFirst: PendingChange[]): AgentTag | undefined { + const newest = rowsNewestFirst[0].agent; + if (!newest) return undefined; + for (const row of rowsNewestFirst) { + if (!row.agent || row.agent.chatUrl !== newest.chatUrl) return undefined; + } + return newest; +} + // Yield to the main thread between diff slices. function idle(): Promise { return new Promise((resolve) => { @@ -348,7 +489,8 @@ function createSlicer(isAborted: () => boolean, onYield: () => void): Slicer { // the run was aborted mid-diff. async function buildGroup( rowsNewestFirst: PendingChange[], - slicer: Slicer + slicer: Slicer, + keyOf: (row: PendingChange) => string ): Promise { let additions = 0; let deletions = 0; @@ -360,17 +502,30 @@ async function buildGroup( } const newest = rowsNewestFirst[0]; const oldest = rowsNewestFirst[rowsNewestFirst.length - 1]; - return { + const group: ChangeGroup = { id: groupId(rowsNewestFirst), startTime: oldest.time, endTime: newest.time, newestMemberUrl: newest.memberUrl, newestHash: newest.hash, actors: dedupedActors(rowsNewestFirst), + contributorKey: keyOf(newest), additions, deletions, changeCount: rowsNewestFirst.length, }; + const agent = agentForRows(rowsNewestFirst); + if (agent) group.agent = agent; + console.log( + "[drafts] built group", + group.id, + "changes:", + group.changeCount, + "key:", + group.contributorKey, + agent ? `agent: ${agent.chatUrl}` : "" + ); + return group; } function byMemberUrl(a: DraftMemberDoc, b: DraftMemberDoc): number { @@ -416,6 +571,24 @@ export function createChangeGrouper( let running = false; let disposed = false; + console.log( + "[drafts] change grouper created (v" + + CHANGE_GROUP_DOC_VERSION + + "), resolveContact wired:", + !!options.resolveContact + ); + + // The contributor a row belongs to, and so what groups split on: one + // chat's agent edits, else the actor's contact (many actor ids, one + // person), else the raw actor id until attribution catches up. An actor + // attributed only after its rows were grouped keeps its actor-keyed rows + // until the next full rebuild — accepted, same class of staleness as the + // sidebar's unattributed-avatar fallback. + const contributorKey = (row: PendingChange): string => + row.agent + ? `agent:${row.agent.chatUrl}` + : (options.resolveContact?.(row.actor) ?? `actor:${row.actor}`); + // Host-doc creation times, resolved once per root url. const creationTimes = new Map>(); const creationTime = (url: AutomergeUrl): Promise => { @@ -438,6 +611,8 @@ export function createChangeGrouper( let task = tasks.get(key); const membersChanged = !task || !sameMemberSets(task.spec.members, spec.members); + const mergesChanged = + !task || !sameMergedDrafts(task.spec.mergedDrafts, spec.mergedDrafts); if (!task) { task = { key, spec, listeners: new Map(), queued: false, debounce: null }; tasks.set(key, task); @@ -445,7 +620,7 @@ export function createChangeGrouper( task.spec = spec; } void ensureListeners(task); - if (membersChanged) schedule(key); + if (membersChanged || mergesChanged) schedule(key); } } @@ -475,6 +650,17 @@ export function createChangeGrouper( return b.every((m) => set.has(key(m))); } + // Merged drafts compare by url alone: a merge's head ranges are recorded + // once and never change, so a new url is the only meaningful difference. + function sameMergedDrafts( + a: MergedDraftSpec[], + b: MergedDraftSpec[] + ): boolean { + if (a.length !== b.length) return false; + const set = new Set(a.map((m) => m.url)); + return b.every((m) => set.has(m.url)); + } + // Keep exactly one change listener per member source doc; edits schedule a // debounced grouping update for the owning timeline. async function ensureListeners(task: Task): Promise { @@ -598,6 +784,15 @@ export function createChangeGrouper( if (!changeGroupDoc) return; const computedThrough = changeGroupDoc.computedThrough ?? {}; + // A merged draft the stored grouping hasn't attributed yet always forces + // a full rebuild: its changes must come OUT of whatever time-based groups + // they already sit in — they may even be fully consumed already, if a + // grouping run raced ahead of the provider's spec update. + const attributedMerges = changeGroupDoc.attributedMerges ?? {}; + const hasNewMerge = spec.mergedDrafts.some( + (md) => !attributedMerges[md.url] + ); + // Each member's frontier as of this gather; the consumed marker advances // to exactly these once the run completes, so the next run's // getChangesMetaSince yields precisely the unconsumed tail. @@ -614,7 +809,20 @@ export function createChangeGrouper( collectMemberRows(tails, member, doc, since, createdAt); } - if (tails.length === 0) { + if (tails.length > 0) { + // Debug: one line per grouping run — proves the grouper saw the new + // changes and how many carried an agent tag. + console.log( + "[drafts] grouping run for", + spec.draftHandle.url, + "— tail rows:", + tails.length, + "agent-tagged:", + tails.filter((r) => r.agent).length + ); + } + + if (tails.length === 0 && !hasNewMerge) { // Nothing new to group; just record any frontier movement (e.g. members // whose unconsumed changes were all filtered out, or brand-new members // with no post-fork changes yet). @@ -633,18 +841,28 @@ export function createChangeGrouper( tails.sort(newestFirst); - // Fast path: every new change lands on or after the newest stored group - // (extending it or opening newer ones) without bridging into the group - // below it — the overwhelmingly common live-editing case. Everything else - // (first build, members without a consumed marker, late-syncing changes - // with old timestamps) rebuilds via the full pass. + // Comment timestamps across the member docs: extra group boundaries for + // both grouping paths below. Comments live in the same docs the changes + // come from (clones for drafts), so a new comment also fires the change + // listener that scheduled this run. + const commentTimesMs = collectCommentTimes(sources.map((s) => s.doc)); + + // Fast path: no merge to attribute, and every new change lands on or + // after the newest stored group (extending it or opening newer ones) + // without bridging into the group below it — the overwhelmingly common + // live-editing case. Everything else (first build, members without a + // consumed marker, late-syncing changes with old timestamps, a freshly + // merged draft) rebuilds via the full pass. const stored = Object.values(changeGroupDoc.groups ?? {}).sort( (a, b) => b.endTime - a.endTime ); const newestStored = stored[0]; const secondStored = stored[1]; - const tailOldestMs = tails[tails.length - 1].time * 1000; + const tailOldestMs = + tails.length > 0 ? tails[tails.length - 1].time * 1000 : 0; const fastOk = + !hasNewMerge && + tails.length > 0 && !!newestStored && tailOldestMs >= newestStored.startTime * 1000 - INACTIVITY_GAP_MS && (!secondStored || @@ -655,6 +873,7 @@ export function createChangeGrouper( changeGroupHandle, newestStored, tails, + commentTimesMs, frontier, isAborted ); @@ -663,7 +882,9 @@ export function createChangeGrouper( changeGroupHandle, sources, createdAt, + commentTimesMs, frontier, + spec.mergedDrafts, isAborted ); } @@ -676,23 +897,40 @@ export function createChangeGrouper( changeGroupHandle: DocHandle, newestStored: ChangeGroup, tailsNewestFirst: PendingChange[], + commentTimesMs: number[], frontier: Record, isAborted: () => boolean ): Promise { - const tailGroups = splitIntoGroups(tailsNewestFirst); + const tailGroups = splitIntoGroups( + tailsNewestFirst, + commentTimesMs, + contributorKey + ); const oldestGroup = tailGroups[tailGroups.length - 1]; const oldestGroupOldestMs = oldestGroup[oldestGroup.length - 1].time * 1000; + // A comment made between the stored group's end and the tail's start is + // a boundary too (same half-open convention as splitIntoGroups), so the + // tail must open a fresh group above it. + const commentBetween = commentTimesMs.some( + (c) => c >= newestStored.endTime * 1000 && c < oldestGroupOldestMs + ); // The oldest run of new changes merges into the stored group when no lull - // separates them (it may even start inside the stored span). + // (and no comment) separates them (it may even start inside the stored + // span) AND it belongs to the same contributor — unless the stored group + // is a merged draft's: that group holds exactly the draft's contribution, + // so edits after the merge always open a fresh group. const attaches = + !newestStored.merge && + !commentBetween && + contributorKey(oldestGroup[0]) === newestStored.contributorKey && oldestGroupOldestMs <= newestStored.endTime * 1000 + INACTIVITY_GAP_MS; const freshGroups = attaches ? tailGroups.slice(0, -1) : tailGroups; const slicer = createSlicer(isAborted, () => {}); const built: ChangeGroup[] = []; for (const rows of freshGroups) { - const group = await buildGroup(rows, slicer); + const group = await buildGroup(rows, slicer, contributorKey); if (group === null) return; built.push(group); } @@ -733,22 +971,26 @@ export function createChangeGrouper( ): void { const tailNewest = tailNewestFirst[0]; const tailOldest = tailNewestFirst[tailNewestFirst.length - 1]; + const tailAgent = agentForRows(tailNewestFirst); const base = d.groups[storedSnapshot.id]; if (!base) { // The stored group vanished under us (concurrent rewrite); keep the // tail as its own group rather than losing it — the next full pass // reconciles the shape. - d.groups[`tg-${tailNewest.hash}`] = { + const fallback: ChangeGroup = { id: `tg-${tailNewest.hash}`, startTime: tailOldest.time, endTime: tailNewest.time, newestMemberUrl: tailNewest.memberUrl, newestHash: tailNewest.hash, actors: dedupedActors(tailNewestFirst), + contributorKey: contributorKey(tailNewest), additions: sums.additions, deletions: sums.deletions, changeCount: tailNewestFirst.length, }; + if (tailAgent) fallback.agent = tailAgent; + d.groups[fallback.id] = fallback; return; } @@ -769,26 +1011,41 @@ export function createChangeGrouper( newestMemberUrl: newer ? tailNewest.memberUrl : base.newestMemberUrl, newestHash: newer ? tailNewest.hash : base.newestHash, actors, + // Same contributor by the attach condition, so the key carries over. + contributorKey: base.contributorKey, additions: base.additions + sums.additions, deletions: base.deletions + sums.deletions, changeCount: base.changeCount + tailNewestFirst.length, }; + // Same-chat by the attach condition too; a newer tail's tag wins so + // `chatHeads` tracks the latest run that touched the group. Deep-copied + // because `base.agent` is a live proxy of the doc being mutated. + const agent = newer ? (tailAgent ?? base.agent) : (base.agent ?? tailAgent); + if (agent) { + const copy: AgentTag = { chatUrl: agent.chatUrl }; + if (agent.chatHeads) copy.chatHeads = [...agent.chatHeads]; + if (agent.toolCallId) copy.toolCallId = agent.toolCallId; + extended.agent = copy; + } if (extended.id !== storedSnapshot.id) delete d.groups[storedSnapshot.id]; d.groups[extended.id] = extended; } - // Full rebuild: regather every member's post-fork history, re-split, and - // diff newest-first in idle slices — flushing completed groups as each - // slice ends so recent history paints while older history backfills. A - // stored group whose id, span, and change count match is reused without - // re-diffing (cheap warm restarts, and no redundant work when another - // client's grouping update syncs in). Stale ids and consumed markers settle - // in the final write. + // Full rebuild: regather every member's post-fork history, pull each + // merged draft's contribution out into its own dedicated group, re-split + // the rest by time, and diff newest-first in idle slices — flushing + // completed groups as each slice ends so recent history paints while older + // history backfills. A stored group whose id, span, and change count match + // is reused without re-diffing (cheap warm restarts, and no redundant work + // when another client's grouping update syncs in). Stale ids, consumed + // markers, and attributed-merge markers settle in the final write. async function rebuildAll( changeGroupHandle: DocHandle, sources: { member: DraftMemberDoc; doc: Automerge.Doc }[], createdAt: number | undefined, + commentTimesMs: number[], frontier: Record, + mergedDrafts: MergedDraftSpec[], isAborted: () => boolean ): Promise { const rows: PendingChange[] = []; @@ -797,7 +1054,8 @@ export function createChangeGrouper( collectMemberRows(rows, member, doc, since, createdAt); } rows.sort(newestFirst); - const groupsRows = splitIntoGroups(rows); + const { merged, rest } = partitionRows(rows, mergedDrafts); + const groupsRows = splitIntoGroups(rest, commentTimesMs, contributorKey); const expectedIds = new Set(groupsRows.map(groupId)); const batch: ChangeGroup[] = []; @@ -810,6 +1068,38 @@ export function createChangeGrouper( }; const slicer = createSlicer(isAborted, flush); + + // One dedicated group per merged draft, regardless of how its changes + // interleave in time with the rest. A merge with no contributed rows + // produces no group; it still settles through the attributedMerges + // marker in the final write. + for (const draft of mergedDrafts) { + const draftRows = merged.get(draft.url) ?? []; + if (draftRows.length === 0) continue; + const id = mergeGroupId(draft.url); + expectedIds.add(id); + const existing = changeGroupHandle.doc()?.groups?.[id]; + if ( + existing && + existing.changeCount === draftRows.length && + existing.startTime === draftRows[draftRows.length - 1].time && + existing.endTime === draftRows[0].time + ) { + continue; + } + const group = await buildGroup(draftRows, slicer, contributorKey); + if (group === null) return; // aborted mid-diff; markers stay put + batch.push({ + ...group, + id, + merge: { + draftUrl: draft.url, + name: draft.name, + members: draft.members, + }, + }); + } + for (const groupRows of groupsRows) { const id = groupId(groupRows); const existing = changeGroupHandle.doc()?.groups?.[id]; @@ -821,7 +1111,7 @@ export function createChangeGrouper( ) { continue; } - const group = await buildGroup(groupRows, slicer); + const group = await buildGroup(groupRows, slicer, contributorKey); if (group === null) return; // aborted mid-diff; markers stay put batch.push(group); } @@ -835,6 +1125,12 @@ export function createChangeGrouper( for (const [url, heads] of Object.entries(frontier)) { d.computedThrough[url as AutomergeUrl] = heads; } + if (mergedDrafts.length > 0) { + if (!d.attributedMerges) d.attributedMerges = {}; + for (const md of mergedDrafts) { + if (!d.attributedMerges[md.url]) d.attributedMerges[md.url] = true; + } + } }); } } diff --git a/drafts/src/clone-policy.ts b/drafts/src/clone-policy.ts index 9c9f7a13..6bc940e5 100644 --- a/drafts/src/clone-policy.ts +++ b/drafts/src/clone-policy.ts @@ -29,6 +29,13 @@ export const SKIPPED_DATATYPES: ReadonlySet = new Set([ "change-group", // Legacy marker retained while existing ChangeGroupDocs are migrated. "change-group-cache", + // The Agent context-tool's chats (and their message docs) plus its per-doc + // chat-list index: the conversation drives draft reviews (accept/reject + // embeds), so it must stay on the real docs — a rejected draft must not + // take the chat history or the tab list with it. Regular `chat` docs keep + // their draft-scoped semantics. See chat/src/lib/agent-drafts.ts. + "agent-chat", + "agent-chats", ]); // Reduce a url to its bare document identity by stripping any path/heads diff --git a/drafts/src/draft-types.ts b/drafts/src/draft-types.ts index 2b6c17c1..fd6adc2e 100644 --- a/drafts/src/draft-types.ts +++ b/drafts/src/draft-types.ts @@ -3,10 +3,18 @@ import type { AutomergeUrl, UrlHeads } from "@automerge/automerge-repo/slim"; // One COW relationship between an original doc and the per-draft clone we // write to. `clonedAt`/`mergedAt` capture the fork and join points on the // original — together they describe what the draft contributed to that doc. +// +// `mergedFrom` is the CLONE's heads at merge time (as opposed to `mergedAt`, +// the target's heads after the merge, which can include concurrent target +// changes). `clonedAt` -> `mergedFrom` brackets exactly the changes this +// draft contributed to the doc: walking the change DAG backwards from +// `mergedFrom` and stopping at `clonedAt` recovers them for attribution +// (see merge-attribution.ts). Absent on merges made before it was recorded. export type CloneEntry = { cloneUrl: AutomergeUrl; clonedAt: UrlHeads; mergedAt?: UrlHeads; + mergedFrom?: UrlHeads; }; // `parent` points at the URL this draft branches off of: the main draft (for @@ -21,7 +29,11 @@ export type CloneEntry = { // // `mergedAt` is a wall-clock timestamp set when the draft is merged into // its parent; absent means "still open". The sidebar uses it to filter -// merged drafts out of the list. +// merged drafts out of the list. `mergedInto` records which DraftDoc the +// merge actually landed in (`findMergeTarget` can skip past already-merged +// ancestors, so it isn't always `parent`); the target's timeline uses it to +// attribute this draft's changes. Recorded, not derived: later re-parenting +// (a merged ancestor handing children up) must not move the attribution. // // `name` is the user-given display name; absent means the default label // ("Draft", or "Main" for the main draft). Renaming main is what creates the @@ -34,6 +46,7 @@ export type DraftDoc = { drafts: AutomergeUrl[]; clones: Record; mergedAt?: number; + mergedInto?: AutomergeUrl; // Points at this draft's ChangeGroupDoc, holding the precomputed activity // groups for its timeline. Stamped lazily by the ChangeGrouper the first // time it touches the timeline (see change-group-cache.ts). @@ -59,6 +72,17 @@ export type ActorAttributionDoc = { actors: Record; }; +// Provenance an agent edit carries in its Automerge change message (written +// by the chat tool as a JSON envelope, parsed tolerantly by the grouper — +// see parseAgentTag). Identifies the chat the edit came from, and optionally +// the chat's heads at execution time so a timeline row can deep-link to the +// conversation state that produced it. +export type AgentTag = { + chatUrl: AutomergeUrl; + chatHeads?: string[]; + toolCallId?: string; +}; + // One persisted burst of activity in a draft's timeline: consecutive changes // (interleaved across the draft's member docs) separated by no more than the // inactivity gap, aggregated down to what a timeline row renders. Computed @@ -74,9 +98,30 @@ export type ChangeGroup = { newestMemberUrl: AutomergeUrl; newestHash: string; actors: string[]; // deduped authors, newest contributor first + // Who this group belongs to — groups split when it changes, so every + // (non-merge) row is a single person's manual edits or a single chat's + // agent edits: `agent:${chatUrl}` / `${contactUrl}` / `actor:${actorId}` + // when the actor has no known contact yet. appendTail compares it to + // decide whether a tail may extend the stored group. + contributorKey: string; + // Present when the group's changes are agent edits: the newest row's tag + // (its chatHeads track the latest run). On a merged-draft group, set only + // when the whole contribution came from one chat. + agent?: AgentTag; additions: number; // summed across ALL member docs in the span deletions: number; changeCount: number; // for scrubber band geometry + // Present on a merged-draft group: every change a merged draft contributed + // forms one group (id `tg-merge-${draftUrl}`), pulled out of the normal + // inactivity-gap grouping. `members` carries the per-doc head ranges the + // attribution walk needs (fork point -> clone heads at merge), copied from + // the merged draft's clone entries so the sidebar can re-resolve the + // group's changes without loading the merged DraftDoc. + merge?: { + draftUrl: AutomergeUrl; + name: string | null; + members: Record; + }; }; // Self-contained: one group doc per DraftDoc, holding that draft's timeline. @@ -91,6 +136,11 @@ export type ChangeGroupDoc = { // these) yields exactly the unconsumed tail — including late-syncing // changes with old timestamps, which is what makes invalidation detectable. computedThrough: Record; + // Merged drafts (by DraftDoc url) whose changes a full rebuild has already + // attributed. A merged draft in the timeline spec but not here forces a + // rebuild; recorded separately from `groups` because a draft merged with + // zero contributed changes produces no group but must still settle. + attributedMerges?: Record; }; // One member doc's pinned view within a checkpoint. `to` is the heads to render diff --git a/drafts/src/merge-attribution.test.ts b/drafts/src/merge-attribution.test.ts new file mode 100644 index 00000000..67543ef6 --- /dev/null +++ b/drafts/src/merge-attribution.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it } from "vitest"; +import * as A from "@automerge/automerge"; +import { encodeHeads, type AutomergeUrl } from "@automerge/automerge-repo/slim"; + +import { + attributedHashes, + frontierHashes, + partitionRows, + type ChangeMetaLike, + type MergedDraftSpec, +} from "./merge-attribution"; + +// Scenarios ported from patchwork-24's compareBranches.test.ts, minus the +// "main merged into branch" case: drafts never merges parent into child, so +// the walk needs no main-side subtraction. + +type TestDoc = { content: string }; + +function decodedChanges(doc: A.Doc): ChangeMetaLike[] { + return A.getAllChanges(doc).map((change) => A.decodeChange(change)); +} + +describe("attributedHashes", () => { + it("attributes a single draft change, with nothing concurrent on main", () => { + // x main + // \ + // x draft + const baseDoc = A.change(A.init(), (d) => { + d.content = "hello"; + }); + const baseHeads = A.getHeads(baseDoc); + const draftDoc = A.change(A.clone(baseDoc), (d) => { + d.content = "world"; + }); + const mergeHeads = A.getHeads(draftDoc); + + const finalDoc = A.merge(A.clone(baseDoc), A.clone(draftDoc)); + const result = attributedHashes( + decodedChanges(finalDoc), + mergeHeads, + baseHeads + ); + + expect(result).toEqual(new Set(mergeHeads)); + }); + + it("attributes two draft changes, with nothing concurrent on main", () => { + // x main + // \ + // x + // | + // x draft + const baseDoc = A.change(A.init(), (d) => { + d.content = "hello"; + }); + const baseHeads = A.getHeads(baseDoc); + + const draftHashes: string[] = []; + let draftDoc = A.change(A.clone(baseDoc), (d) => { + d.content = "world"; + }); + draftHashes.push(A.getHeads(draftDoc)[0]); + draftDoc = A.change(draftDoc, (d) => { + d.content = "yo"; + }); + draftHashes.push(A.getHeads(draftDoc)[0]); + const mergeHeads = A.getHeads(draftDoc); + + const finalDoc = A.merge(A.clone(baseDoc), A.clone(draftDoc)); + const result = attributedHashes( + decodedChanges(finalDoc), + mergeHeads, + baseHeads + ); + + expect(result).toEqual(new Set(draftHashes)); + }); + + it("does not attribute a change concurrent on main", () => { + // x + // |\ + //main x x + // | + // x draft + const baseDoc = A.change(A.init(), (d) => { + d.content = "hello"; + }); + const baseHeads = A.getHeads(baseDoc); + + const draftHashes: string[] = []; + let draftDoc = A.change(A.clone(baseDoc), (d) => { + d.content = "world"; + }); + draftHashes.push(A.getHeads(draftDoc)[0]); + draftDoc = A.change(draftDoc, (d) => { + d.content = "yo"; + }); + draftHashes.push(A.getHeads(draftDoc)[0]); + const mergeHeads = A.getHeads(draftDoc); + + const mainDoc = A.change(A.clone(baseDoc), (d) => { + d.content = "bar"; + }); + + const finalDoc = A.merge(A.clone(mainDoc), A.clone(draftDoc)); + const result = attributedHashes( + decodedChanges(finalDoc), + mergeHeads, + baseHeads + ); + + expect(result).toEqual(new Set(draftHashes)); + }); + + it("does not attribute changes made on main after the merge", () => { + // x main + // |\ + // | x draft + // |/ + // x + // | + // x main again + const baseDoc = A.change(A.init(), (d) => { + d.content = "hello"; + }); + const baseHeads = A.getHeads(baseDoc); + const draftDoc = A.change(A.clone(baseDoc), (d) => { + d.content = "world"; + }); + const mergeHeads = A.getHeads(draftDoc); + + const mergedDoc = A.merge(A.clone(baseDoc), A.clone(draftDoc)); + const finalDoc = A.change(A.clone(mergedDoc), (d) => { + d.content = "bar"; + }); + const result = attributedHashes( + decodedChanges(finalDoc), + mergeHeads, + baseHeads + ); + + expect(result).toEqual(new Set(mergeHeads)); + }); + + it("stops at every head of a multi-head fork frontier", () => { + // x + // |\ + // x x <- fork point: two concurrent heads + // \/ + // x draft + const rootDoc = A.change(A.init(), (d) => { + d.content = "hello"; + }); + const left = A.change(A.clone(rootDoc), (d) => { + d.content = "left"; + }); + const right = A.change(A.clone(rootDoc), (d) => { + d.content = "right"; + }); + const baseDoc = A.merge(A.clone(left), A.clone(right)); + const baseHeads = A.getHeads(baseDoc); + expect(baseHeads.length).toBe(2); + + const draftDoc = A.change(A.clone(baseDoc), (d) => { + d.content = "draft"; + }); + const mergeHeads = A.getHeads(draftDoc); + + const finalDoc = A.merge(A.clone(baseDoc), A.clone(draftDoc)); + const result = attributedHashes( + decodedChanges(finalDoc), + mergeHeads, + baseHeads + ); + + expect(result).toEqual(new Set(mergeHeads)); + }); + + it("keeps draining the walk after dequeuing a stop head", () => { + // A merge frontier can contain a base head itself. patchwork-24's walk + // aborted outright on the first stop head it dequeued; ours must only + // end that branch of the walk. + const metas: ChangeMetaLike[] = [ + { hash: "b", deps: [] }, + { hash: "c", deps: ["b"] }, + ]; + expect(attributedHashes(metas, ["c", "b"], ["b"])).toEqual(new Set(["c"])); + expect(attributedHashes(metas, ["b", "c"], ["b"])).toEqual(new Set(["c"])); + }); + + it("treats hashes missing from the window as stops", () => { + // The grouper's pre-creation-time filter can drop rows from the window; + // the walk must end there rather than throw. + const metas: ChangeMetaLike[] = [{ hash: "c", deps: ["missing"] }]; + expect(attributedHashes(metas, ["c"], [])).toEqual(new Set(["c"])); + }); +}); + +describe("partitionRows", () => { + const memberUrl = "automerge:test-member" as AutomergeUrl; + + it("pulls a merged draft's changes out of a time-interleaved row list", () => { + // Draft and parent edit concurrently with interleaved timestamps; the + // partition must split by DAG reachability, not by time. + const baseDoc = A.change( + A.init(), + { time: 100 }, + (d) => { + d.content = "hello"; + } + ); + const baseHash = A.getHeads(baseDoc)[0]; + const clonedAt = encodeHeads(A.getHeads(baseDoc)); + + const draftHashes: string[] = []; + let draftDoc = A.change(A.clone(baseDoc), { time: 200 }, (d) => { + d.content = "draft one"; + }); + draftHashes.push(A.getHeads(draftDoc)[0]); + draftDoc = A.change(draftDoc, { time: 400 }, (d) => { + d.content = "draft two"; + }); + draftHashes.push(A.getHeads(draftDoc)[0]); + const mergedFrom = encodeHeads(A.getHeads(draftDoc)); + + const parentHashes: string[] = []; + let parentDoc = A.change(A.clone(baseDoc), { time: 300 }, (d) => { + d.content = "parent one"; + }); + parentHashes.push(A.getHeads(parentDoc)[0]); + parentDoc = A.change(parentDoc, { time: 500 }, (d) => { + d.content = "parent two"; + }); + parentHashes.push(A.getHeads(parentDoc)[0]); + + const mergedDoc = A.merge(A.clone(parentDoc), A.clone(draftDoc)); + + // The grouper's newest-first row shape: hash/deps/time plus memberUrl. + const rows = A.getAllChanges(mergedDoc) + .map((change) => A.decodeChange(change)) + .map((meta) => ({ + memberUrl, + hash: meta.hash, + deps: meta.deps, + time: meta.time, + })) + .sort((a, b) => b.time - a.time); + + const draftUrl = "automerge:test-draft" as AutomergeUrl; + const spec: MergedDraftSpec = { + url: draftUrl, + name: "Test draft", + members: { + [memberUrl]: { baseHeads: clonedAt, mergeHeads: mergedFrom }, + }, + }; + + const { merged, rest } = partitionRows(rows, [spec]); + + expect(merged.get(draftUrl)?.map((r) => r.hash)).toEqual( + // Input order (newest-first by time) is preserved. + [draftHashes[1], draftHashes[0]] + ); + expect(rest.map((r) => r.hash)).toEqual([ + parentHashes[1], + parentHashes[0], + baseHash, + ]); + }); + + it("passes everything through when there are no merged drafts", () => { + const rows = [ + { memberUrl, hash: "a", deps: [] }, + { memberUrl, hash: "b", deps: ["a"] }, + ]; + const { merged, rest } = partitionRows(rows, []); + expect(merged.size).toBe(0); + expect(rest).toEqual(rows); + }); +}); + +describe("frontierHashes", () => { + it("reduces a linear chain to its newest change", () => { + const rows: ChangeMetaLike[] = [ + { hash: "c", deps: ["b"] }, + { hash: "b", deps: ["a"] }, + { hash: "a", deps: [] }, + ]; + expect(frontierHashes(rows)).toEqual(["c"]); + }); + + it("keeps concurrent branches as a multi-head frontier", () => { + // A merged draft's rows and the parent's regular rows share a base but + // not each other — the state below a boundary containing both is the + // union, so both heads must survive. + const rows: ChangeMetaLike[] = [ + { hash: "draft2", deps: ["draft1"] }, + { hash: "draft1", deps: ["base"] }, + { hash: "main1", deps: ["base"] }, + { hash: "base", deps: [] }, + ]; + expect(new Set(frontierHashes(rows))).toEqual( + new Set(["draft2", "main1"]) + ); + }); + + it("ignores deps pointing outside the set", () => { + // Rows whose deps reach into older history (below the fork point or the + // creation cutoff) are frontier candidates like any other. + const rows: ChangeMetaLike[] = [{ hash: "a", deps: ["outside"] }]; + expect(frontierHashes(rows)).toEqual(["a"]); + }); + + it("verifies against Automerge's own heads for a concurrent merge", () => { + type TestDoc = { content: string }; + const baseDoc = A.change(A.init(), (d) => { + d.content = "hello"; + }); + const left = A.change(A.clone(baseDoc), (d) => { + d.content = "left"; + }); + const right = A.change(A.clone(baseDoc), (d) => { + d.content = "right"; + }); + const mergedDoc = A.merge(A.clone(left), A.clone(right)); + const rows = A.getAllChanges(mergedDoc).map((change) => + A.decodeChange(change) + ); + expect(new Set(frontierHashes(rows))).toEqual( + new Set(A.getHeads(mergedDoc)) + ); + }); +}); diff --git a/drafts/src/merge-attribution.ts b/drafts/src/merge-attribution.ts new file mode 100644 index 00000000..ed2e6dd0 --- /dev/null +++ b/drafts/src/merge-attribution.ts @@ -0,0 +1,127 @@ +import { + decodeHeads, + type AutomergeUrl, + type UrlHeads, +} from "@automerge/automerge-repo/slim"; + +// Which changes did a merged draft contribute? Ported from patchwork-24's +// getChangesFromMergedBranch: the draft's clone entry brackets its +// contribution per member doc — fork point (`clonedAt` -> `baseHeads`) and +// the clone's heads at merge time (`mergedFrom` -> `mergeHeads`) — and a +// backwards walk over the change DAG between the two recovers exactly the +// change hashes in between. Unlike patchwork-24 there is no main-side +// subtraction: drafts only ever merges child into parent, never parent into +// child, so nothing of the parent's is reachable from the clone's merge +// heads beyond the fork point. + +// A merged draft as the grouper sees it: which draft, its display name, and +// per member doc the head range its contribution spans. Built by the +// draft-state provider from the merged DraftDoc's clone entries, and +// persisted onto the merge group (`ChangeGroup.merge.members`) so the +// sidebar can re-run the walk without loading the DraftDoc. +export type MergedDraftSpec = { + url: AutomergeUrl; + name: string | null; + members: Record; +}; + +// The subset of a change's metadata the walk needs; satisfied by both the +// grouper's PendingChange rows and Automerge's DecodedChange/ChangeMetadata. +export type ChangeMetaLike = { hash: string; deps: string[] }; + +type AttributableRow = ChangeMetaLike & { memberUrl: AutomergeUrl }; + +// Split a timeline's rows into the changes each merged draft contributed +// (keyed by draft url, preserving the input order) and the rest. The rest +// goes through the normal inactivity-gap grouping; each merged draft's rows +// become one dedicated group. A hash claimed by several drafts (a nested +// merge whose ranges overlap) goes to the first claimant. +export function partitionRows( + rows: Row[], + mergedDrafts: MergedDraftSpec[] +): { merged: Map; rest: Row[] } { + const merged = new Map(); + if (mergedDrafts.length === 0) return { merged, rest: rows }; + + const rowsByMember = new Map(); + for (const row of rows) { + let list = rowsByMember.get(row.memberUrl); + if (!list) rowsByMember.set(row.memberUrl, (list = [])); + list.push(row); + } + + // Change hash -> the merged draft that contributed it. + const owner = new Map(); + for (const draft of mergedDrafts) { + merged.set(draft.url, []); + for (const [memberUrl, range] of Object.entries(draft.members)) { + const memberRows = rowsByMember.get(memberUrl as AutomergeUrl); + if (!memberRows) continue; + const hashes = attributedHashes( + memberRows, + decodeHeads(range.mergeHeads), + decodeHeads(range.baseHeads) + ); + for (const hash of hashes) { + if (!owner.has(hash)) owner.set(hash, draft.url); + } + } + } + + const rest: Row[] = []; + for (const row of rows) { + const draftUrl = owner.get(row.hash); + if (draftUrl) { + merged.get(draftUrl)!.push(row); + } else { + rest.push(row); + } + } + return { merged, rest }; +} + +// The frontier of a change set: the hashes no OTHER change in the set lists +// among its deps — i.e. the heads describing exactly the state made of these +// changes (plus their ancestry). Used by the sidebar's scrub boundaries: the +// rows rendered below a boundary can be mutually concurrent (a merge group +// interleaved in time with regular edits), so the boundary pins to this +// multi-head frontier instead of the single newest-by-wall-clock change. +// Deps pointing outside the set (older history) are ignored. Assumes no +// causal chain between two set members passes through a change outside the +// set — true for a timeline's rows, where anything below a row's dependents +// is included with it. +export function frontierHashes(rows: ChangeMetaLike[]): string[] { + const depended = new Set(); + for (const row of rows) { + for (const dep of row.deps) depended.add(dep); + } + return rows.filter((row) => !depended.has(row.hash)).map((row) => row.hash); +} + +// Walk the change DAG backwards from `mergeHeads` over `deps`, stopping at +// `baseHeads`; the hashes visited are the merged draft's contribution. +// Deviations from patchwork-24's getHashesBetweenHeads: a stop-head only +// ends its own branch of the walk rather than aborting the whole traversal +// (drafts merges routinely produce multi-head frontiers), and a hash missing +// from `metas` is treated as a stop rather than an error (the grouper's +// pre-creation-time filter can drop rows from the window). +export function attributedHashes( + metas: ChangeMetaLike[], + mergeHeads: string[], + baseHeads: string[] +): Set { + const byHash = new Map(metas.map((meta) => [meta.hash, meta])); + const stop = new Set(baseHeads); + const attributed = new Set(); + const workQueue = [...mergeHeads]; + + let hash: string | undefined; + while ((hash = workQueue.pop())) { + if (stop.has(hash) || attributed.has(hash)) continue; + const meta = byHash.get(hash); + if (!meta) continue; + attributed.add(hash); + workQueue.push(...meta.deps); + } + return attributed; +} diff --git a/drafts/src/patchwork-view.d.ts b/drafts/src/patchwork-view.d.ts new file mode 100644 index 00000000..a2bf27fa --- /dev/null +++ b/drafts/src/patchwork-view.d.ts @@ -0,0 +1,15 @@ +// The custom element in Solid JSX (the runtime registers it +// globally; this only teaches the type checker the attributes we use). +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + "patchwork-view": { + class?: string; + "doc-url"?: string; + "tool-id"?: string; + }; + } + } +} + +export {}; diff --git a/drafts/src/providers/DraftStateProvider.ts b/drafts/src/providers/DraftStateProvider.ts index c8e4e178..3edbec65 100644 --- a/drafts/src/providers/DraftStateProvider.ts +++ b/drafts/src/providers/DraftStateProvider.ts @@ -27,6 +27,7 @@ import { createChangeGrouper, type TimelineGroupingSpec, } from "../change-group-cache.js"; +import type { MergedDraftSpec } from "../merge-attribution.js"; import { createActorRecorder, ensureActorAttribution, @@ -126,6 +127,7 @@ export const DraftStateProvider = (element: HTMLElement) => { // is open. Member-doc listeners drive updates between list recomputes. const changeGrouper = createChangeGrouper(repo, { onLocalChange: actorRecorder.recordLocalChange, + resolveContact: actorRecorder.contactFor, }); // Main-case membership: docs mounted beneath this provider, ref-counted so a // doc shown in several views is only dropped on its last unmount. Populated @@ -333,7 +335,19 @@ export const DraftStateProvider = (element: HTMLElement) => { ); if (disposed) return; orderedDraftUrls = allDrafts; - draftRouter?.updateAvailableDrafts(allDrafts); + // The router only gets OPEN drafts. A merged draft stays linked in the + // tree (unlike a rejected one, which is unlinked), so without this + // filter it stays selectable forever: a stale `draft=` hash param — + // e.g. restored by a host router rewriting the full hash right after + // an accept — would re-check-out the merged draft, fighting whoever + // reset to main (the post-accept flicker). Filtered here, the stale + // param parks as a forever-pending deep link and reconcileSelection + // actively resets any checkout still pointing at a merged draft. + draftRouter?.updateAvailableDrafts( + allDrafts.filter( + (u) => trackedDrafts.get(u)?.doc()?.mergedAt === undefined + ) + ); } catch (err) { console.error("[drafts] rewalk failed:", err); } finally { @@ -371,6 +385,7 @@ export const DraftStateProvider = (element: HTMLElement) => { draftHandle: mainDraftHandle, members: clonesToMembers(mainDraftHandle.doc()?.clones ?? {}), rootDocUrl: docUrl, + mergedDrafts: mergedDraftSpecsFor(mainDraftHandle.url), }); } const selected = checkedOutHandle?.doc()?.checkedOut ?? null; @@ -386,11 +401,41 @@ export const DraftStateProvider = (element: HTMLElement) => { draftHandle: handle, members: clonesToMembers(doc.clones), rootDocUrl: docUrl, + mergedDrafts: mergedDraftSpecsFor(url), }); } changeGrouper.setTimelines(specs); } + // The drafts merged into `timelineUrl`'s timeline, as attribution specs: + // tracked drafts whose recorded merge target (`mergedInto`) is this + // timeline, carrying per member the head range bracketing their + // contribution. Merged drafts stay tracked (they remain linked in the + // tree), so this is a pure read. Drafts merged before `mergedFrom` and + // `mergedInto` were recorded are skipped — no attribution for those. + function mergedDraftSpecsFor(timelineUrl: AutomergeUrl): MergedDraftSpec[] { + const result: MergedDraftSpec[] = []; + for (const [url, handle] of trackedDrafts) { + const doc = handle.doc(); + if (!doc || doc.mergedAt === undefined || doc.mergedInto !== timelineUrl) + continue; + const members: MergedDraftSpec["members"] = {}; + for (const [originalUrl, entry] of Object.entries(doc.clones)) { + if (!entry.mergedFrom) continue; + // Copy the heads arrays: they were read out of the DraftDoc and end + // up written into the ChangeGroupDoc (`ChangeGroup.merge.members`), + // and a live Automerge object must not cross into another document. + members[originalUrl as AutomergeUrl] = { + baseHeads: [...entry.clonedAt] as UrlHeads, + mergeHeads: [...entry.mergedFrom] as UrlHeads, + }; + } + if (Object.keys(members).length === 0) continue; + result.push({ url, name: doc.name ?? null, members }); + } + return result; + } + // The full read-only list: the main entry plus one summary per non-merged // draft, in rewalk (tree) order. function computeList(): DraftList { diff --git a/drafts/src/styles.css b/drafts/src/styles.css index b57f04e3..83aeb486 100644 --- a/drafts/src/styles.css +++ b/drafts/src/styles.css @@ -478,6 +478,152 @@ white-space: nowrap; } +/* One comment slotted into the timeline at the moment it was made. Same + bones as a group row, but the content reads as a speech-bubble pill so + comments scan differently from edit groups. */ +.draft-comment-row { + display: flex; + align-items: center; + gap: 0.375rem; + width: 100%; + padding: 0.3125rem 0.375rem; + border: none; + border-radius: var(--drafts-radius-sm); + background: transparent; + font-family: inherit; + font-size: 0.75rem; + text-align: left; + color: inherit; + cursor: pointer; +} + +.draft-comment-row:hover { + background: color-mix(in oklch, var(--drafts-hover-bg) 60%, transparent); +} + +.draft-comment-label { + flex: none; + color: var(--drafts-muted-fg); + white-space: nowrap; +} + +.draft-comment-content { + flex: 0 1 auto; + min-width: 0; + padding: 0.125rem 0.4375rem; + border-radius: 0.625rem; + background: color-mix(in oklch, var(--drafts-hover-bg) 80%, transparent); + color: var(--drafts-muted-fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Badge on a group of agent-made changes; clicking opens the chat that made + them. Tinted with the primary color to read apart from the merge badge. */ +.draft-group-agent { + flex: none; + padding: 0.0625rem 0.3125rem; + border-radius: var(--drafts-radius-sm); + background: color-mix(in oklch, var(--drafts-primary) 15%, transparent); + color: var(--drafts-primary-text); + font-size: 0.6875rem; + white-space: nowrap; + cursor: pointer; +} + +.draft-group-agent:hover { + background: color-mix(in oklch, var(--drafts-primary) 25%, transparent); +} + +/* A merge row plus its unfolded contributor runs: one block, registered as + the group's row element so the scrubber band stretches over the runs. */ +.draft-merge-block { + display: flex; + flex-direction: column; + width: 100%; +} + +/* Disclosure chevron on a merge row; rotates when unfolded. Pulled fully + out of the horizontal flow (the negative margin cancels its width plus + the flex gap) so it hangs left of the row content, into the strip by the + scrubber gutter — the avatar stays aligned with every other row's. + Stacked above the scrubber token's drag handles (z-index 20), which pass + through the same strip: a click on the chevron must always toggle the + disclosure, never grab the scrubber. */ +.draft-group-expand { + position: relative; + z-index: 30; + flex: none; + width: 1rem; + margin-left: -1.375rem; + display: flex; + align-items: center; + justify-content: center; + align-self: stretch; + color: var(--drafts-muted-fg); + font-size: 0.8125rem; + cursor: pointer; + transition: transform 120ms ease; +} + +.draft-group-expand:hover { + color: var(--drafts-fg); +} + +.draft-group-expand[data-expanded] { + transform: rotate(90deg); +} + +/* One contributor run inside an unfolded merge group — a slimmer, indented + sibling of .draft-group-row. */ +.draft-run-row { + display: flex; + align-items: center; + gap: 0.375rem; + width: 100%; + padding: 0.25rem 0.375rem 0.25rem 1.5rem; + border: none; + border-radius: var(--drafts-radius-sm); + background: transparent; + font-family: inherit; + font-size: 0.6875rem; + color: var(--drafts-fg); + cursor: pointer; + text-align: left; +} + +.draft-run-row:hover { + background: var(--drafts-hover-bg); +} + +/* The head is parked exactly on this run — the row is the selection + indicator (the mid-group sticker is suppressed for it, see headChange). */ +.draft-run-row[data-selected] { + background: var(--drafts-selected-bg); + box-shadow: inset 0 0 0 1px var(--scrub-color, var(--drafts-primary)); +} + +.draft-merge-loading { + padding: 0.25rem 0.375rem 0.25rem 1.5rem; + color: var(--drafts-muted-fg); + font-size: 0.6875rem; +} + +/* Badge on a merged draft's dedicated group row, naming the source draft. */ +.draft-group-merge { + flex: none; + max-width: 45%; + padding: 0.0625rem 0.3125rem; + border-radius: var(--drafts-radius-sm); + background: color-mix(in oklch, var(--drafts-hover-bg) 80%, transparent); + color: var(--drafts-muted-fg); + font-size: 0.6875rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* Author avatars */ .draft-avatars { display: flex; @@ -599,12 +745,16 @@ margin-top: -1px; border-radius: 1px; background: var(--scrub-color); + /* The line alone is softened further than the shared scrubber ink — it + crosses the rows' text, so it should underline, not strike through. + The dot and grab handles keep the full ink. */ + opacity: 0.65; } /* The baseline's line reads fainter than the head's, so the version being viewed stays the dominant mark. */ .draft-scrubber-token--baseline .draft-scrubber-line { - opacity: 0.55; + opacity: 0.4; } /* Invisible grab strip over the line's gutter end; dragging only starts diff --git a/providers/src/FocusProvider.ts b/providers/src/FocusProvider.ts index b9424778..c2be26e8 100644 --- a/providers/src/FocusProvider.ts +++ b/providers/src/FocusProvider.ts @@ -11,9 +11,20 @@ const SELECTOR = "patchwork:focus"; // renders selection ∪ highlight, with overlap drawn more intensely. // Two fields instead of one because a single shared `selection` would // create a feedback loop between the editor and any view writing back. +// - `openThread`: a one-shot request for the comments panel to reveal a +// comment thread (pin it, select its targets, scroll it into view). +// Written by other views (e.g. the drafts timeline's comment rows) and +// consumed — deleted — by the panel once acted on. `at` (wall-clock ms) +// lets the consumer drop a stale request whose thread never renders +// (e.g. a resolved thread, which the panel doesn't list). +// - `openAgentChat`: the same one-shot convention aimed at the agent +// context tool: select the chat tab with this url (written by e.g. the +// drafts timeline's "via agent" badge, consumed by the agent tool). export type FocusDoc = { selection: Record; highlight: Record; + openThread?: { url: AutomergeUrl; at: number }; + openAgentChat?: { url: AutomergeUrl; at: number }; }; export const FocusProvider = (element: PatchworkViewElement) => { diff --git a/scripts/watch.mjs b/scripts/watch.mjs index 366b819b..fe32908c 100644 --- a/scripts/watch.mjs +++ b/scripts/watch.mjs @@ -49,9 +49,15 @@ function stop(code = 0) { process.exitCode = code; } +// On pnpm >= 11, `pnpm -r` at the (workspace-less) root recurses into every +// directory with a package.json — including the aggregated copies in +// static-dist/packages/, which keep their scripts but have no sources or +// node_modules. Exclude them or their failures kill the whole watch. +const notStaticDist = "--filter=!{static-dist/**}"; + if (!existsSync(join(root, "static-dist", "modules.json"))) { for (const [command, args] of [ - ["pnpm", ["-r", "--if-present", "build"]], + ["pnpm", ["-r", "--if-present", notStaticDist, "build"]], ["node", ["scripts/bundle.mjs"]], ]) { const initial = spawnSync(command, args, { cwd: root, stdio: "inherit" }); @@ -81,7 +87,7 @@ for (const name of readdirSync(root)) { const tools = spawn( "pnpm", - ["-r", "--parallel", "--if-present", "dev"], + ["-r", "--parallel", "--if-present", notStaticDist, "dev"], { cwd: root, stdio: "inherit" }, ); tools.on("exit", (code) => { diff --git a/threepane/src/components/DocumentAreaRoot.tsx b/threepane/src/components/DocumentAreaRoot.tsx index 00b28f87..eebfca1f 100644 --- a/threepane/src/components/DocumentAreaRoot.tsx +++ b/threepane/src/components/DocumentAreaRoot.tsx @@ -235,6 +235,32 @@ function DraftDocumentArea(props: { const contextItems = useTaggedComponents("context-tool"); const hasContext = () => contextItems().length > 0; + // A context tool can ask the shell to reveal a sibling tab (e.g. the drafts + // timeline opening a comment in the comments panel): the bubbling + // `patchwork:open-context-tool` event names the tab's component id. Switch + // to it and make sure the sidebar is actually open. An id that isn't a + // registered context tool is ignored, so the request degrades to nothing + // when the named tool isn't installed. + const [openToolListenerHost, setOpenToolListenerHost] = + createSignal(); + createEffect(() => { + const el = openToolListenerHost(); + if (!el) return; + const onOpen = (event: Event) => { + const toolId = (event as CustomEvent<{ toolId?: string }>).detail + ?.toolId; + if (!toolId || !contextItems().some((item) => item.id === toolId)) + return; + event.stopPropagation(); + props.setSelectedContextToolId(toolId); + props.setIsRightSidebarCollapsed(false); + }; + el.addEventListener("patchwork:open-context-tool", onOpen); + onCleanup(() => + el.removeEventListener("patchwork:open-context-tool", onOpen) + ); + }); + // Remount key for the main view: just the selected doc. Checkpoint pins no // longer ride on this url — the overlay provider streams them on the // descriptors' *backing* urls and `OverlayRepo` swaps handle backings in @@ -276,7 +302,13 @@ function DraftDocumentArea(props: { return ( { + setDraftOverlayProviderHost(el); + // The open-context-tool listener sits on this outermost element so it + // catches requests bubbling from anywhere in the column — the context + // sidebar included. + setOpenToolListenerHost(el); + }} >