From fc292266e7015028cf0754aa5d413d10f16d16a7 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Fri, 14 Aug 2026 15:27:44 +0200 Subject: [PATCH 01/21] more features --- chat/src/agent-tool.tsx | 549 ++++++++++++++++++++++++++ chat/src/components/ChatRoot.tsx | 212 +++++++++- chat/src/components/InputArea.tsx | 5 + chat/src/components/MessageBody.tsx | 7 +- chat/src/components/RichBlockView.tsx | 136 ++++++- chat/src/context-tool.tsx | 78 +--- chat/src/datatype.ts | 14 + chat/src/index.ts | 31 +- chat/src/lib/agent-drafts.ts | 440 +++++++++++++++++++++ chat/src/lib/context-chat.ts | 84 ++++ chat/src/lib/llm-skills.ts | 236 +++++++++++ chat/src/lib/plugin-catalog.ts | 10 + chat/src/styles/chat.css | 88 ++++- chat/src/version.ts | 3 + drafts/src/clone-policy.ts | 7 + 15 files changed, 1821 insertions(+), 79 deletions(-) create mode 100644 chat/src/agent-tool.tsx create mode 100644 chat/src/lib/agent-drafts.ts create mode 100644 chat/src/lib/context-chat.ts create mode 100644 chat/src/lib/llm-skills.ts create mode 100644 chat/src/version.ts diff --git a/chat/src/agent-tool.tsx b/chat/src/agent-tool.tsx new file mode 100644 index 00000000..52cba1b8 --- /dev/null +++ b/chat/src/agent-tool.tsx @@ -0,0 +1,549 @@ +// 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, toolStorageUrl} from "./lib/selected-doc" +import {setRepo} from "./lib/repo" +import {generateId} from "./lib/helpers" +import { + DEFAULT_CONTEXT_CHAT_PLUGINS, + ensureDefaultPlugins, + rememberPluginsAsDefault, + createContextChat, + type ToolStorageDoc, +} from "./lib/context-chat" +import { + AGENT_CHAT_TYPE, + rawRepo, + resolveAgentChatsIndex, + createAgentDraft, + checkedOutDraftHandle, + checkoutDraft, + checkoutAgentDraft, + resolveInDraft, + type AgentChatsIndexDoc, + type CheckedOutDraft, + type DraftDoc, +} from "./lib/agent-drafts" +import type {ChatDoc} from "./types" + +/** 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] : [] + }) + + // 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 + + const onRunStart = async () => { + // 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. + } + + /** 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 (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 + }) + } + + /** 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) => ( + + )} + + + ) +} + +/** 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)} + /> + }> + + + ) +} diff --git a/chat/src/components/ChatRoot.tsx b/chat/src/components/ChatRoot.tsx index 08efaad0..be384bff 100644 --- a/chat/src/components/ChatRoot.tsx +++ b/chat/src/components/ChatRoot.tsx @@ -32,6 +32,15 @@ import { parseToolCalls as llmParseToolCalls, } from "@chee/patchwork-llm" import {generateId} from "../lib/helpers" +import {agentMessageMetadata} from "../lib/agent-drafts" +import { + listSkills, + resolveActiveSkills, + skillsPromptSection, + skillToolSchemas, + runSkillTool, + type ActiveSkill, +} from "../lib/llm-skills" import {automergeUrlToServiceWorkerUrl} from "@inkandswitch/patchwork-filesystem" import {transcribeVoiceNote} from "../lib/transcription" import {reloadPreviewIframe} from "../lib/preview-frame" @@ -46,6 +55,20 @@ export function ChatRoot(props: { // currently-selected doc the computer reads/writes. mode?: "chat" | "context" targetDocUrl?: () => AutomergeUrl | undefined + // Agent-draft lifecycle (the Agent tool's edits-land-on-a-draft flow). + // onRunStart runs before a computer response begins — the wrapper ensures + // the chat's draft exists. resolveDoc aims every run-time doc lookup at + // that draft's clones DIRECTLY (independent of the global checkout, so tab + // switches and parallel runs in other chats can't redirect in-flight + // edits). onRunEnd fires when the run finishes, with whether any + // doc-editing tool actually ran (true → the wrapper posts the + // accept/reject review embed) and, when edits happened, an LLM-written + // one-line summary of them (used as the draft's name). + agentDraft?: { + onRunStart: () => Promise + onRunEnd: (edited: boolean, summary?: string) => void | Promise + resolveDoc: (url: string) => Promise + } // Optional selector OVERRIDE (the embeddable component's `features=` attr). // When absent, the active feature set is driven by the document's `plugins` // array — the same source ChatProvider reads. @@ -208,6 +231,60 @@ export function ChatRoot(props: { createSignal(null) const computerRespondedToIds = new Set() let computerResponding = false + // Did a doc-editing tool run (successfully) during the current computer + // response? Drives the agent-draft review embed (props.agentDraft.onRunEnd). + // `editLogThisRun` keeps a terse record of those calls so the draft-name + // summary can be generated even when the tool exchange never made it into + // the conversation messages (e.g. the run ended on a question). + let editedDocsThisRun = false + let editLogThisRun: string[] = [] + const EDIT_TOOLS = new Set(["automerge_op", "replace_text", "create_doc"]) + function noteToolRun(name: string, args: any, result: string) { + if (EDIT_TOOLS.has(name) && !/^error/i.test(result.trim())) { + editedDocsThisRun = true + try { + editLogThisRun.push(name + " " + JSON.stringify(args).slice(0, 300)) + } catch { + editLogThisRun.push(name) + } + } + } + + // A one-line title for the agent draft: one cheap follow-up completion (no + // tools) asking the model to sum up the edits it just made. Undefined on + // any failure — the draft then keeps its previous name. + async function generateDraftSummary( + messages: any[], + signal: AbortSignal + ): Promise { + try { + const gen = await generateLLM( + [ + ...messages, + { + role: "user", + content: + "[System] You just edited the document with these tool calls:\n" + + editLogThisRun.join("\n").slice(0, 2000) + + "\n\nReply with ONLY a short title of 3\u20138 words describing " + + "those changes (no quotes, no trailing period). It names the " + + "draft that holds them.", + }, + ], + () => {}, + signal + ) + const line = (gen.text || "") + .replace(/^\[Computer\]\s*/i, "") + .trim() + .split("\n")[0] + .replace(/^["'\u201C\u201D\s]+|["'\u201C\u201D\s.]+$/g, "") + return line ? line.slice(0, 80) : undefined + } catch (e) { + console.warn("[Computer] draft summary failed:", e) + return undefined + } + } let computerListenerActive = false let computerListenerCleanup: (() => void) | null = null // Single-host: only one tab should respond as Computer @@ -671,9 +748,22 @@ Never overwrite an entire long field with a key-assign (range:"content") just to })) } - // The tool set for whichever mode we're in, plus any self-defined tools. + // The llm:skill packs active for the CURRENT run (focused-doc datatype + // matches + ids enabled on the chat), resolved at the start of each + // computer response. Drives the "## Skills" prompt section, extra skill + // tools, and their dispatch. + let skillsForRun: ActiveSkill[] = [] + + // The tool set for whichever mode we're in, plus any self-defined tools, + // plus tools contributed by the run's active skills (built-ins win on a + // name conflict). function activeTools() { - return [...(isContext() ? CONTEXT_TOOLS : COMPUTER_TOOLS), ...customTools()] + const base = [ + ...(isContext() ? CONTEXT_TOOLS : COMPUTER_TOOLS), + ...customTools(), + ] + const taken = new Set(base.map((t) => t.name)) + return [...base, ...skillToolSchemas(skillsForRun, taken)] } // Render a structured tool call as the text shown in its card — mirrors the old @@ -1112,6 +1202,14 @@ Never overwrite an entire long field with a key-assign (range:"content") just to // tool_calls or parsed JSON). Args may already be typed (objects/ // numbers) or strings, so each branch is tolerant of both. Returns a result // string fed back to the model. + // Doc resolution for the RUN path (context snapshot + read/edit tools): an + // agent-draft run aims at its chat's own draft clones; otherwise the + // overlay repo decides (the checked-out draft, or main). + function resolveRunDoc(url: string): Promise { + if (props.agentDraft) return props.agentDraft.resolveDoc(url) + return ((props.element as any).repo as any).find(url) + } + async function runToolByName(toolName: string, rawArgs: any): Promise { const args = rawArgs || {} const repo = (props.element as any).repo @@ -1121,7 +1219,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to if (toolName === "read_doc") { const url = args.url || (isContext() ? focusedUrl() : undefined) if (!url) return "Error: no url and no focused document." - const h = await repo.find(url) + const h = await resolveRunDoc(url) const doc = h.doc() if (isContext()) { // Context mode also returns heads so a follow-up automerge_op can @@ -1137,7 +1235,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to } else if (toolName === "automerge_op") { const url = args.url || focusedUrl() if (!url) return "Error: no url and no focused document." - const h = await repo.find(url) + const h = await resolveRunDoc(url) // path / range / value may arrive typed (native function calling) or as // JSON strings (local convention) — be tolerant of both. const parseMaybe = (v: any) => { @@ -1216,7 +1314,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to restrict = undefined } } - const h = await repo.find(url) + const h = await resolveRunDoc(url) const matches = findTextMatches(h.doc(), query, restrict) if (!matches.length) { return ( @@ -1252,7 +1350,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to restrict = undefined } } - const h = await repo.find(url) + const h = await resolveRunDoc(url) const matches = findTextMatches(h.doc(), find, restrict) if (!matches.length) { return ( @@ -1613,6 +1711,17 @@ Never overwrite an entire long field with a key-assign (range:"content") just to "`. It becomes available on your NEXT run (not this turn)." ) } + // A tool contributed by one of the run's active llm:skills — dispatch + // to the skill's own runTool. + const skillResult = await runSkillTool(skillsForRun, toolName, args, { + repo, + handle: props.handle, + element: props.element, + focusedUrl: focusedUrl(), + applyAutomerge, + }) + if (skillResult !== null) return skillResult + // A tool the computer defined for itself via define_tool — run its JS. const custom = ( (props.handle.doc() as any)?.computerCustomTools || [] @@ -1906,7 +2015,9 @@ Never overwrite an entire long field with a key-assign (range:"content") just to const turl = props.targetDocUrl?.() if (turl) { try { - const th = await repo.find(turl) + // Through resolveRunDoc so an agent-draft run snapshots ITS + // draft's clone (with any prior-run edits), not main. + const th = await resolveRunDoc(turl) const td = th.doc() as any let heads: any = [] try { @@ -1999,6 +2110,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to timestamp: Date.now(), isComputer: true, font: "monospace", + ...agentMessageMetadata(props.handle.doc()), } if (replyTo) msgData.replyTo = replyTo if (opts?.embeds) msgData.embeds = opts.embeds @@ -2424,6 +2536,21 @@ Never overwrite an entire long field with a key-assign (range:"content") just to const repo = (props.element as any).repo if (!repo || computerResponding) return computerResponding = true + editedDocsThisRun = false + editLogThisRun = [] + let agentDraftSummary: string | undefined + + // Agent-draft flow: ensure this chat's draft exists BEFORE anything + // touches the focused doc — resolveRunDoc then aims every run-time + // lookup at that draft's clones, independent of what's checked out. + // On failure the run proceeds; edits land on the plain docs. + if (props.agentDraft) { + try { + await props.agentDraft.onRunStart() + } catch (e) { + console.warn("[Computer] agent draft setup failed:", e) + } + } const abortController = new AbortController() setComputerAbort(abortController) @@ -2447,11 +2574,36 @@ Never overwrite an entire long field with a key-assign (range:"content") just to let tokenThrottleTimer: any = null try { - const context = await assembleContext() - resetInactivityTimer() const isMomputer = (userMsg.text || "") .toLowerCase() .includes("@momputer") + + // Which llm:skill packs apply to this run: skills whose datatypes + // match the focused document, skills enabled on the chat by id, and + // @momputer forcing its persona skill. Resolved before the prompt so + // their instructions/tools are in place for every round. + skillsForRun = [] + try { + let focusedType: string | undefined + const turl = props.targetDocUrl?.() + if (turl) { + try { + focusedType = ((await repo.find(turl)).doc() as any)?.[ + "@patchwork" + ]?.type + } catch {} + } + skillsForRun = await resolveActiveSkills({ + focusedType, + enabledIds: activeFeatures(), + forcedIds: isMomputer ? ["momputer"] : undefined, + }) + } catch (e) { + console.warn("[Computer] skill resolution failed:", e) + } + + const context = await assembleContext() + resetInactivityTimer() // Generate a tool name for this response — the LLM uses it if it builds a tool const suggestedToolName = randomToolName() // The built-in COMPUTER_SYSTEM_PROMPT is the *default* — but if the user @@ -2466,10 +2618,10 @@ Never overwrite an entire long field with a key-assign (range:"content") just to '## Your Tool ID\nIf you build a patchwork tool in this response, use `"' + suggestedToolName + '"` as the id for both the datatype and tool plugins, and in supportedDatatypes.' - if (isMomputer) { - systemPrompt += - '\n\n## Special Mode: Momputer\nThe user addressed you as @momputer. Be warm, nurturing, and motherly in your response. 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.' - } + // Skills: active packs' full instructions plus a one-line index of the + // inactive ones. (The momputer persona rides this too, forced above.) + const skillsSection = skillsPromptSection(skillsForRun, listSkills()) + if (skillsSection) systemPrompt += "\n\n" + skillsSection const messages = [...context, {role: "user", content: userMsg.text}] // Create streaming message — use `let` so we can reassign @@ -2482,6 +2634,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to font: isMomputer ? "Comic Sans MS, cursive" : "monospace", streaming: true, replyTo: userMsg.id, + ...agentMessageMetadata(props.handle.doc()), } currentStreamHandle = await repo.create2(streamMsgData) // Resolve through repo.find so that on a draft our streaming writes @@ -2522,7 +2675,11 @@ Never overwrite an entire long field with a key-assign (range:"content") just to setLlmStatus(status.replace(/think(ing)?/gi, "computing")) } - const MAX_TOOL_ROUNDS = 5 + // Generous: document editing legitimately takes many rounds (e.g. a + // CatColab model is two ops per cell plus reads and verification). + // Runaway loops are still bounded by this, the inactivity timeout, + // and the user's stop button. + const MAX_TOOL_ROUNDS = 20 let madeChanges = false let completedResponse = false for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { @@ -2633,7 +2790,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to // display-only — it's already been rendered above). for (const c of calls) { if (c.name === "ask_user") continue - await runToolByName(c.name, c.args) + noteToolRun(c.name, c.args, await runToolByName(c.name, c.args)) } completedResponse = true break @@ -2643,6 +2800,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to let toolResults = "" for (const c of calls) { const result = await runToolByName(c.name, c.args) + noteToolRun(c.name, c.args, result) resetInactivityTimer() toolResults += "\n[Tool result for " + c.name + "]\n" + result + "\n" @@ -2733,6 +2891,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to isComputer: true, font: "monospace", streaming: true, + ...agentMessageMetadata(props.handle.doc()), } currentStreamHandle = await repo.create2(nextMsgData) // See note above: resolve via repo.find so streaming writes @@ -2792,6 +2951,19 @@ Never overwrite an entire long field with a key-assign (range:"content") just to d.streaming = false }) } + + // Agent-draft flow: name the draft after what this run changed. + if ( + props.agentDraft && + editedDocsThisRun && + !abortController.signal.aborted + ) { + setLlmStatus("naming the draft") + agentDraftSummary = await generateDraftSummary( + messages, + abortController.signal + ) + } } catch (err: any) { if (currentStreamHandle) { try { @@ -2823,6 +2995,16 @@ Never overwrite an entire long field with a key-assign (range:"content") just to } computerResponding = false setLlmStatus("") + // Agent-draft flow: the run is over — the wrapper renames the draft + // after the summary and posts the accept/reject review embed when the + // run actually edited docs. + if (props.agentDraft) { + Promise.resolve( + props.agentDraft.onRunEnd(editedDocsThisRun, agentDraftSummary) + ).catch((e) => + console.warn("[Computer] agent draft finish failed:", e) + ) + } } } 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..8df405d0 100644 --- a/chat/src/components/RichBlockView.tsx +++ b/chat/src/components/RichBlockView.tsx @@ -1,18 +1,150 @@ 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, +} 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"} + + }> + + + + + +
+ ) +} + function RichBlockView(props: {block: RichBlock}) { const {isLightBg} = useTheme() const [open, setOpen] = createSignal(false) 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-drafts.ts b/chat/src/lib/agent-drafts.ts new file mode 100644 index 00000000..5e8abf7b --- /dev/null +++ b/chat/src/lib/agent-drafts.ts @@ -0,0 +1,440 @@ +// 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 { + 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 +} + +export type DraftDoc = { + "@patchwork": {type: "draft"} + isMain?: boolean + name?: string + parent: AutomergeUrl + drafts: AutomergeUrl[] + clones: Record + mergedAt?: number + 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, the original otherwise (the main + * draft's identity clones make those the same, so an agent draft merges into + * the originals) — then mark the draft merged (which hides it from the drafts + * sidebar). 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 parentClones = parentHandle?.doc()?.clones ?? {} + const entries = Object.entries(doc?.clones ?? {}) as [ + AutomergeUrl, + CloneEntry, + ][] + for (const [originalUrl, entry] of entries) { + const targetUrl = parentClones[originalUrl]?.cloneUrl ?? originalUrl + if (entry.cloneUrl === targetUrl) continue + const [target, clone] = await Promise.all([ + repo.find(targetUrl), + repo.find(entry.cloneUrl), + ]) + target.merge(clone) + const mergedAt = target.heads() + draftHandle.change((d) => { + const e = d.clones[originalUrl] + if (e && mergedAt) e.mergedAt = mergedAt + }) + } + draftHandle.change((d) => { + d.mergedAt = Date.now() + }) + + 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. */ +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..73742672 --- /dev/null +++ b/chat/src/lib/llm-skills.ts @@ -0,0 +1,236 @@ +// `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() + +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 activates when the focused document matches it, or via `/plugin load `):\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/styles/chat.css b/chat/src/styles/chat.css index c8879f3d..ddccbc1a 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) */ @@ -346,6 +346,35 @@ 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; } .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; } @@ -1298,3 +1327,60 @@ /* 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); } + /* Deploy marker: tells at a glance whether the synced bundle is current. */ + .agent-version { + margin-left:auto; 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..bdbbeb9e --- /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.1" 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 From a8c68f6cdfeda3fbd80791ede69972ec4bc3a63a Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Mon, 17 Aug 2026 15:07:09 +0200 Subject: [PATCH 02/21] add copy-chat-history button to agent chat --- chat/src/agent-tool.tsx | 46 ++++++++- chat/src/lib/svg-icons.ts | 3 + chat/src/lib/transcript.test.ts | 66 ++++++++++++ chat/src/lib/transcript.ts | 176 ++++++++++++++++++++++++++++++++ chat/src/styles/chat.css | 16 ++- chat/src/version.ts | 2 +- 6 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 chat/src/lib/transcript.test.ts create mode 100644 chat/src/lib/transcript.ts diff --git a/chat/src/agent-tool.tsx b/chat/src/agent-tool.tsx index 52cba1b8..4b8c2169 100644 --- a/chat/src/agent-tool.tsx +++ b/chat/src/agent-tool.tsx @@ -43,6 +43,8 @@ import {CHAT_VERSION} from "./version" import {selectedDocUrl, 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, @@ -223,8 +225,14 @@ function AgentHost(props: {element: HTMLElement; repo: Repo}) { onClick={addChat}> + - - {CHAT_VERSION} + + + + {CHAT_VERSION} +
@@ -547,3 +555,37 @@ function ChatTab(props: { ) } + +/** 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/SkillsDebug.tsx b/chat/src/components/SkillsDebug.tsx new file mode 100644 index 00000000..11402f69 --- /dev/null +++ b/chat/src/components/SkillsDebug.tsx @@ -0,0 +1,103 @@ +// 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" + +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 + 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/lib/llm-skills.ts b/chat/src/lib/llm-skills.ts index 73742672..7cc926dd 100644 --- a/chat/src/lib/llm-skills.ts +++ b/chat/src/lib/llm-skills.ts @@ -109,6 +109,35 @@ export async function resolveActiveSkills(opts: { // 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 { @@ -156,7 +185,7 @@ export function skillsPromptSection( } if (inactive.length > 0) { parts.push( - "Installed but NOT active (a skill activates when the focused document matches it, or via `/plugin load `):\n" + + "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") diff --git a/chat/src/styles/chat.css b/chat/src/styles/chat.css index 9aff82b6..2d20ce79 100644 --- a/chat/src/styles/chat.css +++ b/chat/src/styles/chat.css @@ -669,6 +669,52 @@ 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-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 */ From 9702e15e5a6139c8e58a34464ab0a67e85ecc338 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 25 Aug 2026 15:00:30 +0200 Subject: [PATCH 08/21] move provenance rendering into a corkboard-shipped codemirror extension codemirror-base no longer knows about provenance: `codemirror:extension` modules may now be factories receiving {handle, element, repo}, and the corkboard registers one that decorates provenance source ranges and pushes their linked targets into the shared focus selection. --- codemirror-base/src/tool.tsx | 153 +++------------- corkboard/package.json | 2 + corkboard/pnpm-lock.yaml | 43 +++++ corkboard/src/codemirror-provenance.ts | 241 +++++++++++++++++++++++++ corkboard/src/index.ts | 16 ++ 5 files changed, 325 insertions(+), 130 deletions(-) create mode 100644 corkboard/src/codemirror-provenance.ts diff --git a/codemirror-base/src/tool.tsx b/codemirror-base/src/tool.tsx index 4a847aea..39ac3c67 100644 --- a/codemirror-base/src/tool.tsx +++ b/codemirror-base/src/tool.tsx @@ -42,23 +42,6 @@ type CommentEntry = { threadUrl: AutomergeUrl; }; -// Pushed by the provenance provider (see the corkboard tool): one flattened -// (source, target) pair per stored `@provenance` entry. Links are stored in -// the GENERATED doc, so this editor can only learn that its ranges are -// provenance sources through the provider. -type ProvenanceLink = { - sourceUrl: AutomergeUrl; - targetUrl: AutomergeUrl; - entryUrl: AutomergeUrl; -}; - -// A provenance source range in THIS doc, resolved to a live ref handle, with -// the target refs (in other docs) it links to. -type ProvenanceSource = { - handle: DocHandle; - targetUrls: AutomergeUrl[]; -}; - // Diff baseline served by the draft overlay (`draft:baseline`). `heads` is // `null` when there is no baseline yet (e.g. the doc hasn't been COW'd in the // active draft, or "main" is selected), in which case no diff is rendered. @@ -82,15 +65,6 @@ export function CodeMirrorEditor(props: PatchworkToolProps) { [] ); - // Provenance links touching this doc. Answered only when a provenance - // provider is mounted above (e.g. inside the corkboard tool); elsewhere the - // subscription stays at its default and nothing renders. - const provenanceLinks = subscribe( - props.element, - { type: "patchwork:provenance", url: props.handle.url }, - [] - ); - const [focusDoc, focusHandle] = subscribeDoc<{ selection: Record; highlight: Record; @@ -124,15 +98,6 @@ export function CodeMirrorEditor(props: PatchworkToolProps) { { initialValue: [] } ); - // Ranges in this doc that other documents were generated from, each with - // the target refs it links to (used to push those targets into the shared - // selection when the cursor lands on the range). - const [provenanceSources] = createResource( - provenanceLinks, - (links) => resolveProvenanceSources(links, props.handle.url, props.repo), - { initialValue: [] } - ); - // Bounding range of the focused targets (selection ∪ highlight). Recomputes // when the targets change -- e.g. selecting a comment thread elsewhere -- so // the editor can scroll the freshly focused region into view. Positions are @@ -150,49 +115,22 @@ export function CodeMirrorEditor(props: PatchworkToolProps) { return from <= to ? [from, to] : null; }); - // Target refs (in other docs) of every provenance source range that - // overlaps [from, to]. Selecting provenance-annotated text pushes these - // into the shared selection, so views of the generated doc can highlight - // what came from the text under the cursor. - const provenanceTargetsInRange = ( - from: number, - to: number - ): AutomergeUrl[] => { - const targets = new Set(); - for (const source of provenanceSources()) { - 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]; - }; - - let lastEmittedKey: string | undefined; + let lastEmittedUrl: AutomergeUrl | undefined; const onChangeSelection = (from: number, to: number) => { const handle = focusHandle(); if (!handle) return; const nextUrl = props.handle.sub(...PATH, cursor(from, to)).url; - const provenanceTargets = provenanceTargetsInRange(from, to); - const nextKey = [nextUrl, ...provenanceTargets].join("|"); - if (nextKey === lastEmittedKey) return; + if (nextUrl === lastEmittedUrl) return; handle.change((doc) => { - const next: Record = { [nextUrl]: true }; - for (const url of provenanceTargets) next[url] = true; - doc.selection = next; + doc.selection = { [nextUrl]: true }; }); - lastEmittedKey = nextKey; + lastEmittedUrl = nextUrl; }; const decorations = () => { - const emphasisRefs = emphasisTargets(); return RangeSet.of( - [ - ...buildCommentDecorations(commentTargets(), emphasisRefs), - ...buildProvenanceDecorations(provenanceSources(), emphasisRefs), - ], + buildCommentDecorations(commentTargets(), emphasisTargets()), true // sort ranges ); }; @@ -304,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) @@ -376,31 +314,6 @@ async function getDedupedCommentTargets( return Array.from(overlappingRefs); } -// Scopes provenance links to the ones whose SOURCE lives in this doc, -// dedupes by source ref, and resolves each source to a live handle, keeping -// the target urls it links to. -async function resolveProvenanceSources( - links: ProvenanceLink[], - docUrl: AutomergeUrl, - repo: Repo -): Promise { - const targetsBySource = new Map>(); - for (const link of links) { - if (!link.sourceUrl.startsWith(docUrl)) 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 repo.find(sourceUrl), - targetUrls: [...targets], - }); - } - return sources; -} - // Scopes ref urls to this doc and resolves each one to a `DocHandle`. // Used for both our own `selection` and other views' `highlight`. async function resolveSubDocUrlsOfDoc( @@ -440,41 +353,6 @@ function buildCommentDecorations( return out; } -// Provenance source ranges — text that another document was generated from — -// render like comment targets but in the link accent with a dotted underline, -// so the two kinds of annotation stay distinguishable. Emphasised (the focus -// selection/highlight overlaps the range) draws the stronger tint. -function buildProvenanceDecorations( - sources: ProvenanceSource[], - emphasisRefs: DocHandle[] -): Range[] { - const out: Range[] = []; - for (const source of sources) { - const positions = source.handle.rangePositions(); - if (!positions) continue; - const [start, end] = positions; - if (start === end) continue; - const isEmphasised = emphasisRefs.some((s) => s.overlaps(source.handle)); - out.push( - Decoration.mark({ - attributes: { style: provenanceSourceStyle(isEmphasised) }, - }).range(start, end) - ); - } - return out; -} - -function provenanceSourceStyle(isEmphasised: boolean): string { - // Same highlighter-over-paper scheme as `commentTargetStyle`, 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". - 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}); - `; -} - function commentTargetStyle(isEmphasised: boolean): string { // Highlighter-over-paper: tint the editor surface (--text-editor-fill) with the // secondary accent and leave the text in the editor's own ink (--text-editor-line, @@ -492,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"); @@ -506,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/corkboard/package.json b/corkboard/package.json index 8e360b6d..0aec8bb9 100644 --- a/corkboard/package.json +++ b/corkboard/package.json @@ -15,6 +15,8 @@ "dependencies": { "@automerge/automerge": "3.3.0-fragments.1", "@automerge/automerge-repo": "^2.6.0-subduction.26", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.38.4", "@inkandswitch/patchwork-elements": "1.0.0", "@inkandswitch/patchwork-plugins": "^0.0.11", "@inkandswitch/patchwork-providers": "0.3.0", diff --git a/corkboard/pnpm-lock.yaml b/corkboard/pnpm-lock.yaml index d97d43dd..6e3bbe5f 100644 --- a/corkboard/pnpm-lock.yaml +++ b/corkboard/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@automerge/automerge-repo': specifier: ^2.6.0-subduction.26 version: 2.6.0-subduction.48 + '@codemirror/state': + specifier: ^6.5.2 + version: 6.7.1 + '@codemirror/view': + specifier: ^6.38.4 + version: 6.43.9 '@inkandswitch/patchwork-elements': specifier: 1.0.0 version: 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) @@ -208,6 +214,12 @@ packages: 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'} @@ -448,6 +460,9 @@ packages: '@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} @@ -730,6 +745,9 @@ packages: 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==} @@ -933,6 +951,9 @@ packages: 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'} @@ -1073,6 +1094,9 @@ packages: 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'} @@ -1379,6 +1403,17 @@ snapshots: '@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 @@ -1560,6 +1595,8 @@ snapshots: '@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 @@ -1806,6 +1843,8 @@ snapshots: convert-source-map@2.0.0: {} + crelt@1.0.7: {} + csstype@3.2.3: {} debug@4.4.3: @@ -2009,6 +2048,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + style-mod@4.1.3: {} + tinyargs@0.1.4: {} tinybench@2.9.0: {} @@ -2135,6 +2176,8 @@ snapshots: - tsx - yaml + w3c-keyname@2.2.8: {} + webidl-conversions@7.0.0: {} whatwg-mimetype@3.0.0: {} diff --git a/corkboard/src/codemirror-provenance.ts b/corkboard/src/codemirror-provenance.ts new file mode 100644 index 00000000..82542c96 --- /dev/null +++ b/corkboard/src/codemirror-provenance.ts @@ -0,0 +1,241 @@ +// 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 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. + 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.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) + ); + 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/index.ts b/corkboard/src/index.ts index 56ce4bd6..a6c5a616 100644 --- a/corkboard/src/index.ts +++ b/corkboard/src/index.ts @@ -27,6 +27,22 @@ export const plugins: Plugin[] = [ 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; + }, + }, // 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. Auto-activates when a tldraw5 doc is focused. From 80e4f7da25eece1068098b23baa708a1fbc93a52 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 25 Aug 2026 15:00:30 +0200 Subject: [PATCH 09/21] chat: add make_ref tool for granular ref urls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model can now mint edit-stable refs — an element by path/{id} matcher, or a text range by cursor-anchored offsets — so provenance entries can point at specific passages and elements instead of whole documents. Bumps the debug panel version to v0.0.2. --- chat/src/components/ChatRoot.tsx | 71 +++++++++++++++++++++++++++++ chat/src/components/SkillsDebug.tsx | 5 ++ chat/src/styles/chat.css | 1 + 3 files changed, 77 insertions(+) diff --git a/chat/src/components/ChatRoot.tsx b/chat/src/components/ChatRoot.tsx index 942e5db2..8941e581 100644 --- a/chat/src/components/ChatRoot.tsx +++ b/chat/src/components/ChatRoot.tsx @@ -1,4 +1,5 @@ import {createSignal, createMemo, createEffect, Show, onMount, onCleanup} from "solid-js" +import {cursor as automergeCursor} from "@automerge/automerge-repo/slim" import type {DocHandle, AutomergeUrl} from "@automerge/automerge-repo/slim" import {updateText, splice} from "@automerge/automerge/slim" import {applyAutomerge} from "../lib/automerge-ops" @@ -440,6 +441,7 @@ arg: value - edit_tool {toolId|url, code} — replace a tool's source and reload - inspect_iframe {url} — a pinned tool's DOM + console errors - eval_in_iframe {url, code} — run JS in a pinned tool, get the result +- make_ref {url, path?, from?, to?} — mint a granular ref url into a doc (an element by path/{id}, or a text range by offsets) - load_skill {id} — activate an installed skill (see the Skills index) and get its instructions Rules: ALWAYS read_doc before edit_doc/splice_doc, and re-read the returned value after (peers may have changed it). NEVER change a doc's \`@patchwork.type\`, or a tool's datatype/tool \`id\`/\`supportedDatatypes\` (breaks existing docs). To ask the user something, reply in plain text with NO tool call — tool results are not user answers. @@ -509,6 +511,7 @@ arg: value - edit_tool {toolId|url, code} — replace a tool's source and reload - inspect_iframe {url} — a pinned tool's DOM + console errors - eval_in_iframe {url, code} — run JS in a pinned tool, get the result +- make_ref {url, path?, from?, to?} — mint a granular ref url into a doc (an element by path/{id}, or a text range by offsets) - load_skill {id} — activate an installed skill (see the Skills index) and get its instructions Rules: ALWAYS read_doc before edit_doc/splice_doc, and re-read the returned value after (peers may have changed it). NEVER change a doc's \`@patchwork.type\`, or a tool's datatype/tool \`id\`/\`supportedDatatypes\` (breaks existing docs). To ask the user something, reply in plain text with NO tool call — tool results are not user answers. @@ -670,6 +673,25 @@ Keep responses concise. When you create a tool, explain briefly what it does.` }, } + // Granular references: ref urls (element/{id} paths, cursor-anchored text + // ranges) can only be MINTED by the runtime — their anchors encode opaque + // op ids the model cannot fabricate. This tool is what makes fine-grained + // provenance possible from the generic document tools. + const MAKE_REF_TOOL = { + name: "make_ref", + description: + "Mint a granular automerge REF URL into a document — an edit-stable pointer at one element or text range, for provenance sources/targets and other cross-doc links. Pass `path` (array of keys/indices; a {id:\"…\"} object matches a list element by its id, stable across splices) to point at an element. Pass `from`/`to` (0-based character offsets, e.g. from find_text) to point at a text range; `path` then locates the string field (defaults to [\"content\"]). Returns the ref url as a string.", + parameters: { + type: "object", + properties: { + url: {type: "string", description: "the document (optional in context mode; defaults to the focused doc)"}, + path: {type: "array", items: {}, description: "keys/indices from the doc root; {id:\"…\"} matches a list element by id"}, + from: {type: "number", description: "text range start (character offset)"}, + to: {type: "number", description: "text range end (character offset, exclusive)"}, + }, + }, + } + const COMPUTER_TOOLS: {name: string; description: string; parameters: any}[] = [ {name: "read_doc", description: "Read an Automerge document's full contents.", parameters: {type: "object", properties: {url: {type: "string", description: "automerge: URL"}}, required: ["url"]}}, {name: "edit_doc", description: "Set a field on a document (string fields diff collaboratively). Returns the field's new value.", parameters: {type: "object", properties: {url: {type: "string"}, field: {type: "string"}, value: {description: "new value (JSON)"}}, required: ["url", "field", "value"]}}, @@ -679,6 +701,7 @@ Keep responses concise. When you create a tool, explain briefly what it does.` {name: "edit_tool", description: "Replace an existing tool's source code and reload it. Target by toolId or url.", parameters: {type: "object", properties: {toolId: {type: "string"}, url: {type: "string"}, code: {type: "string"}}, required: ["code"]}}, {name: "inspect_iframe", description: "Get a pinned tool iframe's DOM HTML and console errors.", parameters: {type: "object", properties: {url: {type: "string"}}}}, {name: "eval_in_iframe", description: "Run JS inside a pinned tool's iframe and return the result.", parameters: {type: "object", properties: {url: {type: "string"}, code: {type: "string"}}, required: ["code"]}}, + MAKE_REF_TOOL, LOAD_SKILL_TOOL, ASK_USER_TOOL, DEFINE_TOOL, @@ -716,6 +739,7 @@ Every turn, a [Context] block gives you the focused document: its \`url\`, its c • heads — OPTIONAL. The heads array from read_doc. When given, the edit is applied as a back-dated change (changeAt) relative to that version. Omit for a normal "edit current state" change. • url — OPTIONAL. Edit a different document than the focused one. Returns the affected container's new value so you can verify. +- make_ref {url?, path?, from?, to?} — mint a granular automerge REF URL: an edit-stable pointer at one element (\`path\` with a {id:"…"} object matching a list element by id) or a text range (\`from\`/\`to\` character offsets from find_text; \`path\` locates the string field, default ["content"]). Use these for provenance sources/targets and other cross-doc links — never a bare doc url when a finer ref applies. - load_skill {id} — activate an installed skill by id (see the Skills index in these instructions) and get its full instructions back. Use it BEFORE working on a document type whose skill is installed but not active. - ask_user {question, options?} — ask the user something and PAUSE. Posts your question (with optional clickable choices) and ends your turn; their reply comes back as a new message. Use this instead of guessing when you need a decision or missing detail. - inspect_dom {selector?} — (usually disabled) return the live DOM HTML of the running tool/page, optionally narrowed to a CSS selector. Use to see how the focused doc is actually rendered. @@ -746,6 +770,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to {name: "automerge_op", description: "Apply ONE universal Automerge edit to a doc (defaults to the focused doc). range=[from,to] splices a string field (text — from/to are 0-based CHARACTER offsets, to exclusive, every char incl. newlines counts) or a list; range=key assigns (with value) or deletes (without value) on the map/list at path. Omit value to delete. For text, prefer replace_text/find_text so you don't miscount.", parameters: {type: "object", properties: {path: {type: "array", items: {}, description: "keys/indices from the doc root to the container or string ([]=root)"}, range: {description: "[from,to] for a splice, or a string/number key for assign/delete"}, value: {description: "value to insert/set (JSON); omit to delete"}, heads: {type: "array", items: {type: "string"}, description: "optional heads (from read_doc) → back-dated changeAt"}, url: {type: "string", description: "optional target doc (defaults to the focused doc)"}}, required: ["range"]}}, {name: "inspect_dom", description: "Return the live DOM HTML of the running tool/page (optionally narrowed by a CSS selector). Disabled by default.", parameters: {type: "object", properties: {selector: {type: "string", description: "optional CSS selector to narrow the result"}}}}, {name: "eval_js", description: "Evaluate JavaScript in the page and return the result. Unsandboxed. Disabled by default.", parameters: {type: "object", properties: {code: {type: "string"}}, required: ["code"]}}, + MAKE_REF_TOOL, LOAD_SKILL_TOOL, ASK_USER_TOOL, DEFINE_TOOL, @@ -1296,6 +1321,52 @@ Never overwrite an entire long field with a key-assign (range:"content") just to try { if (toolName === "load_skill") { return await activateSkillForRun(String(args.id || "").trim()) + } else if (toolName === "make_ref") { + const url = args.url || focusedUrl() + if (!url) return "Error: no url and no focused document." + const h = await resolveRunDoc(url) + const parsePath = (v: any) => { + if (Array.isArray(v)) return v + if (typeof v !== "string" || !v.trim()) return [] + try { + const parsed = JSON.parse(v) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } + } + const segments: any[] = parsePath(args.path) + const from = typeof args.from === "number" ? args.from : Number(args.from) + const to = typeof args.to === "number" ? args.to : Number(args.to) + const hasRange = Number.isFinite(from) && Number.isFinite(to) + if (hasRange) { + if (segments.length === 0) segments.push("content") + segments.push(automergeCursor(from, to)) + } + if (segments.length === 0) { + return 'Error: give a path (e.g. ["petriNetDefinition","places",{"id":"…"}]) and/or from/to text offsets.' + } + // The runtime exposes handle.sub (subduction) or handle.ref + // (upstream automerge-repo) — same call shape. + const make = (h as any).sub ?? (h as any).ref + if (typeof make !== "function") { + return "Error: this runtime cannot mint ref urls (no handle.sub/ref)." + } + let refUrl: string + try { + refUrl = make.apply(h, segments).url + } catch (e: any) { + return "Error minting ref: " + (e?.message || String(e)) + } + // Report the ref against the document's OWN url: under an agent + // draft the resolved handle is a clone, and a clone-prefixed url + // stored in a doc would dangle after the draft merges. + const ownBare = String(url).split(/[/#]/)[0] + const mintedBare = refUrl.split(/[/#]/)[0] + if (mintedBare !== ownBare) { + refUrl = ownBare + refUrl.slice(mintedBare.length) + } + return refUrl } else if (toolName === "read_doc") { const url = args.url || (isContext() ? focusedUrl() : undefined) if (!url) return "Error: no url and no focused document." diff --git a/chat/src/components/SkillsDebug.tsx b/chat/src/components/SkillsDebug.tsx index 11402f69..fd73dcbc 100644 --- a/chat/src/components/SkillsDebug.tsx +++ b/chat/src/components/SkillsDebug.tsx @@ -13,6 +13,10 @@ import { type LlmSkillDescription, } from "../lib/llm-skills" +// Bump when shipping a change you want to verify made it to a running client +// (pushwork-synced tools can lag; this shows which build is actually loaded). +const CHAT_VERSION = "v0.0.2" + export function SkillsDebug(props: { /** Skills active for the most recent computer run. */ active: () => ActiveSkill[] @@ -27,6 +31,7 @@ export function SkillsDebug(props: { onToggle={(e) => setOpen((e.currentTarget as HTMLDetailsElement).open)}> debug: skills + {CHAT_VERSION} 0}> {props.active().length} active diff --git a/chat/src/styles/chat.css b/chat/src/styles/chat.css index 2d20ce79..910c0bec 100644 --- a/chat/src/styles/chat.css +++ b/chat/src/styles/chat.css @@ -685,6 +685,7 @@ 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); From b275cb16d9ac2c884b520c3e0eb684586d50997e Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 25 Aug 2026 15:20:18 +0200 Subject: [PATCH 10/21] corkboard: emphasise provenance sources when the focus holds their targets Lets a selection on a Petrinaut canvas light up the source text range, not just the other way round. --- corkboard/src/codemirror-provenance.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/corkboard/src/codemirror-provenance.ts b/corkboard/src/codemirror-provenance.ts index 82542c96..8951355e 100644 --- a/corkboard/src/codemirror-provenance.ts +++ b/corkboard/src/codemirror-provenance.ts @@ -68,6 +68,7 @@ export function provenanceExtension( private view: EditorView; private sources: ProvenanceSource[] = []; private emphasis: DocHandle[] = []; + private focusUrls = new Set(); private focusHandle: DocHandle | undefined; private lastPushedKey = ""; private destroyed = false; @@ -169,7 +170,10 @@ export function provenanceExtension( } // Focus refs (selection ∪ highlight) scoped to this doc, resolved to - // handles so `build` can test overlap against the source ranges. + // 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 = [ @@ -183,6 +187,7 @@ export function provenanceExtension( } } if (this.destroyed) return; + this.focusUrls = new Set(urls); this.emphasis = refs; this.poke(); } @@ -201,9 +206,9 @@ export function provenanceExtension( if (!positions) continue; const [start, end] = positions; if (start === end) continue; - const isEmphasised = this.emphasis.some((ref) => - ref.overlaps(source.handle) - ); + 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) }, From 7b469f5e9aeb194e0e86c7229bcc2be2b9e05612 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Wed, 26 Aug 2026 15:45:35 +0200 Subject: [PATCH 11/21] corkboard: read provenance from @patchwork.provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provenance entries moved under the document's @patchwork envelope (a plain array, no `entries` wrapper) — the provider indexes and ref-urls them from the new path. --- corkboard/src/ProvenanceProvider.ts | 6 +++--- corkboard/src/provenance.ts | 12 +++++++----- corkboard/src/tool.tsx | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/corkboard/src/ProvenanceProvider.ts b/corkboard/src/ProvenanceProvider.ts index 5499e713..3d666712 100644 --- a/corkboard/src/ProvenanceProvider.ts +++ b/corkboard/src/ProvenanceProvider.ts @@ -19,7 +19,7 @@ import { /** * Answers `patchwork:provenance` subscriptions. Watches every doc mounted - * inside it for a `@provenance` section and pushes the resulting + * 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 @@ -188,9 +188,9 @@ export const ProvenanceProvider = (element: PatchworkViewElement) => { handle: DocHandle ): ProvenanceLink[] { const links: ProvenanceLink[] = []; - const entries = handle.doc()?.["@provenance"]?.entries ?? []; + const entries = handle.doc()?.["@patchwork"]?.provenance ?? []; for (const entry of entries) { - const entryUrl = handle.sub("@provenance", "entries", { + const entryUrl = handle.sub("@patchwork", "provenance", { id: entry.id, }).url; for (const targetUrl of entry.targets ?? []) { diff --git a/corkboard/src/provenance.ts b/corkboard/src/provenance.ts index c8b8cf11..2698bfd6 100644 --- a/corkboard/src/provenance.ts +++ b/corkboard/src/provenance.ts @@ -2,13 +2,15 @@ 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, 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 +// 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 = { - "@provenance"?: { - entries: ProvenanceEntry[]; + "@patchwork"?: { + type?: string; + provenance?: ProvenanceEntry[]; }; }; diff --git a/corkboard/src/tool.tsx b/corkboard/src/tool.tsx index c5f0723e..49628c50 100644 --- a/corkboard/src/tool.tsx +++ b/corkboard/src/tool.tsx @@ -16,8 +16,8 @@ import { render } from "solid-js/web"; // 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 -// `@provenance` sections and answer `patchwork:provenance` subscriptions -// from any of them (see `ProvenanceProvider`). +// `@patchwork.provenance` sections and answer `patchwork:provenance` +// subscriptions from any of them (see `ProvenanceProvider`). const mount: ToolImplementation = (handle, element) => render(() => , element); From 18fdc13d376b1d4b47527d1e5c1f0918446ba263 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Wed, 26 Aug 2026 17:02:23 +0200 Subject: [PATCH 12/21] chat: multi-scenario agent runs on separate branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent's computer gets a start_scenario tool: called before each alternative's edits, it forks a fresh draft off main and re-aims the run's doc resolution at it, so one sequential run can produce several independent scenarios. Runs that opened scenarios end with a picker embed instead of the single accept/reject — chips check each branch out (with diff baselines) for browsing back and forth, accept merges the selected scenario and unlinks the rest, reject-all discards everything. --- chat/src/agent-tool.tsx | 133 ++++++++++++++- chat/src/components/ChatRoot.tsx | 57 ++++++- chat/src/components/RichBlockView.tsx | 223 +++++++++++++++++++++++++- chat/src/styles/chat.css | 26 +++ chat/src/version.ts | 2 +- 5 files changed, 435 insertions(+), 6 deletions(-) diff --git a/chat/src/agent-tool.tsx b/chat/src/agent-tool.tsx index 4b8c2169..619c1023 100644 --- a/chat/src/agent-tool.tsx +++ b/chat/src/agent-tool.tsx @@ -57,6 +57,7 @@ import { rawRepo, resolveAgentChatsIndex, createAgentDraft, + rejectAgentDraft, checkedOutDraftHandle, checkoutDraft, checkoutAgentDraft, @@ -376,7 +377,13 @@ function ChatPane(props: { // 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()) { @@ -389,6 +396,58 @@ function ChatPane(props: { // 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. */ @@ -400,7 +459,9 @@ function ChatPane(props: { const onRunEnd = async (edited: boolean, summary?: string) => { try { - if (edited) { + if (runScenarios.length > 0) { + await finishScenarios() + } else if (edited) { if (summary) await renameDraft(summary) await postDraftReview() } @@ -429,6 +490,68 @@ function ChatPane(props: { }) } + /** 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() @@ -468,7 +591,13 @@ function ChatPane(props: { element={props.element} mode="context" targetDocUrl={props.targetUrl} - agentDraft={{onRunStart, onRunEnd, resolveDoc}} + agentDraft={{ + onRunStart, + onRunEnd, + resolveDoc, + startScenario, + hasScenarios: () => runScenarios.length > 0, + }} /> )} diff --git a/chat/src/components/ChatRoot.tsx b/chat/src/components/ChatRoot.tsx index 8941e581..ec2bb4d6 100644 --- a/chat/src/components/ChatRoot.tsx +++ b/chat/src/components/ChatRoot.tsx @@ -67,10 +67,18 @@ export function ChatRoot(props: { // doc-editing tool actually ran (true → the wrapper posts the // accept/reject review embed) and, when edits happened, an LLM-written // one-line summary of them (used as the draft's name). + // startScenario (optional) opens a NEW draft branch mid-run and re-aims + // resolveDoc at it — the sequential multi-scenario flow: the model calls + // the start_scenario tool before each alternative's edits, and the wrapper + // posts a scenario picker instead of the single review embed. + // hasScenarios tells the run whether scenarios were opened (so it can skip + // the single-draft naming step). agentDraft?: { onRunStart: () => Promise onRunEnd: (edited: boolean, summary?: string) => void | Promise resolveDoc: (url: string) => Promise + startScenario?: (name: string) => Promise + hasScenarios?: () => boolean } // Optional selector OVERRIDE (the embeddable component's `features=` attr). // When absent, the active feature set is driven by the document's `plugins` @@ -598,11 +606,27 @@ You can include \`\`\`file blocks to create and embed files, \`\`\`embed blocks Keep responses concise. When you create a tool, explain briefly what it does.` + // Offered only when the host provides startScenario (the Agent tool): + // the model's way to produce several alternative versions on separate + // branches, browsed and decided in the scenario picker afterwards. + const SCENARIOS_PROMPT_SECTION = ` + +## Scenarios (alternative versions) +When the user asks for multiple alternatives/options/scenarios ("show me three variants…"), put each one on its own branch: +1. Call start_scenario {name} BEFORE the first edit of each alternative. It opens a fresh branch forked from the document's base state; ALL subsequent edits land on it until the next start_scenario. +2. Scenarios are independent — each forks from the same base, NOT from the previous scenario. Repeat any shared setup in every scenario. +3. Give each a short, descriptive name ("Conservative", "Aggressive timeline"). +4. After the last scenario's edits, briefly summarize how they differ in your reply. The user gets a picker to browse the branches and accept ONE (or reject all) — don't ask which they prefer; they decide there. +Do NOT call start_scenario for ordinary single-outcome edits.` + // Pick the prompt for the active model: local/in-browser models (small, // limited context) get the compact prompt; capable cloud providers get the // full one with worked examples. Falls back to full on any read error. function computerSystemPrompt(): string { - if (isContext()) return CONTEXT_SYSTEM_PROMPT + if (isContext()) + return props.agentDraft?.startScenario + ? CONTEXT_SYSTEM_PROMPT + SCENARIOS_PROMPT_SECTION + : CONTEXT_SYSTEM_PROMPT try { const cfg = scopedCfg() return cfg?.provider === "local" @@ -692,6 +716,25 @@ Keep responses concise. When you create a tool, explain briefly what it does.` }, } + // Multi-scenario runs (Agent tool only — needs the drafts machinery): each + // call re-aims the run's edits at a fresh branch. Included in activeTools + // only when the host wires up agentDraft.startScenario. + const START_SCENARIO_TOOL = { + name: "start_scenario", + description: + "Open a new scenario branch and aim ALL your subsequent document edits at it, until the next start_scenario call. Each scenario forks from the document's base state, independent of the other scenarios. Use ONLY when producing multiple alternative versions for the user to choose between — call it before each alternative's first edit; the user picks between the finished scenarios afterwards.", + parameters: { + type: "object", + properties: { + name: { + type: "string", + description: "short descriptive name for this scenario", + }, + }, + required: ["name"], + }, + } + const COMPUTER_TOOLS: {name: string; description: string; parameters: any}[] = [ {name: "read_doc", description: "Read an Automerge document's full contents.", parameters: {type: "object", properties: {url: {type: "string", description: "automerge: URL"}}, required: ["url"]}}, {name: "edit_doc", description: "Set a field on a document (string fields diff collaboratively). Returns the field's new value.", parameters: {type: "object", properties: {url: {type: "string"}, field: {type: "string"}, value: {description: "new value (JSON)"}}, required: ["url", "field", "value"]}}, @@ -808,6 +851,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to function activeTools() { const base = [ ...(isContext() ? CONTEXT_TOOLS : COMPUTER_TOOLS), + ...(props.agentDraft?.startScenario ? [START_SCENARIO_TOOL] : []), ...customTools(), ] const taken = new Set(base.map((t) => t.name)) @@ -1321,6 +1365,12 @@ Never overwrite an entire long field with a key-assign (range:"content") just to try { if (toolName === "load_skill") { return await activateSkillForRun(String(args.id || "").trim()) + } else if (toolName === "start_scenario") { + if (!props.agentDraft?.startScenario) { + return "Error: scenarios are not available in this chat." + } + const name = String(args.name || "").trim() || "Scenario" + return await props.agentDraft.startScenario(name) } else if (toolName === "make_ref") { const url = args.url || focusedUrl() if (!url) return "Error: no url and no focused document." @@ -3110,10 +3160,13 @@ Never overwrite an entire long field with a key-assign (range:"content") just to } // Agent-draft flow: name the draft after what this run changed. + // Skipped on scenario runs — each scenario was already named by + // the model, and the picker replaces the single review embed. if ( props.agentDraft && editedDocsThisRun && - !abortController.signal.aborted + !abortController.signal.aborted && + !props.agentDraft.hasScenarios?.() ) { setLlmStatus("naming the draft") agentDraftSummary = await generateDraftSummary( diff --git a/chat/src/components/RichBlockView.tsx b/chat/src/components/RichBlockView.tsx index 8df405d0..df024d2f 100644 --- a/chat/src/components/RichBlockView.tsx +++ b/chat/src/components/RichBlockView.tsx @@ -11,6 +11,7 @@ import { rejectAgentDraft, checkedOutDraftHandle, checkoutDraft, + checkoutAgentDraft, } from "../lib/agent-drafts" export function RichBlockList(props: { @@ -25,7 +26,16 @@ export function RichBlockList(props: { {(block) => ( }> + fallback={ + }> + + + }> )} @@ -145,6 +155,217 @@ function DraftReviewBlock(props: { ) } +/** 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/styles/chat.css b/chat/src/styles/chat.css index 910c0bec..dfdaa923 100644 --- a/chat/src/styles/chat.css +++ b/chat/src/styles/chat.css @@ -376,6 +376,32 @@ } .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; } diff --git a/chat/src/version.ts b/chat/src/version.ts index 8953291e..c36fa9a1 100644 --- a/chat/src/version.ts +++ b/chat/src/version.ts @@ -1,3 +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.4" +export const CHAT_VERSION = "v0.0.5" From 72cb771b85110aacefa14c57eea212ceda6820a0 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Wed, 26 Aug 2026 17:31:29 +0200 Subject: [PATCH 13/21] exclude static-dist from watch's pnpm -r sweeps On pnpm >= 11 a workspace-less `pnpm -r` recurses into every directory with a package.json, including the aggregated copies in static-dist/packages/. Those keep their dev/build scripts but have no sources or node_modules, so they all fail and their exit code tears down the whole watch. --- scripts/watch.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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) => { From 136fa18dc799c74faf6f7152b2750f8732de160b Mon Sep 17 00:00:00 2001 From: Mimi Reyburn Date: Wed, 26 Aug 2026 17:06:58 +0100 Subject: [PATCH 14/21] llm skill to create new docs in tldraw - add get datatypes tool and skill - add create embed of datatype to existing corkboard skill --- corkboard/src/datatypes.ts | 52 +++++++++ corkboard/src/embed-placement.ts | 128 +++++++++++++++++++++ corkboard/src/index.ts | 19 +++- corkboard/src/llm-skill-datatypes.ts | 48 ++++++++ corkboard/src/llm-skill.ts | 164 ++++++++++++++++++++++++++- 5 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 corkboard/src/datatypes.ts create mode 100644 corkboard/src/embed-placement.ts create mode 100644 corkboard/src/llm-skill-datatypes.ts 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 index a6c5a616..c05c1bf0 100644 --- a/corkboard/src/index.ts +++ b/corkboard/src/index.ts @@ -43,9 +43,26 @@ export const plugins: Plugin[] = [ 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. Auto-activates when a tldraw5 doc is focused. + // 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", 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 index 7f2053af..55ad4c3c 100644 --- a/corkboard/src/llm-skill.ts +++ b/corkboard/src/llm-skill.ts @@ -2,6 +2,35 @@ // 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, @@ -103,8 +132,25 @@ its own tool. \`docUrl\` is an "automerge:…" url, \`docType\` its datatype id, { ..., "type": "patchwork-doc", "props": { "w": 640, "h": 480, "docUrl": "automerge:", "docName": "Notes", "docType": "", "toolId": "" } } -Only reference documents that already exist (a url the user gave you, or one -from \`docs\` / another shape). You cannot create a Patchwork document from here. +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, @@ -181,6 +227,120 @@ target's edge midpoint. 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); + }, }; From 7014aa2214d7b452b4f20c35706e5deb7ad61b10 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Thu, 27 Aug 2026 13:09:11 +0200 Subject: [PATCH 15/21] csv: provenance-aware CSV viewer for file docs, as a standalone package --- csv/.gitignore | 3 + csv/package.json | 30 + csv/pnpm-lock.yaml | 2118 +++++++++++++++++++++++++++++++++++++ csv/pnpm-workspace.yaml | 9 + csv/src/index.ts | 18 + csv/src/parse-csv.test.ts | 95 ++ csv/src/parse-csv.ts | 102 ++ csv/src/styles.css | 61 ++ csv/src/tool.tsx | 328 ++++++ csv/tsconfig.json | 17 + csv/vite.config.ts | 27 + csv/vitest.config.ts | 14 + 12 files changed, 2822 insertions(+) create mode 100644 csv/.gitignore create mode 100644 csv/package.json create mode 100644 csv/pnpm-lock.yaml create mode 100644 csv/pnpm-workspace.yaml create mode 100644 csv/src/index.ts create mode 100644 csv/src/parse-csv.test.ts create mode 100644 csv/src/parse-csv.ts create mode 100644 csv/src/styles.css create mode 100644 csv/src/tool.tsx create mode 100644 csv/tsconfig.json create mode 100644 csv/vite.config.ts create mode 100644 csv/vitest.config.ts 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"], + }, +}); From 79217bf7e5b9767eb8c2c9aad38cb44967bf8b84 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Mon, 31 Aug 2026 16:57:24 +0200 Subject: [PATCH 16/21] drafts: attribute merged drafts to dedicated timeline groups Record merge provenance on the DraftDoc (mergedFrom per clone, mergedInto) and walk the change DAG between fork and merge heads to pull a merged draft's changes out of the time-based grouping into one labelled group. Scrub boundaries now resolve from the rendered row order instead of raw timestamps (merged changes interleave in time), and the baseline handle renders as the boundary below its change so a selected group reads as a full band. Merging adopts members the target never forked, keeping their changes scoped to the target draft. --- drafts/src/DraftsSidebar.tsx | 510 +++++++++++++++++---- drafts/src/change-group-cache.ts | 116 ++++- drafts/src/draft-types.ts | 31 +- drafts/src/merge-attribution.test.ts | 333 ++++++++++++++ drafts/src/merge-attribution.ts | 127 +++++ drafts/src/providers/DraftStateProvider.ts | 32 ++ drafts/src/styles.css | 14 + 7 files changed, 1057 insertions(+), 106 deletions(-) create mode 100644 drafts/src/merge-attribution.test.ts create mode 100644 drafts/src/merge-attribution.ts diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index d9a34bf5..6ddfafa5 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -44,6 +44,7 @@ import { getDocCreationTime, sameHeads, } 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 +64,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.46"; +const DRAFTS_VERSION = "0.0.49"; // Logged at module load so the console shows which build is running even // before the panel renders. @@ -202,7 +203,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 +317,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 +412,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; @@ -677,39 +691,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 @@ -1660,17 +1713,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 +1743,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 @@ -1703,6 +1770,10 @@ type ScanChange = { seq: number; }; +// 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; + // Renders a draft's (or main's) timeline straight from its ChangeGroupDoc. // The ChangeGrouper computes and persists activity groups (newest first, older // history backfilling), and this component is a pure reader: it paints before @@ -1819,14 +1890,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 +1909,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 +1950,148 @@ 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; + out.push({ hash: meta.hash, time: meta.time, deps: meta.deps, seq }); + }); + return out; + }; + + // 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 +2109,7 @@ function DraftChangesList(props: { time: group.endTime, }, groupStartTime: group.startTime, + memberHeads: boundaryHeads(group, 0, true) ?? undefined, }; } const rows = resolveGroupChanges(group); @@ -1894,6 +2120,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 +2173,7 @@ function DraftChangesList(props: { groupId: group.id, offset: head.offset, time: head.head.time, + memberHeads: boundaryHeads(group, head.offset, false) ?? undefined, }); } } @@ -1962,6 +2190,8 @@ function DraftChangesList(props: { groupId: group.id, offset: BASELINE_GROUP_START, time: group.startTime, + memberHeads: + boundaryHeads(group, BASELINE_GROUP_START, false) ?? undefined, }); } scrubTo(group, 0); @@ -1974,10 +2204,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 +2219,7 @@ function DraftChangesList(props: { groupId: group.id, offset, time: timeAt(group, offset), + memberHeads: boundaryHeads(group, offset, false) ?? undefined, }); }; @@ -2042,12 +2277,25 @@ function DraftChangesList(props: { // 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. const yForPosition = (band: Band, offset: number): number => { 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. + const yForBoundary = (band: Band, offset: number): number => { + 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). const positionForY = ( @@ -2073,6 +2321,32 @@ 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 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), + }; + }; + // 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 @@ -2100,7 +2374,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 +2444,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; @@ -2363,9 +2637,10 @@ 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; @@ -2380,6 +2655,20 @@ function TimeGroupRow(props: { onClick={props.onSelect} > + + {(merge) => ( + + {merge().name ? `Merged "${merge().name}"` : "Merged draft"} + + )} + {formatTime(props.group.endTime)} { 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 +2811,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 +2839,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/change-group-cache.ts b/drafts/src/change-group-cache.ts index d71187db..f988e725 100644 --- a/drafts/src/change-group-cache.ts +++ b/drafts/src/change-group-cache.ts @@ -16,6 +16,7 @@ import type { 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; @@ -44,6 +45,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 = { @@ -268,6 +273,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` @@ -438,6 +449,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 +458,7 @@ export function createChangeGrouper( task.spec = spec; } void ensureListeners(task); - if (membersChanged) schedule(key); + if (membersChanged || mergesChanged) schedule(key); } } @@ -475,6 +488,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 +622,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 +647,7 @@ export function createChangeGrouper( collectMemberRows(tails, member, doc, since, createdAt); } - if (tails.length === 0) { + 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 +666,22 @@ 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. + // 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 || @@ -664,6 +701,7 @@ export function createChangeGrouper( sources, createdAt, frontier, + spec.mergedDrafts, isAborted ); } @@ -684,8 +722,11 @@ export function createChangeGrouper( const oldestGroupOldestMs = oldestGroup[oldestGroup.length - 1].time * 1000; // The oldest run of new changes merges into the stored group when no lull - // separates them (it may even start inside the stored span). + // separates them (it may even start inside the stored span) — 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 && oldestGroupOldestMs <= newestStored.endTime * 1000 + INACTIVITY_GAP_MS; const freshGroups = attaches ? tailGroups.slice(0, -1) : tailGroups; @@ -777,18 +818,20 @@ export function createChangeGrouper( 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, frontier: Record, + mergedDrafts: MergedDraftSpec[], isAborted: () => boolean ): Promise { const rows: PendingChange[] = []; @@ -797,7 +840,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); const expectedIds = new Set(groupsRows.map(groupId)); const batch: ChangeGroup[] = []; @@ -810,6 +854,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); + 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]; @@ -835,6 +911,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/draft-types.ts b/drafts/src/draft-types.ts index 2b6c17c1..bb882b09 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). @@ -77,6 +90,17 @@ export type ChangeGroup = { 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 +115,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/providers/DraftStateProvider.ts b/drafts/src/providers/DraftStateProvider.ts index 6580e807..e9d19136 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, @@ -383,6 +384,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; @@ -398,11 +400,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..8b734917 100644 --- a/drafts/src/styles.css +++ b/drafts/src/styles.css @@ -478,6 +478,20 @@ white-space: nowrap; } +/* 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; From 085454c8762e522432e452d25bf2e0553c931499 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Mon, 31 Aug 2026 16:57:33 +0200 Subject: [PATCH 17/21] chat: record merge provenance when accepting agent drafts Mirror the drafts sidebar's updated mergeDraft in mergeAgentDraft: adopt members the merge target never forked, and record mergedFrom per clone and mergedInto on the draft, so chat-accepted drafts show up as attributed "Merged" groups in the drafts timeline. --- chat/src/lib/agent-drafts.ts | 68 +++++++++++++++++++++++++++++------- chat/src/version.ts | 2 +- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/chat/src/lib/agent-drafts.ts b/chat/src/lib/agent-drafts.ts index 1c64f453..e0763f06 100644 --- a/chat/src/lib/agent-drafts.ts +++ b/chat/src/lib/agent-drafts.ts @@ -13,6 +13,7 @@ // 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, @@ -43,6 +44,9 @@ 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 = { @@ -53,6 +57,9 @@ export type DraftDoc = { 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 } @@ -184,12 +191,14 @@ async function ensureMainDraft( } /** Accept: merge 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, so an agent draft merges into - * the originals) — then mark the draft merged (which hides it from the drafts - * sidebar). 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. */ + * 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 @@ -197,27 +206,60 @@ export async function mergeAgentDraft( const draftHandle = await repo.find(draftUrl) 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 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 - 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 && 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) e.mergedAt = mergedAt + 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) { diff --git a/chat/src/version.ts b/chat/src/version.ts index c36fa9a1..8d95ed6f 100644 --- a/chat/src/version.ts +++ b/chat/src/version.ts @@ -1,3 +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.5" +export const CHAT_VERSION = "v0.0.9" From 1861337a21a5756448ca59aa280c42b120f76d53 Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Mon, 31 Aug 2026 16:57:40 +0200 Subject: [PATCH 18/21] chat: pair tool results with the right cards, stretch stream timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the FIRST unfilled tool-call card — searching from the end paired a multi-call round's results with the wrong cards. Inactivity timeout up to 5 minutes: a model streaming a huge tool call looks silent to the llm lib. SkillsDebug reads the shared CHAT_VERSION instead of its own constant. --- chat/src/components/ChatRoot.tsx | 18 ++++++++++++------ chat/src/components/SkillsDebug.tsx | 5 +---- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/chat/src/components/ChatRoot.tsx b/chat/src/components/ChatRoot.tsx index ec2bb4d6..4596a474 100644 --- a/chat/src/components/ChatRoot.tsx +++ b/chat/src/components/ChatRoot.tsx @@ -2759,9 +2759,12 @@ Never overwrite an entire long field with a key-assign (range:"content") just to const abortController = new AbortController() setComputerAbort(abortController) - // Inactivity timeout: abort if no tokens received for 30s + // Inactivity timeout: abort if no tokens/status received for a while. + // Generous, because the llm lib only reports content deltas: a model + // streaming a huge tool call (or reasoning) looks silent from here even + // though the connection is making steady progress. let inactivityTimer: any = null - const INACTIVITY_TIMEOUT = 90000 + const INACTIVITY_TIMEOUT = 300000 function resetInactivityTimer() { if (inactivityTimer) clearTimeout(inactivityTimer) inactivityTimer = setTimeout(() => { @@ -3011,12 +3014,15 @@ Never overwrite an entire long field with a key-assign (range:"content") just to resetInactivityTimer() toolResults += "\n[Tool result for " + c.name + "]\n" + result + "\n" - // Store result on the corresponding rich block + // Store result on the corresponding rich block. Results arrive + // in call order, so fill the FIRST unfilled card — searching + // from the end paired every multi-call round's results with the + // wrong cards (swapped read_doc/load_skill results in exports). currentStreamHandle.change((d: any) => { if (d.richBlocks) { - const matching = [...d.richBlocks] - .reverse() - .find((b: any) => b.type === "tool-call" && !b.result) + const matching = d.richBlocks.find( + (b: any) => b.type === "tool-call" && !b.result + ) if (matching) matching.result = result.slice(0, 2000) } }) diff --git a/chat/src/components/SkillsDebug.tsx b/chat/src/components/SkillsDebug.tsx index fd73dcbc..2b25486e 100644 --- a/chat/src/components/SkillsDebug.tsx +++ b/chat/src/components/SkillsDebug.tsx @@ -12,10 +12,7 @@ import { type ActiveSkill, type LlmSkillDescription, } from "../lib/llm-skills" - -// Bump when shipping a change you want to verify made it to a running client -// (pushwork-synced tools can lag; this shows which build is actually loaded). -const CHAT_VERSION = "v0.0.2" +import {CHAT_VERSION} from "../version" export function SkillsDebug(props: { /** Skills active for the most recent computer run. */ From 249c582b2005a47ff8da32d9029e8e1543d1ecba Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Tue, 1 Sep 2026 14:33:21 +0200 Subject: [PATCH 19/21] show diff by default when selecting something in the history --- drafts/src/DraftsSidebar.tsx | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index 6ddfafa5..3cd41362 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -2181,19 +2181,18 @@ 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, - memberHeads: - boundaryHeads(group, BASELINE_GROUP_START, false) ?? undefined, - }); - } + props.onBaselineScrub({ + groupId: group.id, + offset: BASELINE_GROUP_START, + time: group.startTime, + memberHeads: + boundaryHeads(group, BASELINE_GROUP_START, false) ?? undefined, + }); scrubTo(group, 0); }; From 9867ada5d704334d67887ccf812937acc967b09a Mon Sep 17 00:00:00 2001 From: Paul Sonnentag Date: Wed, 2 Sep 2026 15:24:29 +0200 Subject: [PATCH 20/21] drafts: show comments as entries in the history timeline Change groups split at comment timestamps so each comment (including replies and resolved threads) gets its own row. Clicking a comment pins the draft to its state at that time, switches the context sidebar to the comments tab via a new patchwork:open-context-tool event, and asks the comments view to select and scroll to the thread through an openThread field on the shared patchwork:focus doc. Comment writes no longer count as edits, and the idle scrubber line sits above comments newer than the latest change. --- comments-view/src/CommentsView.tsx | 44 ++- drafts/src/DraftsSidebar.tsx | 306 +++++++++++++++++- drafts/src/change-group-cache.test.ts | 100 ++++++ drafts/src/change-group-cache.ts | 108 +++++-- drafts/src/patchwork-view.d.ts | 15 + drafts/src/styles.css | 41 +++ providers/src/FocusProvider.ts | 7 + threepane/src/components/DocumentAreaRoot.tsx | 34 +- 8 files changed, 618 insertions(+), 37 deletions(-) create mode 100644 drafts/src/change-group-cache.test.ts create mode 100644 drafts/src/patchwork-view.d.ts 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)} > (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, + }) + ); + }; + // 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 @@ -627,6 +659,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) { checkpoint={() => (isMainSelected() ? (checkedOut()?.at ?? null) : null)} hasCheckpoint={isMainSelected() && isPinned()} onReturnToLatest={clearCheckpoint} + onOpenComment={openComment} eyeOpen={isMainSelected() && eyeOpen()} eyeDisabled={!isPinned()} onToggleEye={toggleEye} @@ -667,6 +700,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) { } hasCheckpoint={selected() === summary.url && isPinned()} onReturnToLatest={clearCheckpoint} + onOpenComment={openComment} eyeOpen={selected() === summary.url && eyeOpen()} eyeDisabled={false} onToggleEye={toggleEye} @@ -1133,6 +1167,7 @@ function MainCard(props: { checkpoint: Accessor; hasCheckpoint: boolean; onReturnToLatest: () => void; + onOpenComment: (comment: TimelineComment) => void; eyeOpen: boolean; eyeDisabled: boolean; onToggleEye: () => void; @@ -1211,6 +1246,7 @@ function MainCard(props: { eyeOpen={() => props.eyeOpen} checkpoint={props.checkpoint} onReturnToLatest={props.onReturnToLatest} + onOpenComment={props.onOpenComment} />
@@ -1238,6 +1274,7 @@ function DraftCard(props: { checkpoint: Accessor; hasCheckpoint: boolean; onReturnToLatest: () => void; + onOpenComment: (comment: TimelineComment) => void; eyeOpen: boolean; eyeDisabled: boolean; onToggleEye: () => void; @@ -1326,6 +1363,7 @@ function DraftCard(props: { eyeOpen={() => props.eyeOpen} checkpoint={props.checkpoint} onReturnToLatest={props.onReturnToLatest} + onOpenComment={props.onOpenComment} /> @@ -1774,6 +1812,40 @@ type ScanChange = { // list the scan and the attribution walk share. type MemberScanRow = Omit; +// 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. // The ChangeGrouper computes and persists activity groups (newest first, older // history backfilling), and this component is a pure reader: it paints before @@ -1801,6 +1873,8 @@ 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; }) { const repo = "repo" in window ? window.repo : undefined; @@ -1836,7 +1910,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 @@ -1854,7 +1936,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:", @@ -1873,6 +1959,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 @@ -2196,6 +2376,28 @@ function DraftChangesList(props: { 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)); + }; + // 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). @@ -2250,10 +2452,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)); }); @@ -2346,6 +2549,16 @@ function DraftChangesList(props: { }; }; + // 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 @@ -2354,10 +2567,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) }; }); @@ -2542,13 +2755,35 @@ 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())} + /> + )} + + + {(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 @@ -2678,6 +2913,47 @@ function TimeGroupRow(props: { ); } +// 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 diff --git a/drafts/src/change-group-cache.test.ts b/drafts/src/change-group-cache.test.ts new file mode 100644 index 00000000..a4fa8e6e --- /dev/null +++ b/drafts/src/change-group-cache.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import type { Doc } from "@automerge/automerge/slim"; + +import { + collectCommentTimes, + INACTIVITY_GAP_MS, + 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("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 f988e725..b76926a7 100644 --- a/drafts/src/change-group-cache.ts +++ b/drafts/src/change-group-cache.ts @@ -19,7 +19,7 @@ import type { 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 = 2; // 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, @@ -184,7 +184,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") { @@ -242,25 +245,72 @@ 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). A +// comment reads as its own timeline entry, so the changes before and after it +// must not aggregate into one row. The 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[] = [] +): T[][] { + const groups: T[][] = []; + let window: T[] = []; let prevTimeMs: number | null = null; + let ci = 0; for (const row of rowsNewestFirst) { const timeMs = row.time * 1000; // 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) { + groups.push(window); + window = []; + } } window.push(row); prevTimeMs = timeMs; @@ -666,6 +716,12 @@ export function createChangeGrouper( tails.sort(newestFirst); + // 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 @@ -692,6 +748,7 @@ export function createChangeGrouper( changeGroupHandle, newestStored, tails, + commentTimesMs, frontier, isAborted ); @@ -700,6 +757,7 @@ export function createChangeGrouper( changeGroupHandle, sources, createdAt, + commentTimesMs, frontier, spec.mergedDrafts, isAborted @@ -714,19 +772,28 @@ 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); 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) — 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. + // (and no comment) separates them (it may even start inside the stored + // span) — 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 && oldestGroupOldestMs <= newestStored.endTime * 1000 + INACTIVITY_GAP_MS; const freshGroups = attaches ? tailGroups.slice(0, -1) : tailGroups; @@ -830,6 +897,7 @@ export function createChangeGrouper( changeGroupHandle: DocHandle, sources: { member: DraftMemberDoc; doc: Automerge.Doc }[], createdAt: number | undefined, + commentTimesMs: number[], frontier: Record, mergedDrafts: MergedDraftSpec[], isAborted: () => boolean @@ -841,7 +909,7 @@ export function createChangeGrouper( } rows.sort(newestFirst); const { merged, rest } = partitionRows(rows, mergedDrafts); - const groupsRows = splitIntoGroups(rest); + const groupsRows = splitIntoGroups(rest, commentTimesMs); const expectedIds = new Set(groupsRows.map(groupId)); const batch: ChangeGroup[] = []; 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/styles.css b/drafts/src/styles.css index 8b734917..d2031577 100644 --- a/drafts/src/styles.css +++ b/drafts/src/styles.css @@ -478,6 +478,47 @@ 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 merged draft's dedicated group row, naming the source draft. */ .draft-group-merge { flex: none; diff --git a/providers/src/FocusProvider.ts b/providers/src/FocusProvider.ts index b9424778..3927bfe4 100644 --- a/providers/src/FocusProvider.ts +++ b/providers/src/FocusProvider.ts @@ -11,9 +11,16 @@ 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). export type FocusDoc = { selection: Record; highlight: Record; + openThread?: { url: AutomergeUrl; at: number }; }; export const FocusProvider = (element: PatchworkViewElement) => { 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); + }} > Date: Wed, 2 Sep 2026 18:10:07 +0200 Subject: [PATCH 21/21] more draft fixes --- chat/src/agent-tool.tsx | 59 ++- chat/src/components/ChatRoot.tsx | 39 +- chat/src/lib/agent-change.ts | 77 ++++ chat/src/version.ts | 2 +- drafts/src/DraftsSidebar.tsx | 418 ++++++++++++++++++++- drafts/src/actor-attribution.ts | 7 + drafts/src/change-group-cache.test.ts | 96 +++++ drafts/src/change-group-cache.ts | 192 ++++++++-- drafts/src/draft-types.ts | 21 ++ drafts/src/providers/DraftStateProvider.ts | 1 + drafts/src/styles.css | 97 ++++- providers/src/FocusProvider.ts | 4 + 12 files changed, 963 insertions(+), 50 deletions(-) create mode 100644 chat/src/lib/agent-change.ts diff --git a/chat/src/agent-tool.tsx b/chat/src/agent-tool.tsx index 619c1023..d57a9a09 100644 --- a/chat/src/agent-tool.tsx +++ b/chat/src/agent-tool.tsx @@ -40,7 +40,7 @@ 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, toolStorageUrl} from "./lib/selected-doc" +import {selectedDocUrl, subscribe, toolStorageUrl} from "./lib/selected-doc" import {setRepo} from "./lib/repo" import {generateId} from "./lib/helpers" import {copyChatTranscript} from "./lib/transcript" @@ -68,6 +68,13 @@ import { } 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 @@ -135,6 +142,56 @@ function AgentHost(props: {element: HTMLElement; repo: Repo}) { 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") diff --git a/chat/src/components/ChatRoot.tsx b/chat/src/components/ChatRoot.tsx index 4596a474..89556166 100644 --- a/chat/src/components/ChatRoot.tsx +++ b/chat/src/components/ChatRoot.tsx @@ -3,6 +3,7 @@ import {cursor as automergeCursor} from "@automerge/automerge-repo/slim" import type {DocHandle, AutomergeUrl} from "@automerge/automerge-repo/slim" import {updateText, splice} from "@automerge/automerge/slim" import {applyAutomerge} from "../lib/automerge-ops" +import {makeAgentTag, agentChange} from "../lib/agent-change" import type {ChatDoc} from "../types" import type {FeatureSelector} from "../features" import {featurePlugins} from "../features" @@ -1357,9 +1358,20 @@ Never overwrite an entire long field with a key-assign (range:"content") just to return notes.length ? "\n\n" + notes.join("\n\n") : "" } - async function runToolByName(toolName: string, rawArgs: any): Promise { + async function runToolByName( + toolName: string, + rawArgs: any, + toolCallId?: string + ): Promise { const args = rawArgs || {} const repo = (props.element as any).repo + // Stamped as the change message on every document edit this call makes, + // so consumers (the drafts timeline) can attribute it to this chat run. + // NOTE: only the built-in edit tools below write through agentChange; + // skill tools and define_tool customs edit through their own ctx and + // stay untagged — the "[agent-change] tagging" log shows which happened. + const agentTag = makeAgentTag(props.handle, toolCallId) + console.log("[agent] tool call:", toolName, "id:", toolCallId ?? "(none)") // In context mode, doc-editing tools default to the focused document. const focusedUrl = () => props.targetDocUrl?.() try { @@ -1480,11 +1492,12 @@ Never overwrite an entire long field with a key-assign (range:"content") just to const value = hasValue ? parseValueMaybe(args.value) : undefined const heads = parseMaybe(args.heads) const mut = (d: any) => applyAutomerge(d, path, range, value) - if (Array.isArray(heads) && heads.length) { - h.changeAt(heads, mut) - } else { - h.change(mut) - } + agentChange( + h, + agentTag, + mut, + Array.isArray(heads) && heads.length ? heads : undefined + ) // Return the affected container so the model can verify. const after = h.doc() as any let container: any = after @@ -1587,7 +1600,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to } else { chosen = matches[0] } - h.change((d: any) => + agentChange(h, agentTag, (d: any) => applyAutomerge(d, chosen.path, [chosen.start, chosen.end], replacement) ) let after: any = h.doc() @@ -1652,7 +1665,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to val = args.value } } - h.change((d: any) => { + agentChange(h, agentTag, (d: any) => { if (typeof val === "string" && typeof d[args.field] === "string") { updateText(d, [args.field], val) } else { @@ -1671,7 +1684,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to const index = parseInt(args.index, 10) const deleteCount = parseInt(args.deleteCount || "0", 10) const insert = args.insert || "" - h.change((d: any) => { + agentChange(h, agentTag, (d: any) => { splice(d, [args.field], index, deleteCount, insert) }) const after = h.doc() as any @@ -3000,7 +3013,11 @@ Never overwrite an entire long field with a key-assign (range:"content") just to // display-only — it's already been rendered above). for (const c of calls) { if (c.name === "ask_user") continue - noteToolRun(c.name, c.args, await runToolByName(c.name, c.args)) + noteToolRun( + c.name, + c.args, + await runToolByName(c.name, c.args, c.id) + ) } completedResponse = true break @@ -3009,7 +3026,7 @@ Never overwrite an entire long field with a key-assign (range:"content") just to // Execute tool calls and store results let toolResults = "" for (const c of calls) { - const result = await runToolByName(c.name, c.args) + const result = await runToolByName(c.name, c.args, c.id) noteToolRun(c.name, c.args, result) resetInactivityTimer() toolResults += 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/version.ts b/chat/src/version.ts index 8d95ed6f..08f92643 100644 --- a/chat/src/version.ts +++ b/chat/src/version.ts @@ -1,3 +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.9" +export const CHAT_VERSION = "v0.0.10" diff --git a/drafts/src/DraftsSidebar.tsx b/drafts/src/DraftsSidebar.tsx index 98b99598..5c37272a 100644 --- a/drafts/src/DraftsSidebar.tsx +++ b/drafts/src/DraftsSidebar.tsx @@ -29,6 +29,7 @@ import { } from "@inkandswitch/patchwork-providers-solid"; import type { ActorAttributionDoc, + AgentTag, ChangeGroup, ChangeGroupDoc, CheckedOutDraft, @@ -44,7 +45,9 @@ import { computeEditCounts, computeRangeEditCounts, getDocCreationTime, + parseAgentTag, sameHeads, + splitIntoGroups, } from "./change-group-cache"; import { attributedHashes, frontierHashes } from "./merge-attribution"; import { ensureMainDraft } from "./draft-docs"; @@ -66,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.50"; +const DRAFTS_VERSION = "0.0.55"; // Logged at module load so the console shows which build is running even // before the panel renders. @@ -84,10 +87,12 @@ export function DraftsSidebar(props: { element: HTMLElement }) { // 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`. Unresolved when no focus provider is around, in - // which case the write half is skipped. + // (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` @@ -112,6 +117,24 @@ export function DraftsSidebar(props: { element: HTMLElement }) { ); }; + // 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 @@ -660,6 +683,7 @@ export function DraftsSidebar(props: { element: HTMLElement }) { hasCheckpoint={isMainSelected() && isPinned()} onReturnToLatest={clearCheckpoint} onOpenComment={openComment} + onOpenAgentChat={openAgentChat} eyeOpen={isMainSelected() && eyeOpen()} eyeDisabled={!isPinned()} onToggleEye={toggleEye} @@ -701,6 +725,7 @@ 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} @@ -1168,6 +1193,8 @@ function MainCard(props: { 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; @@ -1247,6 +1274,7 @@ function MainCard(props: { checkpoint={props.checkpoint} onReturnToLatest={props.onReturnToLatest} onOpenComment={props.onOpenComment} + onOpenAgentChat={props.onOpenAgentChat} />
@@ -1275,6 +1303,8 @@ function DraftCard(props: { 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; @@ -1364,6 +1394,7 @@ function DraftCard(props: { checkpoint={props.checkpoint} onReturnToLatest={props.onReturnToLatest} onOpenComment={props.onOpenComment} + onOpenAgentChat={props.onOpenAgentChat} /> @@ -1806,12 +1837,34 @@ 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 @@ -1875,6 +1928,8 @@ function DraftChangesList(props: { 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; @@ -2143,11 +2198,84 @@ function DraftChangesList(props: { const out: MemberScanRow[] = []; metas.forEach((meta, seq) => { if (cutoff !== undefined && meta.time && meta.time < cutoff) return; - out.push({ hash: meta.hash, time: meta.time, deps: meta.deps, seq }); + 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 @@ -2398,6 +2526,34 @@ function DraftChangesList(props: { 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). @@ -2440,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); @@ -2477,21 +2636,63 @@ 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). 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. + // 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 @@ -2499,7 +2700,8 @@ function DraftChangesList(props: { }; // 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 => { @@ -2508,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), @@ -2536,6 +2759,33 @@ function DraftChangesList(props: { 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) }; @@ -2679,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 ( @@ -2760,11 +3016,70 @@ function DraftChangesList(props: { {(group) => ( - rowEls.set(group().id, el)} - onSelect={() => selectGroup(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} + /> + )} + +
+ )} +
+ + + )}
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 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 index a4fa8e6e..543c36ba 100644 --- a/drafts/src/change-group-cache.test.ts +++ b/drafts/src/change-group-cache.test.ts @@ -4,6 +4,7 @@ import type { Doc } from "@automerge/automerge/slim"; import { collectCommentTimes, INACTIVITY_GAP_MS, + parseAgentTag, splitIntoGroups, } from "./change-group-cache"; @@ -79,6 +80,101 @@ describe("splitIntoGroups", () => { }); }); +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 = { diff --git a/drafts/src/change-group-cache.ts b/drafts/src/change-group-cache.ts index b76926a7..35ff2ae6 100644 --- a/drafts/src/change-group-cache.ts +++ b/drafts/src/change-group-cache.ts @@ -11,6 +11,7 @@ import { import * as Automerge from "@automerge/automerge/slim"; import type { + AgentTag, ChangeGroup, ChangeGroupDoc, DraftDoc, @@ -19,7 +20,7 @@ import type { 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 = 2; +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, @@ -64,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 @@ -236,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`. @@ -277,23 +316,29 @@ export function collectCommentTimes( // 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 -// AND no comment was made in between (`commentTimesMs`, newest first). A -// comment reads as its own timeline entry, so the changes before and after it -// must not aggregate into one row. The 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. +// 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[] = [] + 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 && window.length > 0) { @@ -307,13 +352,18 @@ export function splitIntoGroups( } const commentBetween = ci < commentTimesMs.length && commentTimesMs[ci] >= timeMs; - if (prevTimeMs - timeMs > INACTIVITY_GAP_MS || commentBetween) { + 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; @@ -354,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, @@ -362,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); }); } @@ -374,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) => { @@ -409,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; @@ -421,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 { @@ -477,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 => { @@ -697,6 +809,19 @@ export function createChangeGrouper( collectMemberRows(tails, member, doc, since, createdAt); } + 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 @@ -776,7 +901,11 @@ export function createChangeGrouper( frontier: Record, isAborted: () => boolean ): Promise { - const tailGroups = splitIntoGroups(tailsNewestFirst, commentTimesMs); + const tailGroups = splitIntoGroups( + tailsNewestFirst, + commentTimesMs, + contributorKey + ); const oldestGroup = tailGroups[tailGroups.length - 1]; const oldestGroupOldestMs = oldestGroup[oldestGroup.length - 1].time * 1000; @@ -788,19 +917,20 @@ export function createChangeGrouper( ); // The oldest run of new changes merges into the stored group when no lull // (and no comment) separates them (it may even start inside the stored - // span) — 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. + // 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); } @@ -841,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; } @@ -877,10 +1011,22 @@ 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; } @@ -909,7 +1055,7 @@ export function createChangeGrouper( } rows.sort(newestFirst); const { merged, rest } = partitionRows(rows, mergedDrafts); - const groupsRows = splitIntoGroups(rest, commentTimesMs); + const groupsRows = splitIntoGroups(rest, commentTimesMs, contributorKey); const expectedIds = new Set(groupsRows.map(groupId)); const batch: ChangeGroup[] = []; @@ -941,7 +1087,7 @@ export function createChangeGrouper( ) { continue; } - const group = await buildGroup(draftRows, slicer); + const group = await buildGroup(draftRows, slicer, contributorKey); if (group === null) return; // aborted mid-diff; markers stay put batch.push({ ...group, @@ -965,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); } diff --git a/drafts/src/draft-types.ts b/drafts/src/draft-types.ts index bb882b09..fd6adc2e 100644 --- a/drafts/src/draft-types.ts +++ b/drafts/src/draft-types.ts @@ -72,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 @@ -87,6 +98,16 @@ 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 diff --git a/drafts/src/providers/DraftStateProvider.ts b/drafts/src/providers/DraftStateProvider.ts index e9d19136..3edbec65 100644 --- a/drafts/src/providers/DraftStateProvider.ts +++ b/drafts/src/providers/DraftStateProvider.ts @@ -127,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 diff --git a/drafts/src/styles.css b/drafts/src/styles.css index d2031577..83aeb486 100644 --- a/drafts/src/styles.css +++ b/drafts/src/styles.css @@ -519,6 +519,97 @@ 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; @@ -654,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 3927bfe4..c2be26e8 100644 --- a/providers/src/FocusProvider.ts +++ b/providers/src/FocusProvider.ts @@ -17,10 +17,14 @@ const SELECTOR = "patchwork:focus"; // 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) => {