diff --git a/packages/app/CONTEXT.md b/packages/app/CONTEXT.md index 20a9578dd4..eb6bdb733d 100644 --- a/packages/app/CONTEXT.md +++ b/packages/app/CONTEXT.md @@ -22,17 +22,22 @@ other surface opens beside or in front of the Chat; the Chat is never replaced b another surface, only overlaid. There is no view in which the Chat is absent. _Avoid_: Main view, editor, session pane (as the whole hub) -**Brain**: -The data-true living background of the **Chat** — a map of the current session's thought that -fills the Chat behind every component and is never interactive. One per window, present wherever -the Chat is (Landing included) and nowhere else. -_Avoid_: Background, wallpaper, atmosphere, brain strip, canvas - -**Glass**: -The frosted, translucent surface every chat component floats on above the **Brain** — tinted -just enough that text stays legible over the moving graph. (How the blur and tint are derived -is an ADR-0002 concern, not a glossary one.) -_Avoid_: Card background, frosted panel, blur layer, tier +**Context tree**: +The collapsible band folded into the session header, beneath the title row and entity +chips — a left-to-right tree of what the agent is actually holding in mind: root = amico, +branches = the session's turns (one per user prompt, however many assistant messages it +spans), leaves = the markdown, source, skills, and agents each turn pulled into context. +Interactive: clicking a file node opens the real file (project files as a session tab, +vault files in the **Vault panel**). One per session view; absent until the session holds +context, and absent on the Landing. See ADR 0003. +_Avoid_: Brain (that was the ambient, non-interactive strip), knowledge graph, minimap + +**Vault panel**: +The dismissible drawer that puts the vault — every attached mount and every file inside +it — in the interface. Read-only browsing with inline markdown/source rendering; opened +from the titlebar vault button, the command palette, or a **Context tree** click on a +vault node. +_Avoid_: Vaults tab (that is the mount *list* in status), file manager, explorer **Rail**: The one narrow vertical strip of icons along the window's leading edge. Collapsed to @@ -80,9 +85,10 @@ only surfaces its state), setup wall ## Relationships - The **Landing** is a **Chat**; the window never opens on anything else. -- The **Brain** is the background of the **Chat** and only the Chat; every chat component - floats on **Glass** above it, while the **Rail**, titlebar, and **Panels** stay solid and - frame the living pane. +- The **Context tree** sits above the **Chat** and belongs to one session; the **Vault + panel** belongs to the window and opens beside whatever Chat is showing. A Context-tree + click on a vault node opens the Vault panel on that file — the two surfaces reference + each other but neither owns the other. - Every **Rail surface** opens as a **Panel** beside the **Chat**; the Chat is never replaced, only overlaid, and at most one Panel is open at a time. - The **Rail** carries navigation surfaces above and the **Account zone** below; both @@ -115,6 +121,8 @@ reader meeting them in older code or notes should map them forward: a possible future, not part of this redesign. See ADR 0001. - **Home cards** "Meet Amico" and "Jump back in" → folded into the first-run Landing and the Chats surface respectively; they are no longer standalone surfaces. -- **Inline brain strip** (the Brain as a single row inside the message timeline) → absorbed - into the **Brain** background. One render loop, promoted from a timeline row to the room; - the timeline keeps only its text shimmer. See ADR 0002. +- **Brain** (the ambient session map — first an inline timeline strip, then ADR 0002's + proposed chat-wide background) → superseded by the **Context tree** top panel. The + background direction was parked (ADR 0003); the strip row is deleted; the ambient engine + survives as a library only. **Glass** (the frosted card surface of the background design) + retires with it. diff --git a/packages/app/docs/adr/0002-brain-as-chat-background.md b/packages/app/docs/adr/0002-brain-as-chat-background.md index c4ba6a4399..7805ebc4c3 100644 --- a/packages/app/docs/adr/0002-brain-as-chat-background.md +++ b/packages/app/docs/adr/0002-brain-as-chat-background.md @@ -1,6 +1,7 @@ # 0002 — The Brain becomes the Chat's living background; components float on legibility-floored glass -- Status: Proposed +- Status: Superseded by ADR 0003 (direction parked 2026-07-26; the Brain moved to the + context-tree top panel instead of becoming the chat background) - Date: 2026-07-24 - Deciders: Kate Bonner (Head of Product) - Tracking: harmoniqs/opencode#56 diff --git a/packages/app/docs/adr/0003-context-tree-top-panel.md b/packages/app/docs/adr/0003-context-tree-top-panel.md new file mode 100644 index 0000000000..4259ad79b9 --- /dev/null +++ b/packages/app/docs/adr/0003-context-tree-top-panel.md @@ -0,0 +1,74 @@ +# 0003 — The knowledge graph moves to a top panel as the agent's context tree; the vault gets a first-class surface + +- Status: Accepted +- Date: 2026-07-27 +- Deciders: Kate Bonner (Head of Product), on Aaron Trowbridge's design feedback (2026-07-26) +- Supersedes: ADR 0002 (Brain as chat background — direction parked) + +## Context + +The amico **Brain** rendered as an inline strip inside the message timeline: one row among +many, easy to scroll past, non-interactive by design (`aria-hidden` canvas, no hit-testing), +and drawn over a **hardcoded sample of the armonissima vault** rather than anything the +agent is actually doing. ADR 0002 proposed promoting it to a chat-wide living background; +that direction was reviewed and parked. + +Aaron's feedback reframed what the graph is *for*: not ambience, but **orientation** — a +researcher watching the agent work wants to see *what the agent is holding in mind* and to +reach those artifacts directly. Two gaps followed from that framing: + +1. The graph showed a decorative vault skeleton, not the session's real context, and + nothing on it could be clicked. +2. The **vault itself** — the knowledge base the agent reads and writes — had no surface in + the interface at all. The app could list mount names (`/amicode/vaults`) but could not + browse or read a single note. + +## Decision + +**1. The graph moves out of the timeline into a pinned top panel** between the session +header and the chat, collapsible, one per session view. The inline brain strip row is +removed from the timeline (`TimelineRow.Brain` deleted); the ambient background direction +of ADR 0002 is not pursued. + +**2. The graph is redesigned as the agent's context tree.** A left-to-right tidy tree: +root = amico; branches = the session's turns (roman-numeral plates carrying the prompt +excerpt — the atlas idea, restructured); leaves = the distinct markdown, source, skills, +agents, and web references each turn pulled into context. Commits only — searches/globs +are transient scouting and never enter the tree. A leaf re-touched by a later turn is +**deduplicated into a thin recall link** back to the existing node, so the tree stays a map +of distinct context, not a log. Marathon sessions fold their oldest turns into one +"earlier" branch. + +**3. The tree is interactive — the graph is now a navigation instrument.** Nodes +hit-test; hover raises the label and hands the node to the host; **clicking a file node +opens the real file** — project files as a session file tab (the same flow as the file +tree), vault files in the Vault panel, landed directly on that note. Camera: auto-fit +until the user pans/zooms; wheel zooms about the cursor; double-click refits. The log's +hover-glance events (`amicode:brain-hover`) keep working against the tree. + +**4. The vault lives in the interface.** A read-only **Vault panel** (drawer beside the +Chat, per the CONTEXT.md Panel concept) lists every attached mount and every file inside +it, and renders markdown and source inline. Two new raw server routes back it — +`GET /amicode/vault-files` (recursive listing) and `GET /amicode/vault-file` (single read, +real-path traversal guard, size cap) — beside the existing `/amicode/vaults` mount list. +Entry points: a titlebar vault button (both titlebar variants), the command palette +("Toggle vault panel"), and context-tree clicks on vault nodes. + +## Consequences + +- `brain-engine.ts` stays as-is (the ambient engine remains a library; the strip component + is deleted). The context tree is a **separate, purpose-built engine** + (`context-tree-engine.ts`): declarative `setTree`, tidy layout, pointer interaction, + DPR-change-aware canvas. Both engines remain exempt from the single-yellow accent rule as + data visualizations; `#fff676` still marks only the live position. +- The tree derivation is pure and headless (`context-tree-data.ts`), unit-tested without + the sync store; `brain-ref` now carries the full file path so nodes can open files. +- The vault routes are read-only and refuse path escapes even through symlinks; binaries + are listed but marked unreadable, so the tree still shows the vault's true shape. +- Vault contents are proprietary knowledge, so browsing is a **local-researcher + capability, not a server API**: the routes refuse on any non-loopback bind (same + signal as the credential-mutation guard; `AMICO_VAULT_BROWSER=1` opts a shared + deployment in, `=0` forces off), and a mount can go fully dark with + `browse = false` in its `.amico-vault.toml` — the agent's read grants are unaffected. +- Keyboard reachability of individual canvas nodes is an open follow-up; every action the + canvas offers also exists via keyboard-reachable surfaces (file tree, Vault panel). diff --git a/packages/app/docs/design/agentic-surfaces.md b/packages/app/docs/design/agentic-surfaces.md new file mode 100644 index 0000000000..336531f8c9 --- /dev/null +++ b/packages/app/docs/design/agentic-surfaces.md @@ -0,0 +1,77 @@ +# Agentic surfaces — static elements generated from the researcher's work state + +- Status: Proposal for discussion (Kate × Aaron) — no implementation yet +- Date: 2026-07-27 +- Origin: Aaron Trowbridge's feedback (2026-07-26): "the potential to make static + elements agentically generated based on the user's current work state" + +## The idea + +Most of the interface outside the conversation is **authored once and shown to +everyone**: the new-session view, the setup nudge, empty states, panel headers, the +defaults capsule. Aaron's observation is that an agent product doesn't have to treat +these as fixed copy — the agent knows the researcher's work state (active problems, run +history, vault recency, calibration drift, what the last session ended on) and could +**compose these surfaces** the way it composes an answer. + +The context-tree top panel (ADR 0003) is the first surface of this kind already shipped: +its content is entirely derived from work state, with a fixed *frame* and generated +*content*. This document asks where else that pattern pays for itself. + +## Inventory — static elements that could be work-state-driven + +| Surface | Today | Agentically generated would mean | +|---|---|---| +| New-session view | Fixed problem list + copy | "You were mid-sweep on the CZ ladder; run 9 finished overnight — resume?" with the 2–3 next actions ranked from ledger + session history | +| Setup nudge | Profile completeness flag | Nudge text chosen from the actual missing step (vault, Julia, connection) and phrased against what the user tried last | +| Empty states (run gallery, pulse bank, library) | Generic copy | Seeded with the nearest real thing: last run, closest catalog pulse, most-recent paper | +| Session header context | Session title | One generated line of "where this thread left off" on resume | +| Composer placeholder | Fixed prompt copy | Rotates against work state ("ask about run 12's stagnation…") | +| Defaults capsule | Static solver defaults | Defaults proposed from the last N solves of this problem family | + +## Architecture sketch — three tiers, in order of trust + +1. **Derived (no LLM, deterministic).** Templates filled from ledger/vault/session + queries. Cheap, always fresh, testable — the context tree lives here. Most of the + table above can too, and this tier should be exhausted first. +2. **Generated-and-cached.** A background agent (the distiller cadence fits) writes + short surface copy into a per-user store; the UI renders it as data. Latency-free at + render time; staleness bounded by the cadence. The existing server-rendered widget + pipeline (`widgets-src/*` → `/amicode/widget-frame`) is the natural substrate — it + already solves sandboxing, CSP, and per-widget data plumbing. +3. **Live-generated.** Composed at render time by a model call. Highest freshness, + highest cost, needs skeleton states and a hard timeout fallback to tier-1 content. + Probably justified only for the new-session view, if at all. + +## Constraints that must hold + +- **Frames are fixed; content is generated.** Layout, tokens, and interaction patterns + stay authored (amicode-design-system governs them); the agent fills slots. No + agent-invented UI structure. +- **Fallback is the current static copy.** Every agentic surface degrades to exactly + what ships today — an empty work state must never produce a broken or blank surface. +- **Provenance visible on anything actionable.** If a surface proposes an action + ("resume the sweep"), it must say what it derived that from, and be wrong-safe: + clicking a stale suggestion can never destroy state. +- **No surprise tokens.** Tier 2/3 generation runs on explicit cadences or user action, + never silently per-render. + +## Open questions for Aaron + +1. Which surface first? The new-session view has the highest leverage (it's the landing + for every returning user), but empty states are the cheapest proof of the pattern. +2. Is tier 2 (cached generation on the distiller cadence) fresh enough, or does the + resume-context line need tier 3? +3. Should generated copy be visibly marked (e.g. the thought-color accent) so + researchers learn which parts of the interface are alive? +4. Does the widget kernel become the delivery mechanism for all of this, or is it + simpler to add a `GET /amicode/surface?id=…` route returning structured slots? + +## Relationship to shipped work + +- **Context tree (ADR 0003)** — tier 1, shipped. The template for "fixed frame, + work-state content." +- **Command zoom fixes** — unrelated mechanically, but they remove the "static chrome is + broken" noise that would undermine trust in smarter chrome. +- **Vault panel** — the read path any generated surface will cite when it references + vault knowledge. diff --git a/packages/app/src/components/prompt-input/clipboard-bridge.test.ts b/packages/app/src/components/prompt-input/clipboard-bridge.test.ts index 38cf3c87b0..e12d45bd83 100644 --- a/packages/app/src/components/prompt-input/clipboard-bridge.test.ts +++ b/packages/app/src/components/prompt-input/clipboard-bridge.test.ts @@ -4,13 +4,16 @@ import { readClipboardViaBridge, writeClipboardViaBridge } from "./clipboard-bri type Listener = (event: MessageEvent) => void // A stand-in for a framed window (parent !== self) that records outgoing -// clipboard-requests and lets a test play back the host's reply. -function fakeFramedWindow() { +// clipboard-requests and lets a test play back the host's reply. `readText` +// simulates the host's navigator.clipboard grant: a function to grant it, +// absent to model the VS Code webview iframe (no permission). +function fakeFramedWindow(readText?: () => Promise) { const listeners = new Set() const posted: Array> = [] const win = { addEventListener: (_type: string, fn: Listener) => listeners.add(fn), removeEventListener: (_type: string, fn: Listener) => listeners.delete(fn), + navigator: readText ? { clipboard: { readText } } : {}, parent: { postMessage: (message: Record) => posted.push(message), }, @@ -60,6 +63,28 @@ describe("readClipboardViaBridge", () => { expect(await pending).toBe("") }) + test("framed host WITHOUT the amicode relay (Simple Browser, plain embeds) falls back to navigator.clipboard", async () => { + // No reply ever comes — the request went into the void. The API key being + // pasted must still arrive via the host's own clipboard-read grant. + const bridge = fakeFramedWindow(() => Promise.resolve("sk-cloud-key-123")) + expect(await readClipboardViaBridge(bridge.win, 15)).toBe("sk-cloud-key-123") + }) + + test("a live bridge reply wins — navigator.clipboard is not consulted", async () => { + const bridge = fakeFramedWindow(() => { + throw new Error("must not be called when the bridge answered") + }) + const pending = readClipboardViaBridge(bridge.win) + const request = bridge.posted[0] + bridge.reply({ source: "amicode", kind: "clipboard", nonce: request.nonce, text: "from the bridge" }) + expect(await pending).toBe("from the bridge") + }) + + test("no relay AND no clipboard permission resolves empty (the caller's no-op stands)", async () => { + const bridge = fakeFramedWindow(() => Promise.reject(new Error("NotAllowedError"))) + expect(await readClipboardViaBridge(bridge.win, 15)).toBe("") + }) + test("resolves empty without posting when the app is not framed", async () => { const posted: unknown[] = [] const win = { diff --git a/packages/app/src/components/prompt-input/clipboard-bridge.ts b/packages/app/src/components/prompt-input/clipboard-bridge.ts index 8559bbacde..54ef6f58cf 100644 --- a/packages/app/src/components/prompt-input/clipboard-bridge.ts +++ b/packages/app/src/components/prompt-input/clipboard-bridge.ts @@ -26,6 +26,23 @@ export function writeClipboardViaBridge(text: string, win: Window = window): boo return true } +// The bridge only answers inside the AMICODE webview — its parent runs the +// relay. Every OTHER framed host (VS Code Simple Browser pointed at :3002, any +// plain iframe embed) posts into the void, times out, and — because the ⌘V +// handlers preventDefault before asking — pastes NOTHING, silently. So an +// empty/timed-out bridge reply falls through to navigator.clipboard.readText(): +// dead in the VS Code webview (no permission to lose), but it restores paste in +// every framed host that grants clipboard-read. API-key entry was the reported +// casualty (credential fields are exactly what you paste into an embedded app). +async function readTextDirect(win: Window): Promise { + try { + const text = await win.navigator?.clipboard?.readText?.() + return typeof text === "string" ? text : "" + } catch { + return "" // no permission / no API — the caller's no-op stands + } +} + export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise { return new Promise((resolve) => { // Unframed: native paste works — don't post into the void or wait out the timeout. @@ -40,7 +57,11 @@ export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_ const finish = (text: string) => { win.removeEventListener("message", onMessage) if (timer !== undefined) clearTimeout(timer) - resolve(text) + if (text) { + resolve(text) + return + } + void readTextDirect(win).then(resolve) } const onMessage = (event: MessageEvent) => { diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 4bcb8fadba..8669dcac53 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -25,6 +25,7 @@ import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } fr import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" +import { vaultPanel } from "@/context/vault-panel" import { useSettings } from "@/context/settings" import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" @@ -698,6 +699,16 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { data-tauri-drag-region onMouseDown={drag} > + + +
{!tauriApi() &&
} @@ -725,11 +736,23 @@ type TitlebarV2RightState = { } function TitlebarV2Right(props: { state: TitlebarV2RightState }) { + const language = useLanguage() return (
+ } + state={vaultPanel.opened() ? "pressed" : undefined} + onClick={vaultPanel.toggle} + aria-label={language.t(vaultPanel.opened() ? "amicode.vault.close" : "amicode.vault.open")} + aria-expanded={vaultPanel.opened()} + />
) diff --git a/packages/app/src/components/vault-panel.tsx b/packages/app/src/components/vault-panel.tsx new file mode 100644 index 0000000000..e4c9d57bc9 --- /dev/null +++ b/packages/app/src/components/vault-panel.tsx @@ -0,0 +1,290 @@ +// amicode: the Vault panel — the vault, and every file that makes it up, +// living IN the interface (Aaron, 2026-07-26). A dismissible drawer beside the +// Chat (CONTEXT.md's Panel concept): pick a mount, browse its real files, and +// read markdown / source inline. Data comes from the read-only +// /amicode/vault-files + /amicode/vault-file routes (vault-browser.ts) — the +// same mounts the agent itself reads through its per-mount grants. +// Opened by the rail's vault button, the command palette, or a context-tree +// click on a vault node (which lands directly on that file). +import { For, Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Markdown } from "@opencode-ai/ui/markdown" +import { useLanguage } from "@/context/language" +import { useServer } from "@/context/server" +import { amicodeGet } from "@/utils/amicode-fetch" +import { vaultPanel } from "@/context/vault-panel" + +type Mount = { id: string; kind: string; writable: boolean } +type Entry = { path: string; name: string; size: number; readable: boolean } +type Dir = { name: string; path: string; dirs: Dir[]; files: Entry[] } + +function foldTree(files: Entry[]): Dir { + const root: Dir = { name: "", path: "", dirs: [], files: [] } + const dirOf = new Map([["", root]]) + const ensureDir = (p: string): Dir => { + const known = dirOf.get(p) + if (known) return known + const cut = p.lastIndexOf("/") + const parent = ensureDir(cut < 0 ? "" : p.slice(0, cut)) + const dir: Dir = { name: cut < 0 ? p : p.slice(cut + 1), path: p, dirs: [], files: [] } + parent.dirs.push(dir) + dirOf.set(p, dir) + return dir + } + for (const f of files) { + const cut = f.path.lastIndexOf("/") + ensureDir(cut < 0 ? "" : f.path.slice(0, cut)).files.push(f) + } + return root +} + +export function VaultPanel() { + const language = useLanguage() + const server = useServer() + + const [mountsRaw] = createResource( + () => (vaultPanel.opened() ? server.current : undefined), + (conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined), + ) + const mounts = createMemo(() => { + const raw = mountsRaw() as { mounts?: Mount[] } | undefined + return Array.isArray(raw?.mounts) ? raw.mounts.filter((m) => typeof m?.id === "string") : [] + }) + + const [chosenMount, setChosenMount] = createSignal(undefined) + const mount = createMemo(() => { + const chosen = chosenMount() + if (chosen && mounts().some((m) => m.id === chosen)) return chosen + return mounts()[0]?.id + }) + + const [listingRaw] = createResource( + () => (vaultPanel.opened() && mount() && server.current ? { conn: server.current, mount: mount()! } : undefined), + (key) => amicodeGet(key.conn, `/amicode/vault-files?mount=${encodeURIComponent(key.mount)}`).catch(() => undefined), + ) + const listing = createMemo(() => { + const raw = listingRaw() as { ok?: boolean; truncated?: boolean; files?: Entry[]; error?: string } | undefined + if (!raw) return undefined + if (!raw.ok || !Array.isArray(raw.files)) return { error: raw.error ?? "unreadable" } as const + return { tree: foldTree(raw.files), truncated: !!raw.truncated, total: raw.files.length } as const + }) + + const [expanded, setExpanded] = createSignal(new Set(), { equals: false }) + const toggleDir = (p: string) => { + const next = new Set(expanded()) + if (next.has(p)) next.delete(p) + else next.add(p) + setExpanded(next) + } + + const [selected, setSelected] = createSignal(undefined) + const [fileRaw] = createResource( + () => + vaultPanel.opened() && mount() && selected()?.readable && server.current + ? { conn: server.current, mount: mount()!, path: selected()!.path } + : undefined, + (key) => + amicodeGet( + key.conn, + `/amicode/vault-file?mount=${encodeURIComponent(key.mount)}&path=${encodeURIComponent(key.path)}`, + ).catch(() => undefined), + ) + const fileBody = createMemo(() => { + const raw = fileRaw() as { ok?: boolean; content?: string; error?: string } | undefined + if (!raw) return undefined + return raw.ok && typeof raw.content === "string" ? { content: raw.content } : { error: raw.error ?? "unreadable" } + }) + + // a context-tree click lands directly on its file: pick the mount, unfold + // the ancestors, select — then clear so the next open starts at the tree + createEffect(() => { + const target = vaultPanel.target() + if (!target || !vaultPanel.opened()) return + setChosenMount(target.mount) + const parts = target.path.split("/") + const next = new Set(expanded()) + for (let i = 1; i < parts.length; i++) next.add(parts.slice(0, i).join("/")) + setExpanded(next) + setSelected({ + path: target.path, + name: parts[parts.length - 1], + size: 0, + readable: true, + }) + vaultPanel.clearTarget() + }) + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape" || !vaultPanel.opened()) return + e.preventDefault() + if (selected()) setSelected(undefined) + else vaultPanel.close() + } + window.addEventListener("keydown", onKeyDown) + onCleanup(() => window.removeEventListener("keydown", onKeyDown)) + + const renderDir = (dir: Dir, depth: number) => ( + <> + a.name.localeCompare(b.name))}> + {(sub) => ( + <> + + {renderDir(sub, depth + 1)} + + )} + + a.name.localeCompare(b.name))}> + {(f) => ( + + )} + + + ) + + return ( + +
} + > + {(ok) => ( + <> + {renderDir(ok().tree, 0)} + +
+ {language.t("amicode.vault.truncated")} +
+
+ + )} + + )} + +
+
+ + +
+ + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+ } + > + {(body) => ( + {language.t("amicode.vault.error")}
} + > + {(ok) => ( + + {ok().content} + + } + > + + + )} + + )} + + + + + + ) +} diff --git a/packages/app/src/context/vault-panel.ts b/packages/app/src/context/vault-panel.ts new file mode 100644 index 0000000000..9233ffc6a0 --- /dev/null +++ b/packages/app/src/context/vault-panel.ts @@ -0,0 +1,29 @@ +// amicode: the Vault panel's open-state + pending file target. A module-scope +// singleton (one drawer per window) rather than a provider — the panel and its +// callers (context-tree clicks, the rail button, the command palette) live in +// unrelated subtrees, and threading a context through all of them buys nothing. +import { createSignal } from "solid-js" + +export type VaultFileTarget = { mount: string; path: string } + +const [opened, setOpened] = createSignal(false) +const [target, setTarget] = createSignal(undefined) + +export const vaultPanel = { + opened, + /** the file the panel should show once open (set by context-tree clicks) */ + target, + open(t?: VaultFileTarget) { + if (t) setTarget(t) + setOpened(true) + }, + close() { + setOpened(false) + }, + toggle() { + setOpened((x) => !x) + }, + clearTarget() { + setTarget(undefined) + }, +} diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index 67cc20ea7b..6f660ffd43 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -148,15 +148,19 @@ export const DESKTOP_MENU: DesktopMenu[] = [ { type: "item", label: "Reload", action: "view.reload", role: "reload" }, { type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" }, { type: "separator" }, + // action-only, no native role: on macOS a role becomes Electron's own + // zoom (multiplicative levels, no zoom-factor-changed event) while the + // renderer keydown ALSO handles Cmd+=/-/0 — two systems fighting over + // one chord, with the titlebar/terminal reading a stale factor. The + // action path is the single implementation on every platform. { type: "item", label: "Actual Size", action: "view.resetZoom", - role: "resetZoom", - accelerator: { windows: "Ctrl+0" }, + accelerator: { macos: "Cmd+0", windows: "Ctrl+0" }, }, - { type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } }, - { type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } }, + { type: "item", label: "Zoom In", action: "view.zoomIn", accelerator: { macos: "Cmd+=", windows: "Ctrl++" } }, + { type: "item", label: "Zoom Out", action: "view.zoomOut", accelerator: { macos: "Cmd+-", windows: "Ctrl+-" } }, { type: "separator" }, { type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" }, ], diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index c50c07d28e..4c81dfccf9 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -8,6 +8,7 @@ import { type Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" import { installGlobalClipboardFallback } from "@/utils/global-clipboard" +import { webZoom } from "@/utils/web-zoom" import { handleNotificationClick } from "@/utils/notification-click" import { authFromToken } from "@/utils/server" import pkg from "../package.json" @@ -167,6 +168,7 @@ const platform: Platform = { forward, restart, notify, + webviewZoom: webZoom, getDefaultServer: async () => { const stored = readDefaultServerUrl() return stored ? ServerConnection.Key.make(stored) : null diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d68e3e1c3b..d18f497936 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -729,6 +729,24 @@ export const dict = { "amicode.unavailable": "status unavailable", "amicode.fetchFailed": "Could not reach the server for problem status", "amicode.editInChat": "Edit in chat", + "amicode.contextTree.title": "Context tree", + "amicode.contextTree.count": "{{count}} in context", + "amicode.contextTree.fit": "Fit tree to view", + "amicode.contextTree.collapse": "Collapse context tree", + "amicode.contextTree.expand": "Expand context tree", + "amicode.contextTree.canvasLabel": + "The agent's context tree — turns branch from amico; click a file node to open it", + "amicode.vault.title": "Vault", + "amicode.vault.open": "Open vault", + "amicode.vault.close": "Close vault panel", + "amicode.vault.back": "Back to files", + "amicode.vault.empty": "No vaults attached yet", + "amicode.vault.error": "Could not read this vault", + "amicode.vault.unreadable": "This file can't be previewed here", + "amicode.vault.truncated": "Listing truncated — the vault is larger than the panel shows", + "amicode.zoomIn": "Zoom in", + "amicode.zoomOut": "Zoom out", + "amicode.zoomReset": "Reset zoom", "session.share.popover.title": "Publish on web", "session.share.popover.description.shared": diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index cec98a1774..4beaa1f6ab 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -15,6 +15,8 @@ import { import { makeEventListener } from "@solid-primitives/event-listener" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" +import { VaultPanel } from "@/components/vault-panel" +import { webZoomIn, webZoomOut, webZoomReset } from "@/utils/web-zoom" import { useServerSync } from "@/context/server-sync" import { Persist, persisted } from "@/utils/persist" import { base64Encode } from "@opencode-ai/core/util/encode" @@ -1001,6 +1003,34 @@ export default function Layout(props: ParentProps) { keybind: "mod+comma", onSelect: () => openSettings(), }, + // web host only: the desktop build zooms through Electron (menu roles + // were retired for the action path), and registering the same chords + // here would double-fire against its renderer keydown handler + ...(platform.platform === "web" + ? [ + { + id: "view.zoomIn", + title: language.t("amicode.zoomIn"), + category: language.t("command.category.view"), + keybind: "mod+=", + onSelect: () => webZoomIn(), + }, + { + id: "view.zoomOut", + title: language.t("amicode.zoomOut"), + category: language.t("command.category.view"), + keybind: "mod+-", + onSelect: () => webZoomOut(), + }, + { + id: "view.zoomReset", + title: language.t("amicode.zoomReset"), + category: language.t("command.category.view"), + keybind: "mod+0", + onSelect: () => webZoomReset(), + }, + ] + : []), ...(platform.platform === "desktop" && platform.exportDebugLogs ? [ { @@ -2285,6 +2315,7 @@ export default function Layout(props: ParentProps) { {props.children} + } @@ -2437,6 +2468,7 @@ export default function Layout(props: ParentProps) { + diff --git a/packages/app/src/pages/session/brain-strip.tsx b/packages/app/src/pages/session/brain-strip.tsx deleted file mode 100644 index 57b7581cc6..0000000000 --- a/packages/app/src/pages/session/brain-strip.tsx +++ /dev/null @@ -1,177 +0,0 @@ -// amicode: the amico brain — a permanent timeline row (stable key, kept -// mounted) living in the conversation flow: right beneath the thinking -// shimmer while a turn works, after the last message at rest. Height changes -// ride the timeline's own row-measurement and bottom-lock machinery. -// One brain instance per session view: completed messages replay instantly -// into the atlas, the busy message's tool calls animate live, and each -// completed turn with ≥2 commits is charted as a named constellation -// ("plate N · "). Auto-breathe: expands while thinking, -// lingers a beat after the ceremony, collapses to a 72px living slice — -// always visible, never hidden; click (or Enter/Space) overrides until the -// next turn reclaims auto. An open question dock forces the collapsed -// slice (amico is waiting on the user, not thinking). -// -// The graph renders on an IN-DOCUMENT canvas (brain-engine), not an iframe. -// The old /brain.html embed broke three separate ways at the frame boundary: -// document requests can't carry server auth (armed password ⇒ 401 ⇒ blank -// strip), a parent/child color-scheme mismatch composites the transparent -// frame opaque white (async webview theming ⇒ white box + full reload on -// every flip), and the page's own stylesheet ground + prototype chrome -// painted an unwanted first frame before script hid them. Native, there is -// no fetch, no second document, no handshake — the canvas is transparent -// from frame zero, events are direct calls, and a theme flip repaints -// without losing the atlas. - -import { createEffect, createMemo, createSignal, on, onCleanup, onMount, Show } from "solid-js" -import { useSync } from "@/context/sync" -import { amicoBrainRef, type AmicoBrainRef } from "@opencode-ai/ui/brain-ref" -import { createBrainEngine, type BrainEngine } from "@opencode-ai/ui/brain-engine" - -type BrainTouch = { id: string } & AmicoBrainRef -type BrainEvent = ({ kind: "touch"; replay: boolean } & BrainTouch) | { kind: "chart"; id: string; title: string } - -export function BrainStrip(props: { sessionID?: string }) { - // keyed remount per session: a fresh brain restores the new session's atlas - return {(sid) => } -} - -function BrainFrame(props: { sessionID: string }) { - const sync = useSync() - const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) - const getParts = (msgId: string) => sync.data.part[msgId] ?? [] - const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle") - - const turnTitle = (parentID: string | undefined) => { - const parent = parentID ? messages().find((m) => m.id === parentID) : undefined - if (!parent) return "" - for (const p of getParts(parent.id)) { - if (p.type === "text" && typeof p.text === "string" && p.text.trim()) return p.text.trim().slice(0, 28) - } - return "" - } - - // the session's event stream: touches in message order, plus a chart marker - // after each completed assistant message that committed ≥2 touches - const events = createMemo(() => { - const out: BrainEvent[] = [] - for (const m of messages()) { - if (m.role !== "assistant") continue - const done = typeof m.time?.completed === "number" - let commits = 0 - for (const p of getParts(m.id)) { - if (p.type !== "tool") continue - const ref = amicoBrainRef(p.tool, p.state.input ?? {}) - if (!ref) continue - if (!ref.consider) commits++ - out.push({ kind: "touch", replay: done, id: p.id, ...ref }) - } - if (done && commits >= 2) out.push({ kind: "chart", id: `chart-${m.id}`, title: turnTitle(m.parentID) }) - } - return out - }) - - // an unresolved question means the dock is expanded into this region - const questionOpen = createMemo(() => - messages().some( - (m) => - m.role === "assistant" && - typeof m.time?.completed !== "number" && - getParts(m.id).some( - (p) => - p.type === "tool" && p.tool === "question" && (p.state.status === "pending" || p.state.status === "running"), - ), - ), - ) - - // auto-breathe with manual override - const [manual, setManual] = createSignal(null) - const [linger, setLinger] = createSignal(false) - createEffect( - on(busy, (b, prev) => { - if (b) setManual(null) // a new turn reclaims auto - if (prev && !b) { - setLinger(true) - const t = setTimeout(() => setLinger(false), 5000) - onCleanup(() => clearTimeout(t)) - } - }), - ) - const expanded = () => !questionOpen() && (manual() ?? (busy() || linger())) - - // the engine is created when the canvas mounts and destroyed with the row; - // theme flips are direct, lossless repaints — no reload, no re-flush - const [engine, setEngine] = createSignal() - const currentScheme = () => (document.documentElement.dataset.colorScheme === "light" ? "light" : "dark") - // refs run before layout (clientWidth/Height are 0 at creation), so seed the - // engine with the strip's real height — the camera's close-up vs whole-network - // branch keys on it — and re-measure once mounted - onMount(() => engine()?.resize()) - const themeObserver = new MutationObserver(() => engine()?.setTheme(currentScheme())) - themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-color-scheme"] }) - onCleanup(() => themeObserver.disconnect()) - onCleanup(() => engine()?.destroy()) - - // amicode: hovering a tool row in the log glances at its node on the map - // (emitted by packages/ui message-part via the amicode:brain-hover event) - const onToolHover = (e: Event) => { - const d = (e as CustomEvent).detail as { label?: string } | undefined - if (d?.label) engine()?.highlight(d.label) - } - window.addEventListener("amicode:brain-hover", onToolHover) - onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover)) - - const sent = new Set() - let initialFlush = true - createEffect(() => { - const evs = events() - const brain = engine() - if (!brain) return - const replayCharts = initialFlush // charts already on the atlas restore silently - for (const ev of evs) { - if (sent.has(ev.id)) continue - sent.add(ev.id) - if (ev.kind === "touch") brain.touch({ label: ev.label, type: ev.type, consider: ev.consider, replay: ev.replay }) - else brain.chart(ev.title, replayCharts) - } - initialFlush = false - }) - - return ( -
setManual(!expanded())} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - setManual(!expanded()) - } - }} - > - - setEngine( - createBrainEngine(el, { - scheme: currentScheme(), - size: { width: 800, height: expanded() ? 224 : 72 }, - }), - ) - } - aria-hidden="true" - class="pointer-events-none block h-full w-full" - /> -
- ) -} diff --git a/packages/app/src/pages/session/context-tree-panel.tsx b/packages/app/src/pages/session/context-tree-panel.tsx new file mode 100644 index 0000000000..ccfc251d1f --- /dev/null +++ b/packages/app/src/pages/session/context-tree-panel.tsx @@ -0,0 +1,255 @@ +// amicode: the context tree — the knowledge graph, moved to the TOP PANEL and +// redesigned around the session (Aaron, 2026-07-26). A pinned, collapsible +// panel between the session header and the chat that charts the agent's +// actual context: root = amico, branches = the session's turns, leaves = the +// markdown / source / skills / agents each turn pulled in. Unlike the retired +// inline brain strip this surface is INTERACTIVE — clicking a file node opens +// the real file (project files in a session tab, vault files in the Vault +// panel). Hovering a tool row in the log still glances at its node here via +// the same amicode:brain-hover event the strip used. +import { createEffect, createMemo, createSignal, onCleanup, onMount, For, Show } from "solid-js" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { useSync } from "@/context/sync" +import { useFile } from "@/context/file" +import { useLanguage } from "@/context/language" +import { amicoBrainRef } from "@opencode-ai/ui/brain-ref" +import { + createContextTreeEngine, + contextTreeKindColor, + type ContextTreeEngine, + type ContextTreeKind, + type ContextTreeScheme, + type ContextTreeSelection, +} from "@opencode-ai/ui/context-tree-engine" +import { buildContextTree, vaultRefFromPath, type ContextTurn } from "@opencode-ai/ui/context-tree-data" +import { vaultPanel } from "@/context/vault-panel" +import { createOpenSessionFileTab } from "@/pages/session/helpers" +import { useSessionLayout } from "@/pages/session/session-layout" + +const OPEN_KEY = "amicode-context-tree-open" + +/** The panel's session gate, shared with the timeline header so its bottom + * padding can defer to the tree block when the tree will render: ≥1 commit + * touch anywhere in the session (same refs the tree charts). */ +export function sessionHasContextItems( + messages: readonly { id: string; role?: string }[], + partsFor: (messageID: string) => readonly { type?: string; tool?: string; state?: { input?: unknown } }[], +): boolean { + for (const m of messages) { + if (m.role !== "assistant") continue + for (const p of partsFor(m.id) ?? []) { + if (p?.type !== "tool" || typeof p.tool !== "string") continue + const ref = amicoBrainRef(p.tool, (p.state?.input as Record) ?? {}) + if (ref && !ref.consider) return true + } + } + return false +} +const LEGEND: { kind: ContextTreeKind; label: string }[] = [ + { kind: "note", label: "notes" }, + { kind: "source", label: "source" }, + { kind: "skill", label: "skills" }, + { kind: "agent", label: "agents" }, +] + +export function ContextTreePanel(props: { sessionID?: string }) { + // keyed remount per session: a fresh tree charts the new session's context + return ( + + {(sid) => } + + ) +} + +function ContextTreeFrame(props: { sessionID: string }) { + const sync = useSync() + const file = useFile() + const language = useLanguage() + const { tabs, view } = useSessionLayout() + + const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) + const getParts = (msgId: string) => sync.data.part[msgId] ?? [] + const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle") + + const turnTitle = (parentID: string | undefined) => { + const parent = parentID ? messages().find((m) => m.id === parentID) : undefined + if (!parent) return "" + for (const p of getParts(parent.id)) { + if (p.type === "text" && typeof p.text === "string" && p.text.trim()) return p.text.trim() + } + return "" + } + + // the session's turns: ONE branch per user prompt. A single ask can span + // several assistant messages (continuation steps), and charting per + // assistant message drew duplicate roman-numeral branches carrying the + // same prompt excerpt — group by the parent user message instead. + const turns = createMemo(() => { + const byPrompt = new Map() + const out: ContextTurn[] = [] + for (const m of messages()) { + if (m.role !== "assistant") continue + const key = m.parentID ?? m.id + let turn = byPrompt.get(key) + if (!turn) { + turn = { id: key, title: turnTitle(m.parentID), refs: [], busy: false } + byPrompt.set(key, turn) + out.push(turn) + } + for (const p of getParts(m.id)) { + if (p.type !== "tool") continue + const ref = amicoBrainRef(p.tool, p.state.input ?? {}) + if (!ref || ref.consider) continue + turn.refs.push(ref) + } + if (typeof m.time?.completed !== "number" && busy()) turn.busy = true + } + return out.filter((t) => t.refs.length > 0) + }) + + const itemCount = createMemo(() => { + const seen = new Set() + for (const t of turns()) for (const r of t.refs) seen.add(r.path ?? r.label.toLowerCase()) + return seen.size + }) + + // collapse is a window preference, not per-session state + const [open, setOpen] = createSignal(localStorage.getItem(OPEN_KEY) !== "false") + const toggle = () => { + const next = !open() + setOpen(next) + localStorage.setItem(OPEN_KEY, String(next)) + } + + const [engine, setEngine] = createSignal() + const currentScheme = (): ContextTreeScheme => + document.documentElement.dataset.colorScheme === "light" ? "light" : "dark" + const [scheme, setScheme] = createSignal( + typeof document === "undefined" ? "dark" : currentScheme(), + ) + + // node clicks open the real thing: vault files through the Vault panel, + // project files as a session file tab (same flow as the file tree) + const openTab = createOpenSessionFileTab({ + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + openTab: (tab) => tabs().open(tab), + pathFromTab: file.pathFromTab, + loadFile: file.load, + openReviewPanel: () => { + if (!view().reviewPanel.opened()) view().reviewPanel.open() + }, + setActive: (tab) => tabs().setActive(tab), + }) + const onSelect = (node: ContextTreeSelection) => { + if (!node.path) return + const vaultRef = vaultRefFromPath(node.path) + if (vaultRef) { + vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel }) + return + } + openTab(file.tab(node.path)) + } + + onMount(() => engine()?.resize()) + const themeObserver = new MutationObserver(() => { + setScheme(currentScheme()) + engine()?.setTheme(currentScheme()) + }) + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-color-scheme"] }) + onCleanup(() => themeObserver.disconnect()) + onCleanup(() => engine()?.destroy()) + + // hovering a tool row in the log glances at its node on the tree — the same + // event the brain strip consumed, so message-part needs no change + const onToolHover = (e: Event) => { + const d = (e as CustomEvent).detail as { label?: string } | undefined + if (d?.label) engine()?.highlight(d.label) + } + window.addEventListener("amicode:brain-hover", onToolHover) + onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover)) + + createEffect(() => { + const brain = engine() + if (!brain) return + brain.setTree(buildContextTree(turns())) + }) + // folded away — stop burning frames + createEffect(() => { + const brain = engine() + if (!brain) return + if (open()) brain.resume() + else brain.pause() + }) + + const hasItems = createMemo(() => itemCount() > 0) + + return ( + // folded into the sticky session header (Kate, 2026-07-27): no card + // chrome of its own — a hairline seam + the header's solid ground (the + // canvas must not sit on the header's fade-to-transparent gradient). + // Absent entirely until the session holds ≥1 context item. + +
+
+
{language.t("amicode.contextTree.title")}
+
+ {language.t("amicode.contextTree.count", { count: itemCount() })} +
+ + + +
+ + engine()?.fit()} + aria-label={language.t("amicode.contextTree.fit")} + /> + + +
+
+ + setEngine( + createContextTreeEngine(el, { + scheme: currentScheme(), + size: { width: 900, height: 192 }, + onSelect, + }), + ) + } + role="img" + aria-label={language.t("amicode.contextTree.canvasLabel")} + class="block h-full w-full" + /> +
+
+ + ) +} diff --git a/packages/app/src/pages/session/message-timeline.data.ts b/packages/app/src/pages/session/message-timeline.data.ts index 80828a9022..0643a424ea 100644 --- a/packages/app/src/pages/session/message-timeline.data.ts +++ b/packages/app/src/pages/session/message-timeline.data.ts @@ -66,9 +66,6 @@ export namespace TimelineRow { userMessageID: string }> {} export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {} - // amicode: the session's living map — a permanent row anchored after the - // thinking row (last content row before the spacer) - export class Brain extends Data.TaggedClass("Brain")<{}> {} export type TimelineRow = | CommentStrip @@ -79,7 +76,6 @@ export namespace TimelineRow { | DiffSummary | Error | Retry - | Brain | BottomSpacer export const key = (row: TimelineRow) => { @@ -100,8 +96,6 @@ export namespace TimelineRow { return `error:${row.userMessageID}` case "Retry": return `retry:${row.userMessageID}` - case "Brain": - return "brain" case "BottomSpacer": return "bottom-spacer" } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index e20de8dfe6..0d3bcc05ca 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -19,7 +19,8 @@ import { useMutation } from "@tanstack/solid-query" import { Virtualizer, type VirtualizerHandle } from "virtua/solid" import { Accordion } from "@opencode-ai/ui/accordion" import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner" -import { AmicodeEntityRail } from "@opencode-ai/ui/amicode-entity-rail" +import { AmicodeEntityRail, sessionHasAmicodeParts } from "@opencode-ai/ui/amicode-entity-rail" +import { ContextTreePanel, sessionHasContextItems } from "@/pages/session/context-tree-panel" import { AmicodeEntityView, entityLabel, @@ -85,7 +86,6 @@ import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" import { makeTimer } from "@solid-primitives/timer" import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data" -import { BrainStrip } from "./brain-strip" const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] @@ -93,7 +93,7 @@ const emptyTools: ToolPart[] = [] const emptyAssistantMessages: AssistantMessage[] = [] const idle = { type: "idle" as const } -type FramedTimelineRow = Exclude +type FramedTimelineRow = Exclude type TimelineRowByTag = Extract function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) { @@ -456,6 +456,11 @@ export function MessageTimeline(props: { }) const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new")) const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts + // the same session gates the rail and context tree apply internally — they + // drive the header's bottom padding: the tree block closes the header when + // present, chips need their seat otherwise + const entityRailVisible = createMemo(() => sessionHasAmicodeParts(sessionMessages(), getMsgParts)) + const contextTreeVisible = createMemo(() => sessionHasContextItems(sessionMessages(), getMsgParts)) const childTaskDescription = createMemo(() => { const id = sessionID() if (!id) return @@ -497,7 +502,7 @@ export function MessageTimeline(props: { const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => { const rows = messageRowMemos().flatMap((memo) => memo()) if (rows.length === 0) return rows - return reuseTimelineRows(previous, [...rows, new TimelineRow.Brain(), new TimelineRow.BottomSpacer()]) + return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()]) }) const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys }) const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys())) @@ -521,10 +526,6 @@ export function MessageTimeline(props: { const keepMounted = createMemo(() => { const rows = timelineRows() const out: number[] = [] - // amicode: the brain row stays mounted even when scrolled out of range — - // its iframe must never be torn down mid-session - const brainIndex = rows.findIndex((row) => row._tag === "Brain") - if (brainIndex >= 0) out.push(brainIndex) const id = activeMessageID() if (id) { const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id) @@ -1352,22 +1353,6 @@ export function MessageTimeline(props: { ) } - case "Brain": - // amicode: the session's living map — in the flow, right beneath the - // thinking shimmer while a turn works, after the last message at rest. - // Same column constraint as framed rows: without it the card overshoots - // the centered content container. - return ( -
- -
- ) case "BottomSpacer": return diff --git a/packages/app/src/pages/session/use-amicode-commands.tsx b/packages/app/src/pages/session/use-amicode-commands.tsx index b9a072be9d..760680081b 100644 --- a/packages/app/src/pages/session/use-amicode-commands.tsx +++ b/packages/app/src/pages/session/use-amicode-commands.tsx @@ -25,6 +25,19 @@ export function useAmicodeCommands() { const command = useCommand() const amico = (option: Omit): CommandOption => ({ ...option, category: "Amico" }) + // Not gated on inAmicode: the Vault panel talks straight to the server's + // /amicode/vault-* routes, so it works in any host. + command.register("amicode-vault", () => [ + amico({ + id: "vault.toggle", + title: "Toggle vault panel", + description: "Browse the attached vault mounts and read notes inline", + onSelect: () => { + void import("@/context/vault-panel").then((m) => m.vaultPanel.toggle()) + }, + }), + ]) + command.register("amicode", () => inAmicode() ? [ diff --git a/packages/app/src/utils/web-zoom.ts b/packages/app/src/utils/web-zoom.ts new file mode 100644 index 0000000000..e9032bd1da --- /dev/null +++ b/packages/app/src/utils/web-zoom.ts @@ -0,0 +1,46 @@ +// amicode: in-app zoom for the WEB host (the amicode VS Code webview and the +// plain browser at :3002). The desktop build zooms through Electron +// (webview-zoom.ts); the web build had NO zoom at all — Cmd+=/-/0 fell +// through to the host, which zooms the whole editor window (or the browser +// tab), never the app. This module owns a CSS zoom on the document root, +// persisted per-window, and feeds the same platform.webviewZoom signal the +// titlebar and terminal already watch. +import { createSignal } from "solid-js" + +const KEY = "amicode-zoom" +const MIN = 0.5 +const MAX = 3 +const STEP = 0.1 + +const clamp = (value: number) => Math.min(Math.max(value, MIN), MAX) + +function readSaved(): number { + if (typeof localStorage === "undefined") return 1 + const parsed = Number.parseFloat(localStorage.getItem(KEY) ?? "1") + return Number.isFinite(parsed) ? clamp(parsed) : 1 +} + +const [webZoom, setSignal] = createSignal(readSaved()) + +function apply(zoom: number) { + if (typeof document === "undefined") return + if (zoom === 1) document.documentElement.style.removeProperty("zoom") + else document.documentElement.style.setProperty("zoom", String(zoom)) +} +apply(webZoom()) + +export function setWebZoom(next: number) { + const zoom = Math.round(clamp(next) * 100) / 100 + apply(zoom) + setSignal(zoom) + try { + localStorage.setItem(KEY, String(zoom)) + } catch { + // storage may be unavailable in a sandboxed webview — zoom still applies + } +} + +export const webZoomIn = () => setWebZoom(webZoom() + STEP) +export const webZoomOut = () => setWebZoom(webZoom() - STEP) +export const webZoomReset = () => setWebZoom(1) +export { webZoom } diff --git a/packages/desktop/src/main/desktop-menu-actions.ts b/packages/desktop/src/main/desktop-menu-actions.ts index aa15e05e27..71aff2e1bb 100644 --- a/packages/desktop/src/main/desktop-menu-actions.ts +++ b/packages/desktop/src/main/desktop-menu-actions.ts @@ -1,6 +1,6 @@ import { BrowserWindow } from "electron" import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" -import { createMainWindow, updateTitlebar } from "./windows" +import { createMainWindow, updateZoom } from "./windows" export type DesktopMenuActionHandlers = Partial<{ checkForUpdates: () => void @@ -80,5 +80,7 @@ export function runDesktopMenuAction( function setZoom(win: BrowserWindow | null, value: number) { if (!win) return win.webContents.setZoomFactor(Math.min(Math.max(value, 0.2), 10)) - updateTitlebar(win) + // updateZoom, not just updateTitlebar: the renderer's webviewZoom signal + // (titlebar counter-scale, terminal refit) must hear about menu-driven zoom + updateZoom(win) } diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 4aaa64d250..5894a9f5c8 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -9,7 +9,7 @@ import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../prel import { runDesktopMenuAction } from "./desktop-menu-actions" import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" import { getStore } from "./store" -import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows" +import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateZoom } from "./windows" import type { UpdaterController } from "./updater-controller" import { createUpdaterSubscriptions } from "./updater-subscriptions" @@ -214,7 +214,7 @@ export function registerIpcHandlers(deps: Deps) { event.sender.setZoomFactor(factor) const win = BrowserWindow.fromWebContents(event.sender) if (!win) return - updateTitlebar(win) + updateZoom(win) }) ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled()) ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => { diff --git a/packages/desktop/src/main/store-keys.ts b/packages/desktop/src/main/store-keys.ts index f05018a269..39ef19eaac 100644 --- a/packages/desktop/src/main/store-keys.ts +++ b/packages/desktop/src/main/store-keys.ts @@ -2,3 +2,4 @@ export const SETTINGS_STORE = "opencode.settings" export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" export const WSL_SERVERS_KEY = "wslServers" export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" +export const ZOOM_FACTOR_KEY = "zoomFactor" diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index e0179f54c6..7a70587ee6 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -8,7 +8,7 @@ import { fileURLToPath, pathToFileURL } from "node:url" import type { TitlebarTheme } from "../preload/types" import { exportDebugLogs, write as writeLog } from "./logging" import { getStore } from "./store" -import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys" +import { PINCH_ZOOM_ENABLED_KEY, ZOOM_FACTOR_KEY } from "./store-keys" import { createUnresponsiveSampler } from "./unresponsive" const root = dirname(fileURLToPath(import.meta.url)) @@ -390,7 +390,10 @@ function isRendererUrl(value?: string, html = false) { function wireZoom(win: BrowserWindow) { pinchZoomEnabled.set(win, getPinchZoomEnabled()) - win.webContents.setZoomFactor(1) + // restore the saved zoom instead of hard-resetting to 100% — losing the + // zoom level on every relaunch/new window was the "my zoom keeps + // resetting" report + win.webContents.setZoomFactor(getSavedZoom()) win.webContents.on("zoom-changed", (event, zoomDirection) => { event.preventDefault() if (pinchZoomEnabled.get(win)) { @@ -407,9 +410,17 @@ function clampZoom(value: number) { return Math.min(Math.max(value, minZoomLevel), maxZoomLevel) } -function updateZoom(win: BrowserWindow) { +function getSavedZoom(): number { + const saved = getStore().get(ZOOM_FACTOR_KEY) + return typeof saved === "number" && Number.isFinite(saved) ? clampZoom(saved) : 1 +} + +/** The single choke point after a zoom change: titlebar overlay, the + * renderer's zoom signal, and persistence all stay in sync through here. */ +export function updateZoom(win: BrowserWindow) { updateTitlebar(win) win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor()) + getStore().set(ZOOM_FACTOR_KEY, win.webContents.getZoomFactor()) } function upsertKeyValue(obj: Record, keyToChange: string, value: any) { diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts index bc5b025be2..d4ff0f6c55 100644 --- a/packages/opencode/src/server/amicode/connections.ts +++ b/packages/opencode/src/server/amicode/connections.ts @@ -766,6 +766,12 @@ export function setBindHostname(hostname: string | undefined): string | undefine return previous } +/** The current bind hostname — shared with the vault-browser routes, whose + * loopback gate rides the same signal as the credential-mutation guard. */ +export function getBindHostname(): string | undefined { + return bindHostname +} + /** Same loopback family the mdns gate recognizes (server.ts), widened to the * whole 127/8 block and the v4-mapped form. undefined = in-process handler. */ export function isLoopbackHostname(hostname: string | undefined): boolean { diff --git a/packages/opencode/src/server/amicode/vault-browser.ts b/packages/opencode/src/server/amicode/vault-browser.ts new file mode 100644 index 0000000000..9f5154f7d2 --- /dev/null +++ b/packages/opencode/src/server/amicode/vault-browser.ts @@ -0,0 +1,194 @@ +// AMICODE: vault browser data source (GET /amicode/vault-files + /amicode/vault-file). +// Lists and reads files inside attached vault mounts so the app's Vault panel can +// browse the knowledge base the agent works from. Strictly read-only. Contract +// (same as vaults.ts): every builder returns a JSON string and never throws — +// failures come back as `{ok:false, error}` bodies the panel renders inline. +import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs" +import { homedir } from "node:os" +import path from "node:path" +import { getBindHostname, isLoopbackHostname } from "./connections" + +const MAX_FILES = 5_000 +const MAX_DEPTH = 12 +const MAX_BYTES = 512_000 + +/** Directories that are machinery, not knowledge — never listed. */ +const SKIP_DIRS = new Set(["node_modules", "__pycache__"]) + +/** Text file kinds the panel can render. Everything else is listed but marked + * unreadable so the tree still shows the vault's true shape (e.g. catalog + * binaries under git-lfs). */ +const TEXT_EXT = new Set([ + ".md", ".txt", ".jl", ".py", ".ts", ".tsx", ".js", ".jsx", ".json", ".toml", + ".yaml", ".yml", ".csv", ".tex", ".sh", ".css", ".html", ".xml", ".svg", ".gitignore", +]) + +function vaultsRoot(): string { + return process.env.AMICO_VAULTS_ROOT || path.join(homedir(), ".amico", "vaults") +} + +const err = (code: string, detail: string) => JSON.stringify({ ok: false, error: `${code}: ${detail}` }) + +/** Vault contents are proprietary knowledge (team mounts hold unpublished + * results); browsing is a LOCAL-researcher capability, not a server API. Same + * loopback family + bind signal as the credential-mutation guard + * (connections.ts) — a 0.0.0.0 / LAN-bound server refuses these routes even + * to authed callers, unless AMICO_VAULT_BROWSER=1 explicitly opts a shared + * deployment in (=0 forces off everywhere). */ +export function browseAllowed(env: Record = process.env): boolean { + const flag = env.AMICO_VAULT_BROWSER + if (flag === "1" || flag === "true") return true + if (flag === "0" || flag === "false") return false + return isLoopbackHostname(getBindHostname()) +} + +const browseRefusal = () => + err("forbidden", "vault browsing serves loopback servers only (set AMICO_VAULT_BROWSER=1 to override)") + +/** A vault opts out of the browser entirely with `browse = false` in its + * .amico-vault.toml marker — the agent's read grants are unaffected, but the + * panel will not list or serve a byte of it. */ +export function browseOptedOut(dir: string): boolean { + try { + return /^\s*browse\s*=\s*false\s*$/m.test(readFileSync(path.join(dir, ".amico-vault.toml"), "utf8")) + } catch { + return false + } +} + +const optOutRefusal = (mountId: string) => err("forbidden", `vault "${mountId}" opts out of browsing (browse = false)`) + +export function isTextFile(name: string): boolean { + const ext = path.extname(name).toLowerCase() + return TEXT_EXT.has(ext) || TEXT_EXT.has(name.toLowerCase()) +} + +/** Resolve a mount id (the `name` in its .amico-vault.toml marker, falling back + * to the directory basename — parity with vaults.ts scanMounts) to its real + * directory. When two mounts declare the same marker name, the one whose + * DIRECTORY is also named `id` wins, so a duplicate can't silently shadow a + * vault the researcher can see in the mount list. Returns undefined when no + * such mount exists. */ +export function mountDir(id: string, root: string = vaultsRoot()): string | undefined { + let entries: string[] + try { + entries = readdirSync(root).sort() + } catch { + return undefined + } + let byName: string | undefined + for (const base of entries) { + let text: string + try { + text = readFileSync(path.join(root, base, ".amico-vault.toml"), "utf8") + } catch { + continue + } + const name = text.match(/^\s*name\s*=\s*"([^"]*)"/m)?.[1] || base + if (name !== id) continue + if (base === id) return path.join(root, base) + byName ??= path.join(root, base) + } + return byName +} + +export type VaultFileEntry = { path: string; name: string; size: number; readable: boolean } + +/** Recursive listing of one mount as a FLAT array of relative paths (the panel + * folds it into a tree). Dotfiles and machinery dirs are skipped; the vault's + * own `.amico-vault.toml` marker is machinery too. */ +export function vaultFilesBody(mountId: string | undefined, root: string = vaultsRoot()): string { + if (!browseAllowed()) return browseRefusal() + if (!mountId) return err("bad_request", "mount is required") + const dir = mountDir(mountId, root) + if (!dir) return err("not_found", `no attached vault named "${mountId}"`) + if (browseOptedOut(dir)) return optOutRefusal(mountId) + let realRoot: string + try { + realRoot = realpathSync(dir) + } catch { + return err("not_found", `no attached vault named "${mountId}"`) + } + const files: VaultFileEntry[] = [] + let truncated = false + const walk = (abs: string, rel: string, depth: number) => { + if (depth > MAX_DEPTH || files.length >= MAX_FILES) { + truncated = true + return + } + let entries: string[] + try { + entries = readdirSync(abs).sort() + } catch { + return + } + for (const name of entries) { + if (files.length >= MAX_FILES) { + truncated = true + return + } + if (name.startsWith(".")) continue + if (SKIP_DIRS.has(name)) continue + const absChild = path.join(abs, name) + const relChild = rel ? `${rel}/${name}` : name + let st + try { + // lstat, not stat: a symlink inside the mount may point ANYWHERE, and + // a listing that follows it would enumerate names/sizes outside the + // vault (vaultFileBody blocks the read, but the metadata leaks). Only + // symlinks whose real target stays inside the mount are walked. + st = lstatSync(absChild) + if (st.isSymbolicLink()) { + const real = realpathSync(absChild) + if (real !== realRoot && !real.startsWith(realRoot + path.sep)) continue + st = statSync(real) + } + } catch { + continue + } + if (st.isDirectory()) walk(absChild, relChild, depth + 1) + else if (st.isFile()) + files.push({ path: relChild, name, size: st.size, readable: isTextFile(name) && st.size <= MAX_BYTES }) + } + } + walk(dir, "", 0) + return JSON.stringify({ ok: true, mount: mountId, count: files.length, truncated, files }) +} + +/** Read one file inside a mount. The traversal guard resolves REAL paths on + * both sides — mounts may themselves be symlinks (attachVault symlinks local + * vault dirs), so the containment check runs on resolved locations. */ +export function vaultFileBody(mountId: string | undefined, relPath: string | undefined, root: string = vaultsRoot()): string { + if (!browseAllowed()) return browseRefusal() + if (!mountId) return err("bad_request", "mount is required") + if (!relPath) return err("bad_request", "path is required") + const dir = mountDir(mountId, root) + if (!dir) return err("not_found", `no attached vault named "${mountId}"`) + if (browseOptedOut(dir)) return optOutRefusal(mountId) + let realRoot: string + let realTarget: string + try { + realRoot = realpathSync(dir) + realTarget = realpathSync(path.resolve(dir, relPath)) + } catch { + return err("not_found", `no such file in "${mountId}": ${relPath}`) + } + if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) + return err("forbidden", "path escapes the vault mount") + let st + try { + st = statSync(realTarget) + } catch { + return err("not_found", `no such file in "${mountId}": ${relPath}`) + } + if (!st.isFile()) return err("bad_request", "path is not a file") + if (st.size > MAX_BYTES) return err("too_large", `file is ${st.size} bytes (limit ${MAX_BYTES})`) + if (!isTextFile(realTarget)) return err("not_text", "not a renderable text file") + let content: string + try { + content = readFileSync(realTarget, "utf8") + } catch (e) { + return err("read_failed", String(e)) + } + return JSON.stringify({ ok: true, mount: mountId, path: relPath, size: st.size, content }) +} diff --git a/packages/opencode/src/server/amicode/widgets-src/library.ts b/packages/opencode/src/server/amicode/widgets-src/library.ts index 95d75dd81e..d2e6f82703 100644 --- a/packages/opencode/src/server/amicode/widgets-src/library.ts +++ b/packages/opencode/src/server/amicode/widgets-src/library.ts @@ -31,7 +31,7 @@ export default { '
' + (lib.count > 0 ? lib.count + ' paper' + (lib.count === 1 ? '' : 's') + ' \\u00b7 latest: ' + esc(lib.latestName || '') : 'upload papers \\u2014 Amico learns your work') + '
' + - '
' + + '
' + '' + (lib.latestPath ? 'Discuss latest →' : '') + '
' + diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 84c6134766..b8e0bc2ea9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -59,6 +59,7 @@ import { Workspace } from "@/control-plane/workspace" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/shared/ui" import * as AmicodeVaults from "@/server/amicode/vaults" +import * as AmicodeVaultBrowser from "@/server/amicode/vault-browser" import * as AmicodeProblems from "@/server/amicode/problems" import * as AmicodeWidgets from "@/server/amicode/widgets" import * as AmicodeDashboard from "@/server/amicode/dashboard" @@ -203,6 +204,25 @@ const amicodeVaultsRoute = HttpRouter.use((router) => return HttpServerResponse.text(out, { contentType: "application/json" }) }), ) + // Vault browser (the app's Vault panel): recursive read-only listing of one + // mount, and single-file reads with a real-path traversal guard. Builders + // never reject — failures come back as ok:false JSON bodies. + yield* router.add("GET", "/amicode/vault-files", (request) => + Effect.sync(() => { + const mount = new URL(request.url, "http://localhost").searchParams.get("mount") ?? undefined + return HttpServerResponse.text(AmicodeVaultBrowser.vaultFilesBody(mount), { contentType: "application/json" }) + }), + ) + yield* router.add("GET", "/amicode/vault-file", (request) => + Effect.sync(() => { + const params = new URL(request.url, "http://localhost").searchParams + const mount = params.get("mount") ?? undefined + const file = params.get("path") ?? undefined + return HttpServerResponse.text(AmicodeVaultBrowser.vaultFileBody(mount, file), { + contentType: "application/json", + }) + }), + ) // amicode#203: New-project creation — mkdir + best-effort git init. JSON // body {name, parentDir}; never rejects (failures come back as ok:false). yield* router.add("POST", "/amicode/project", (request) => diff --git a/packages/opencode/test/server/amicode-vault-browser.test.ts b/packages/opencode/test/server/amicode-vault-browser.test.ts new file mode 100644 index 0000000000..e626cc0e09 --- /dev/null +++ b/packages/opencode/test/server/amicode-vault-browser.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { browseAllowed, browseOptedOut, isTextFile, mountDir, vaultFileBody, vaultFilesBody } from "@/server/amicode/vault-browser" +import { setBindHostname } from "@/server/amicode/connections" + +// The suite runs with no listener bound (bindHostname undefined = in-process, +// loopback-equivalent), so the browse gate is open by default; gate tests +// simulate exposed binds explicitly and restore after. +afterEach(() => setBindHostname(undefined)) + +/** A vaults root with one mount ("armonia-test") holding a small vault. */ +function fixtureRoot() { + const root = mkdtempSync(path.join(tmpdir(), "vault-browser-root-")) + const mount = path.join(root, "armonia-test") + mkdirSync(path.join(mount, "insights"), { recursive: true }) + mkdirSync(path.join(mount, ".obsidian"), { recursive: true }) + mkdirSync(path.join(mount, "node_modules", "junk"), { recursive: true }) + writeFileSync(path.join(mount, ".amico-vault.toml"), 'kind = "personal"\nname = "armonia-test"\n') + writeFileSync(path.join(mount, "STRATEGY.md"), "# Strategy\n") + writeFileSync(path.join(mount, "insights", "gate-fidelity.md"), "# Gate fidelity\nSee [[STRATEGY]].\n") + writeFileSync(path.join(mount, "insights", "pulse.bin"), Buffer.from([0, 1, 2])) + writeFileSync(path.join(mount, ".obsidian", "workspace.json"), "{}") + writeFileSync(path.join(mount, "node_modules", "junk", "x.js"), "junk") + return { root, mount } +} + +describe("browse gates (proprietary vaults never serve off-box)", () => { + test("loopback / in-process binds may browse; exposed binds may not", () => { + expect(browseAllowed({})).toBe(true) // undefined bind = in-process + setBindHostname("127.0.0.1") + expect(browseAllowed({})).toBe(true) + setBindHostname("0.0.0.0") + expect(browseAllowed({})).toBe(false) + setBindHostname("192.168.1.20") + expect(browseAllowed({})).toBe(false) + }) + test("AMICO_VAULT_BROWSER overrides in both directions", () => { + setBindHostname("0.0.0.0") + expect(browseAllowed({ AMICO_VAULT_BROWSER: "1" })).toBe(true) + setBindHostname("127.0.0.1") + expect(browseAllowed({ AMICO_VAULT_BROWSER: "0" })).toBe(false) + }) + test("an exposed bind refuses both routes with the forbidden body", () => { + const { root } = fixtureRoot() + setBindHostname("0.0.0.0") + expect(JSON.parse(vaultFilesBody("armonia-test", root)).error).toMatch(/^forbidden: vault browsing/) + expect(JSON.parse(vaultFileBody("armonia-test", "STRATEGY.md", root)).error).toMatch(/^forbidden: vault browsing/) + }) + test("browse = false in the marker darkens the mount for listing AND reads", () => { + const { root, mount } = fixtureRoot() + writeFileSync(path.join(mount, ".amico-vault.toml"), 'kind = "team"\nname = "armonia-test"\nbrowse = false\n') + expect(browseOptedOut(mount)).toBe(true) + expect(JSON.parse(vaultFilesBody("armonia-test", root)).error).toMatch(/^forbidden: vault "armonia-test" opts out/) + expect(JSON.parse(vaultFileBody("armonia-test", "STRATEGY.md", root)).error).toMatch(/^forbidden:/) + }) +}) + +describe("isTextFile", () => { + test("markdown and source are text; binaries are not", () => { + expect(isTextFile("note.md")).toBe(true) + expect(isTextFile("solve.jl")).toBe(true) + expect(isTextFile("pulse.bin")).toBe(false) + expect(isTextFile("pulse.h5")).toBe(false) + }) +}) + +describe("mountDir", () => { + test("resolves by marker name, not directory basename", () => { + const root = mkdtempSync(path.join(tmpdir(), "vault-browser-root-")) + mkdirSync(path.join(root, "dir-basename")) + writeFileSync(path.join(root, "dir-basename", ".amico-vault.toml"), 'kind = "team"\nname = "real-name"\n') + expect(mountDir("real-name", root)).toBe(path.join(root, "dir-basename")) + expect(mountDir("dir-basename", root)).toBeUndefined() + }) + test("unknown mount / missing root → undefined", () => { + expect(mountDir("nope", "/does/not/exist")).toBeUndefined() + }) + test("duplicate marker names: the dir actually named like the id wins", () => { + const root = mkdtempSync(path.join(tmpdir(), "vault-browser-root-")) + for (const base of ["alpha", "dup"]) { + mkdirSync(path.join(root, base)) + writeFileSync(path.join(root, base, ".amico-vault.toml"), 'kind = "personal"\nname = "dup"\n') + } + expect(mountDir("dup", root)).toBe(path.join(root, "dup")) + }) +}) + +describe("vaultFilesBody", () => { + test("lists files recursively; skips dotfiles, .obsidian, node_modules", () => { + const { root } = fixtureRoot() + const out = JSON.parse(vaultFilesBody("armonia-test", root)) + expect(out.ok).toBe(true) + const paths = out.files.map((f: { path: string }) => f.path) + expect(paths).toEqual(["STRATEGY.md", "insights/gate-fidelity.md", "insights/pulse.bin"]) + }) + test("non-text files are listed but marked unreadable", () => { + const { root } = fixtureRoot() + const out = JSON.parse(vaultFilesBody("armonia-test", root)) + const bin = out.files.find((f: { path: string }) => f.path === "insights/pulse.bin") + expect(bin.readable).toBe(false) + const md = out.files.find((f: { path: string }) => f.path === "STRATEGY.md") + expect(md.readable).toBe(true) + }) + test("a symlinked dir escaping the mount is not listed (no metadata leak)", () => { + const { root, mount } = fixtureRoot() + const outside = mkdtempSync(path.join(tmpdir(), "outside-")) + writeFileSync(path.join(outside, "secret-notes.md"), "outside") + symlinkSync(outside, path.join(mount, "escape")) + const out = JSON.parse(vaultFilesBody("armonia-test", root)) + const paths = out.files.map((f: { path: string }) => f.path) + expect(paths.some((p: string) => p.startsWith("escape/"))).toBe(false) + }) + test("a symlink staying inside the mount is still listed", () => { + const { root, mount } = fixtureRoot() + symlinkSync(path.join(mount, "insights"), path.join(mount, "insights-link")) + const out = JSON.parse(vaultFilesBody("armonia-test", root)) + const paths = out.files.map((f: { path: string }) => f.path) + expect(paths).toContain("insights-link/gate-fidelity.md") + }) + test("missing mount param / unknown mount → ok:false", () => { + const { root } = fixtureRoot() + expect(JSON.parse(vaultFilesBody(undefined, root)).ok).toBe(false) + expect(JSON.parse(vaultFilesBody("ghost", root)).error).toMatch(/^not_found:/) + }) +}) + +describe("vaultFileBody", () => { + test("reads a markdown file", () => { + const { root } = fixtureRoot() + const out = JSON.parse(vaultFileBody("armonia-test", "insights/gate-fidelity.md", root)) + expect(out.ok).toBe(true) + expect(out.content).toContain("[[STRATEGY]]") + }) + test("path traversal is refused even via ..", () => { + const { root } = fixtureRoot() + writeFileSync(path.join(root, "secret.md"), "outside") + const out = JSON.parse(vaultFileBody("armonia-test", "../secret.md", root)) + expect(out.ok).toBe(false) + expect(out.error).toMatch(/^forbidden:/) + }) + test("symlink escaping the mount is refused", () => { + const { root, mount } = fixtureRoot() + const outside = mkdtempSync(path.join(tmpdir(), "outside-")) + writeFileSync(path.join(outside, "leak.md"), "leak") + symlinkSync(path.join(outside, "leak.md"), path.join(mount, "leak.md")) + const out = JSON.parse(vaultFileBody("armonia-test", "leak.md", root)) + expect(out.ok).toBe(false) + expect(out.error).toMatch(/^forbidden:/) + }) + test("symlinked MOUNT still reads its own files (attachVault symlinks local vaults)", () => { + const { mount } = fixtureRoot() + const root2 = mkdtempSync(path.join(tmpdir(), "vault-browser-root2-")) + symlinkSync(mount, path.join(root2, "linked")) + const out = JSON.parse(vaultFileBody("armonia-test", "STRATEGY.md", root2)) + expect(out.ok).toBe(true) + expect(out.content).toContain("# Strategy") + }) + test("binary → not_text; missing → not_found", () => { + const { root } = fixtureRoot() + expect(JSON.parse(vaultFileBody("armonia-test", "insights/pulse.bin", root)).error).toMatch(/^not_text:/) + expect(JSON.parse(vaultFileBody("armonia-test", "nope.md", root)).error).toMatch(/^not_found:/) + }) +}) diff --git a/packages/ui/src/amicode/brain-engine.ts b/packages/ui/src/amicode/brain-engine.ts index c47c184c51..4eee2d674b 100644 --- a/packages/ui/src/amicode/brain-engine.ts +++ b/packages/ui/src/amicode/brain-engine.ts @@ -519,6 +519,22 @@ export function createBrainEngine(canvas: HTMLCanvasElement, opts: BrainEngineOp ro = new ResizeObserver(() => resize()) ro.observe(canvas) } + // webview/app zoom changes devicePixelRatio WITHOUT changing the CSS box — + // ResizeObserver stays silent and the backing store goes stale (blurry + // graph until an unrelated layout resize). Watch the dppx itself and re-arm + // after every change, since the query is pinned to one exact ratio. + let dprQuery: MediaQueryList | null = null + const onDprChange = () => { + resize() + armDprWatch() + } + function armDprWatch() { + if (typeof matchMedia === "undefined" || typeof devicePixelRatio === "undefined") return + dprQuery?.removeEventListener("change", onDprChange) + dprQuery = matchMedia(`(resolution: ${devicePixelRatio}dppx)`) + dprQuery.addEventListener("change", onDprChange) + } + armDprWatch() const cam = { x: 0, y: 0, k: 1, tx: 0, ty: 0, tk: 1 } function nx(n: BNode) { @@ -1152,6 +1168,7 @@ export function createBrainEngine(canvas: HTMLCanvasElement, opts: BrainEngineOp if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(rafId) ro?.disconnect() motionQuery?.removeEventListener("change", onMotionChange) + dprQuery?.removeEventListener("change", onDprChange) }, stats: () => ({ scheme, diff --git a/packages/ui/src/amicode/brain-ref.ts b/packages/ui/src/amicode/brain-ref.ts index dc40e02495..145f098fd2 100644 --- a/packages/ui/src/amicode/brain-ref.ts +++ b/packages/ui/src/amicode/brain-ref.ts @@ -5,7 +5,13 @@ // (message-part) and the brain strip's touch stream (packages/app // brain-strip.tsx). Labels must match the brain page's node ids — skills map // by bare name; unknown files graft new nodes. -export type AmicoBrainRef = { label: string; type: string; consider: boolean } +export type AmicoBrainRef = { + label: string + type: string + consider: boolean + /** full file path when the touch IS a file — lets the context tree open it */ + path?: string +} export function amicoBrainRef(tool: string, input: Record = {}): AmicoBrainRef | undefined { const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined) @@ -19,7 +25,7 @@ export function amicoBrainRef(tool: string, input: Record = {}) if (!fp) return undefined const label = base(fp) const type = /\.(md|txt)$/i.test(label) ? "note" : /\.jl$/i.test(label) ? "package" : "resource" - return { label, type, consider: false } + return { label, type, consider: false, path: fp } } case "grep": case "glob": { diff --git a/packages/ui/src/amicode/context-tree-data.test.ts b/packages/ui/src/amicode/context-tree-data.test.ts new file mode 100644 index 0000000000..e3579fc4c9 --- /dev/null +++ b/packages/ui/src/amicode/context-tree-data.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test" +import { buildContextTree, contextKind, vaultRefFromPath, type ContextTurn } from "./context-tree-data" + +const turn = (id: string, title: string, refs: ContextTurn["refs"], busy = false): ContextTurn => ({ + id, + title, + refs, + busy, +}) + +describe("contextKind", () => { + test("maps types and extensions", () => { + expect(contextKind({ label: "STRATEGY.md", type: "note", path: "/v/STRATEGY.md" })).toBe("note") + expect(contextKind({ label: "solve.jl", type: "package", path: "/p/solve.jl" })).toBe("source") + expect(contextKind({ label: "transmon", type: "skill" })).toBe("skill") + expect(contextKind({ label: "Explore", type: "agent" })).toBe("agent") + expect(contextKind({ label: "run solve", type: "experiment" })).toBe("action") + expect(contextKind({ label: "arxiv.org", type: "resource" })).toBe("web") + }) +}) + +describe("vaultRefFromPath", () => { + test("splits mount and relative path", () => { + expect(vaultRefFromPath("/Users/k/.amico/vaults/armonissima/insights/x.md")).toEqual({ + mount: "armonissima", + rel: "insights/x.md", + }) + expect(vaultRefFromPath("/Users/k/project/src/x.ts")).toBeUndefined() + }) +}) + +describe("buildContextTree", () => { + test("root → turns → leaves; roman numerals + prompt excerpts", () => { + const tree = buildContextTree([ + turn("m1", "optimize a CZ gate", [ + { label: "setup.jl", type: "package", path: "/p/setup.jl" }, + { label: "transmon", type: "skill" }, + ]), + turn("m2", "now fix stagnation", [{ label: "Explore", type: "agent" }]), + ]) + expect(tree.kind).toBe("root") + expect(tree.children!.map((t) => t.label)).toEqual(["I · optimize a CZ gate", "II · now fix stagnation"]) + expect(tree.children![0].children!.length).toBe(2) + }) + test("consider refs (searches) never enter the tree", () => { + const tree = buildContextTree([turn("m1", "hunt", [{ label: "saveat", type: "resource", consider: true }])]) + expect(tree.children![0].children!.length).toBe(0) + }) + test("a re-touched file dedups into a recall link, not a duplicate", () => { + const tree = buildContextTree([ + turn("m1", "read it", [{ label: "solve.jl", type: "package", path: "/p/solve.jl" }]), + turn("m2", "read it again", [{ label: "solve.jl", type: "package", path: "/p/solve.jl" }]), + ]) + const [t1, t2] = tree.children! + expect(t1.children!.length).toBe(1) + expect(t2.children!.length).toBe(0) + expect(t2.recalls).toEqual([t1.children![0].id]) + }) + test("busy turn puts the live cursor on its newest leaf", () => { + const tree = buildContextTree([ + turn( + "m1", + "working", + [ + { label: "a.md", type: "note", path: "/p/a.md" }, + { label: "b.md", type: "note", path: "/p/b.md" }, + ], + true, + ), + ]) + const leaves = tree.children![0].children! + expect(leaves[1].active).toBe(true) + expect(leaves[0].active).toBeUndefined() + }) + test("vault paths are flagged for the Vault panel", () => { + const tree = buildContextTree([ + turn("m1", "vault read", [{ label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" }]), + ]) + expect(tree.children![0].children![0].vault).toBe(true) + }) + test("marathon sessions fold old turns into one earlier branch", () => { + const turns = Array.from({ length: 30 }, (_, i) => + turn(`m${i}`, `turn ${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]), + ) + const tree = buildContextTree(turns) + expect(tree.children![0].id).toBe("turn-earlier") + expect(tree.children!.length).toBe(25) // fold + last 24 + expect(tree.children![0].children!.length).toBe(6) // 30 - 24 folded refs + // a later re-touch of a folded file recalls into the fold, not a dup + const again = buildContextTree([...turns, turn("m30", "again", [{ label: "f0.md", type: "note", path: "/p/f0.md" }])]) + const last = again.children![again.children!.length - 1] + expect(last.children!.length).toBe(0) + expect(last.recalls!.length).toBe(1) + }) +}) diff --git a/packages/ui/src/amicode/context-tree-data.ts b/packages/ui/src/amicode/context-tree-data.ts new file mode 100644 index 0000000000..c93d2a2ad5 --- /dev/null +++ b/packages/ui/src/amicode/context-tree-data.ts @@ -0,0 +1,164 @@ +// amicode: THE single mapping from a session's turns to the context tree the +// top panel renders — pure and headless so the shape is testable without the +// sync store. Turns come in message order; each ref is one tool touch already +// mapped by brain-ref. Commits only: searches/globs are transient scouting, +// not context the agent holds, so `consider` refs never enter the tree. +// +// Dedup law: a file/skill/agent enters the tree ONCE, under the turn that +// first pulled it in. A later turn touching the same thing draws a recall +// link back to the existing node instead of duplicating it — the tree stays +// a map of distinct context, not a log. +import type { ContextTreeKind, ContextTreeNodeInput } from "./context-tree-engine" + +export type ContextRef = { + label: string + type?: string + path?: string + consider?: boolean +} + +export type ContextTurn = { + id: string + /** first line of the user prompt that opened the turn (may be empty) */ + title: string + refs: ContextRef[] + /** the turn is still running — its newest touch wears the live cursor */ + busy?: boolean +} + +const ROMAN = [ + "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", + "XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX", +] +const roman = (i: number) => ROMAN[i % ROMAN.length] + +/** Beyond this many turns the oldest fold into one "· earlier ·" branch so a + * marathon session stays a readable tree, not a wall. */ +const TURN_CAP = 24 + +const SOURCE_EXT = /\.(jl|py|ts|tsx|js|jsx|json|toml|yaml|yml|sh|css|html|rs|go|c|h|cpp)$/i +const NOTE_EXT = /\.(md|txt|tex)$/i + +export function contextKind(ref: ContextRef): ContextTreeKind { + if (ref.type === "skill") return "skill" + if (ref.type === "agent") return "agent" + if (ref.type === "experiment") return "action" + if (ref.type === "note") return "note" + if (ref.path) { + if (NOTE_EXT.test(ref.path)) return "note" + if (SOURCE_EXT.test(ref.path)) return "source" + return "resource" + } + if (ref.type === "package") return "source" + // webfetch hostnames and bash verbs arrive as pathless resources + if (/^[\w.-]+\.[a-z]{2,}$/i.test(ref.label)) return "web" + return "resource" +} + +/** A path inside an attached vault mount → {mount, rel} so the host can open + * it through the Vault panel instead of a project file tab. */ +export function vaultRefFromPath(path: string): { mount: string; rel: string } | undefined { + const m = path.match(/\/\.amico\/vaults\/([^/]+)\/(.+)$/) + if (m) return { mount: m[1], rel: m[2] } + return undefined +} + +const dedupKey = (ref: ContextRef, kind: ContextTreeKind) => + ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}` + +export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput { + const root: ContextTreeNodeInput = { + id: "root", + label: opts.rootLabel ?? "amico", + kind: "root", + children: [], + } + let visible = turns + let offset = 0 + if (turns.length > TURN_CAP) { + // fold the overflow into one quiet branch; its refs still claim dedup + // keys so later recalls point somewhere real + const folded = turns.slice(0, turns.length - TURN_CAP) + visible = turns.slice(turns.length - TURN_CAP) + offset = folded.length + const earlier: ContextTreeNodeInput = { + id: "turn-earlier", + label: `· ${folded.length} earlier turns ·`, + kind: "turn", + children: [], + recalls: [], + } + root.children!.push(earlier) + const seen = seenOf(root) + for (const t of folded) + for (const ref of t.refs) { + if (ref.consider) continue + const kind = contextKind(ref) + const key = dedupKey(ref, kind) + if (seen.has(key)) continue + seen.set(key, `ctx-${seen.size}`) + earlier.children!.push(leafOf(ref, kind, seen.get(key)!)) + } + } + const seen = seenOf(root) + let lastLeaf: ContextTreeNodeInput | undefined + visible.forEach((turn, i) => { + const n = offset + i + const excerpt = turn.title.trim().slice(0, 26) + const node: ContextTreeNodeInput = { + id: `turn-${turn.id}`, + label: excerpt ? `${roman(n)} · ${excerpt}` : roman(n), + kind: "turn", + children: [], + recalls: [], + } + for (const ref of turn.refs) { + if (ref.consider) continue + const kind = contextKind(ref) + const key = dedupKey(ref, kind) + const known = seen.get(key) + if (known) { + if (!node.recalls!.includes(known)) node.recalls!.push(known) + continue + } + const id = `ctx-${seen.size}` + seen.set(key, id) + const leaf = leafOf(ref, kind, id) + node.children!.push(leaf) + lastLeaf = leaf + } + // a turn that only recalled things it already holds still charts — the + // recall links ARE its story + root.children!.push(node) + if (turn.busy) { + const cursor = lastLeaf && node.children!.includes(lastLeaf) ? lastLeaf : node + cursor.active = true + } + }) + return root +} + +function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput { + const vault = ref.path ? vaultRefFromPath(ref.path) : undefined + return { + id, + label: ref.label.slice(0, 32), + kind, + path: ref.path, + vault: !!vault, + } +} + +/** Rebuild the dedup index from a partially-built tree (used by the fold). */ +function seenOf(root: ContextTreeNodeInput): Map { + const seen = new Map() + const walk = (n: ContextTreeNodeInput) => { + for (const c of n.children ?? []) { + if (c.kind !== "turn" && c.kind !== "root") + seen.set(c.path ? `p:${c.path}` : `${c.kind}:${c.label.toLowerCase()}`, c.id) + walk(c) + } + } + walk(root) + return seen +} diff --git a/packages/ui/src/amicode/context-tree-engine.test.ts b/packages/ui/src/amicode/context-tree-engine.test.ts new file mode 100644 index 0000000000..a87e006299 --- /dev/null +++ b/packages/ui/src/amicode/context-tree-engine.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "bun:test" +import { createContextTreeEngine, type ContextTreeEngineOptions, type ContextTreeNodeInput } from "./context-tree-engine" + +/* Headless like brain-engine.test.ts: no DOM, no rAF, no matchMedia. The stub + canvas has no addEventListener, so pointer wiring is skipped — interaction + logic is exercised through pick()/locate() directly. */ + +type Call = { method: string; args: unknown[] } +function recordingCtx() { + const calls: Call[] = [] + const record = + (method: string) => + (...args: unknown[]) => { + calls.push({ method, args }) + if (method === "measureText") return { width: 42 } + return undefined + } + const ctx: Record = { calls } + for (const m of [ + "setTransform", + "clearRect", + "fillRect", + "beginPath", + "moveTo", + "lineTo", + "bezierCurveTo", + "stroke", + "fill", + "arc", + "setLineDash", + "fillText", + "measureText", + ]) + ctx[m] = record(m) + return ctx as { calls: Call[] } & Record +} +function stubCanvas(ctx: unknown) { + return { + clientWidth: 0, + clientHeight: 0, + width: 0, + height: 0, + getContext: () => ctx, + } as unknown as HTMLCanvasElement +} +function makeEngine(opts: Partial = {}) { + const ctx = recordingCtx() + const engine = createContextTreeEngine(stubCanvas(ctx), { + scheme: "dark", + reduceMotion: true, + animate: false, + size: { width: 800, height: 208 }, + ...opts, + }) + return { engine, ctx } +} + +const TREE: ContextTreeNodeInput = { + id: "root", + label: "amico", + kind: "root", + children: [ + { + id: "turn-1", + label: "I · optimize a CZ gate", + kind: "turn", + children: [ + { id: "f-strategy", label: "STRATEGY.md", kind: "note", path: "STRATEGY.md", vault: true }, + { id: "f-solve", label: "solve.jl", kind: "source", path: "scripts/solve.jl" }, + { id: "s-transmon", label: "transmon", kind: "skill" }, + ], + }, + { + id: "turn-2", + label: "II · fix the stagnation", + kind: "turn", + recalls: ["f-solve"], + children: [{ id: "a-explore", label: "Explore", kind: "agent", active: true }], + }, + ], +} + +describe("setTree", () => { + test("counts nodes, tree edges, recall links, and depth", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + const s = engine.stats() + expect(s.nodes).toBe(7) // root + 2 turns + 4 leaves + expect(s.edges).toBe(6) // tree edges: every non-root has exactly one + expect(s.recalls).toBe(1) + expect(s.depth).toBe(2) + }) + test("re-setting keeps known ids and absorbs new ones", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + const before = engine.locate("f-solve") + const grown = structuredClone(TREE) + grown.children![1].children!.push({ id: "f-new", label: "new.md", kind: "note", path: "new.md" }) + engine.setTree(grown) + expect(engine.stats().nodes).toBe(8) + // under reduced motion positions land instantly, so a known leaf stays put + expect(engine.locate("f-solve")).toEqual(before!) + expect(engine.locate("f-new")).toBeDefined() + }) + test("recall to an unknown id is dropped, not fatal", () => { + const { engine } = makeEngine() + const t = structuredClone(TREE) + t.children![1].recalls = ["ghost-id"] + engine.setTree(t) + expect(engine.stats().recalls).toBe(0) + }) +}) + +describe("pick / locate", () => { + test("pick at a node's location returns its id; empty space returns undefined", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + engine.tick(16) + const p = engine.locate("f-strategy")! + expect(engine.pick(p.x, p.y)).toBe("f-strategy") + expect(engine.pick(-9999, -9999)).toBeUndefined() + }) + test("locate of an unknown id is undefined", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + expect(engine.locate("nope")).toBeUndefined() + }) +}) + +describe("render", () => { + test("a tick paints the frame: transform, clear, curves, circles, labels", () => { + const { engine, ctx } = makeEngine() + engine.setTree(TREE) + engine.tick(16) + const methods = new Set(ctx.calls.map((c) => c.method)) + expect(methods.has("setTransform")).toBe(true) + expect(methods.has("clearRect")).toBe(true) + expect(methods.has("bezierCurveTo")).toBe(true) + expect(methods.has("arc")).toBe(true) + expect(methods.has("fillText")).toBe(true) + }) + test("a NaN timestamp is refused (would poison the clock)", () => { + const { engine, ctx } = makeEngine() + engine.setTree(TREE) + engine.tick(Number.NaN) + expect(ctx.calls.length).toBe(0) + }) + test("recall edges render dashed", () => { + const { engine, ctx } = makeEngine() + engine.setTree(TREE) + engine.tick(16) + const dashes = ctx.calls.filter((c) => c.method === "setLineDash" && (c.args[0] as number[]).length > 0) + expect(dashes.length).toBeGreaterThan(0) + }) +}) + +describe("theme / lifecycle", () => { + test("setTheme swaps the scheme losslessly", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + engine.setTheme("light") + expect(engine.stats().scheme).toBe("light") + expect(engine.stats().nodes).toBe(7) + }) + test("highlight of unknown label is a no-op; known label survives a tick", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + engine.highlight("does-not-exist") + engine.highlight("solve.jl") + engine.tick(16) + expect(engine.stats().nodes).toBe(7) + }) + test("resize with explicit dims scales the backing store by DPR", () => { + const { engine } = makeEngine() + const canvas = stubCanvas(recordingCtx()) + const e2 = createContextTreeEngine(canvas, { reduceMotion: true, animate: false }) + e2.resize(400, 100) + expect((canvas as unknown as { width: number }).width).toBeGreaterThanOrEqual(400) + e2.destroy() + engine.destroy() + }) + test("destroy is idempotent and setTree after destroy is ignored", () => { + const { engine } = makeEngine() + engine.setTree(TREE) + engine.destroy() + engine.destroy() + engine.setTree(structuredClone(TREE)) + expect(engine.stats().nodes).toBe(7) + }) +}) diff --git a/packages/ui/src/amicode/context-tree-engine.ts b/packages/ui/src/amicode/context-tree-engine.ts new file mode 100644 index 0000000000..0ab630a05e --- /dev/null +++ b/packages/ui/src/amicode/context-tree-engine.ts @@ -0,0 +1,691 @@ +/* ================================================================ + the context tree — what amico is holding in mind (native engine) + + The knowledge graph, moved to the top panel and redesigned around the + session instead of the vault sample: a left-to-right tidy tree of the + agent's ACTUAL context. Root = amico; branches = the session's turns; + leaves = the markdown, source, skills, agents and web references each + turn pulled into context. A leaf touched again by a later turn is not + duplicated — the later turn draws a thin recall link back to it, so + the tree stays a map of distinct context, not a log. + + Unlike the ambient brain (brain-engine.ts), this surface is + INTERACTIVE: nodes hit-test, hover raises their label, and clicking a + file node hands its path to the host (onSelect) to open the real + markdown/source. Camera: auto-fit until the user pans/zooms; wheel + zooms about the cursor, drag pans, double-click refits. + + Shared laws with the brain: circles only; #fff676 belongs to the live + position alone; both palettes carried in-module so a theme flip is a + repaint, never a reload; DPR re-checked when the device-pixel-ratio + itself changes (webview zoom), not only on box resize. + ================================================================ */ + +export type ContextTreeScheme = "dark" | "light" + +export type ContextTreeKind = + | "root" + | "turn" + | "note" // markdown / prose + | "source" // code + | "skill" + | "agent" + | "web" + | "action" // solves, bash verbs + | "resource" + +export type ContextTreeNodeInput = { + id: string + label: string + kind: ContextTreeKind + /** full or repo-relative file path — presence makes the node openable */ + path?: string + /** the file lives in a vault mount (open via the Vault panel, not a tab) */ + vault?: boolean + /** where the agent currently works — wears the thought-color cursor */ + active?: boolean + children?: ContextTreeNodeInput[] + /** ids of earlier nodes this node re-touched — drawn as recall links */ + recalls?: string[] +} + +export type ContextTreeSelection = { + id: string + label: string + kind: ContextTreeKind + path?: string + vault?: boolean +} + +export interface ContextTreeEngineOptions { + scheme?: ContextTreeScheme + /** override prefers-reduced-motion (tests) */ + reduceMotion?: boolean + /** drive the render loop via requestAnimationFrame (default true; tests call tick()) */ + animate?: boolean + /** layout fallback when the canvas has no measured size yet */ + size?: { width: number; height: number } + onSelect?: (node: ContextTreeSelection) => void + onHover?: (node: ContextTreeSelection | null) => void +} + +export interface ContextTreeStats { + scheme: ContextTreeScheme + nodes: number + edges: number + recalls: number + depth: number +} + +export interface ContextTreeEngine { + /** declaratively replace the tree; known ids keep their motion state */ + setTree(root: ContextTreeNodeInput): void + /** lossless palette swap + repaint */ + setTheme(scheme: ContextTreeScheme): void + /** a glance from the log: ring the node whose label matches */ + highlight(label: string): void + /** re-fit the camera to the whole tree (also clears manual pan/zoom) */ + fit(): void + /** omit both to re-measure from the canvas box */ + resize(width?: number, height?: number): void + tick(nowMs: number): void + pause(): void + resume(): void + destroy(): void + /** screen-space hit test (CSS px) — the id under the point, if any */ + pick(x: number, y: number): string | undefined + /** screen-space position of a node (CSS px) — tests + host tooltips */ + locate(id: string): { x: number; y: number } | undefined + stats(): ContextTreeStats +} + +/* ---------- tokens: both palettes in-module (same idiom as brain-engine) ---------- */ +type Palette = { + fg: string + edge: string + recall: string + nodeFill: string + nodeBorder: string + thought: string + labelHalo: string + kind: Record +} +const PALETTES: Record = { + dark: { + fg: "#d6d6d2", + edge: "rgba(255, 255, 255, 0.16)", + recall: "rgba(255, 255, 255, 0.10)", + nodeFill: "#242423", + nodeBorder: "rgba(255, 255, 255, 0.24)", + thought: "#fff676", + labelHalo: "rgba(19, 19, 18, 0.72)", + kind: { + root: "#fff676", + turn: "#d6d6d2", + note: "#3794ff", + source: "#4d9e51", + skill: "#9b6bc4", + agent: "#c9c9c4", + web: "#c17800", + action: "#c17800", + resource: "#8a8a86", + }, + }, + light: { + fg: "#22221f", + edge: "rgba(0, 0, 0, 0.18)", + recall: "rgba(0, 0, 0, 0.10)", + nodeFill: "#eae7dd", + nodeBorder: "rgba(0, 0, 0, 0.26)", + thought: "#8f8000", + labelHalo: "rgba(250, 249, 246, 0.78)", + kind: { + root: "#8f8000", + turn: "#22221f", + note: "#1866c9", + source: "#33753a", + skill: "#7b4fa8", + agent: "#4a4a44", + web: "#8f5800", + action: "#8f5800", + resource: "#77776f", + }, + }, +} + +/** The kind→color mapping, for host chrome (the panel legend) — single source + * with the render palette so the dots can never drift from the canvas. */ +export function contextTreeKindColor(scheme: ContextTreeScheme, kind: ContextTreeKind): string { + return PALETTES[scheme].kind[kind] ?? PALETTES[scheme].fg +} + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace("#", "") + const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16) + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] +} +function rgba(color: string, a: number): string { + if (color.startsWith("rgba") || color.startsWith("rgb")) return color + const [r, g, b] = hexToRgb(color) + return `rgba(${r},${g},${b},${a})` +} + +/* ---------- internal shapes ---------- */ +interface TNode { + id: string + label: string + kind: ContextTreeKind + path?: string + vault?: boolean + active: boolean + depth: number + // world targets (tidy layout) and animated positions + tx: number + ty: number + x: number + y: number + alpha: number + flash: number + ringT: number + half: number + parent?: TNode +} +interface TEdge { + s: TNode + t: TNode + recall: boolean +} + +const COL = 168 // world px between depths +const ROW = 30 // world px between leaf rows +const HALF: Record = { root: 8, turn: 5.5, agent: 5 } + +export function createContextTreeEngine( + canvas: HTMLCanvasElement, + opts: ContextTreeEngineOptions = {}, +): ContextTreeEngine { + let scheme: ContextTreeScheme = opts.scheme === "light" ? "light" : "dark" + let css: Palette = PALETTES[scheme] + let reduceMotion = + opts.reduceMotion ?? (typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches) + let motionQuery: MediaQueryList | null = null + const onMotionChange = (e: MediaQueryListEvent) => { + reduceMotion = e.matches + } + if (opts.reduceMotion === undefined && typeof matchMedia !== "undefined") { + motionQuery = matchMedia("(prefers-reduced-motion: reduce)") + motionQuery.addEventListener("change", onMotionChange) + } + const animate = opts.animate ?? true + const ctx = canvas.getContext("2d") + + let nodes: TNode[] = [] + let byId = new Map() + let edges: TEdge[] = [] + let recallCount = 0 + let maxDepth = 0 + + /* ---------- canvas, DPR, camera ---------- */ + let W = 0, + H = 0, + DPR = 1 + function resize(width?: number, height?: number) { + DPR = Math.min((typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1) || 1, 2) + W = width ?? canvas.clientWidth ?? 0 + H = height ?? canvas.clientHeight ?? 0 + if (!Number.isFinite(W) || !Number.isFinite(H) || W < 1 || H < 1) { + W = opts.size?.width ?? 800 + H = opts.size?.height ?? 208 + } + canvas.width = W * DPR + canvas.height = H * DPR + } + resize() + let ro: ResizeObserver | null = null + if (typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver(() => resize()) + ro.observe(canvas) + } + // webview/app zoom changes devicePixelRatio WITHOUT changing the CSS box — + // ResizeObserver stays silent and the backing store goes stale (blurry). + // Watch the dppx itself and re-arm after every change. + let dprQuery: MediaQueryList | null = null + const onDprChange = () => { + resize() + armDprWatch() + } + function armDprWatch() { + if (typeof matchMedia === "undefined" || typeof devicePixelRatio === "undefined") return + dprQuery?.removeEventListener("change", onDprChange) + dprQuery = matchMedia(`(resolution: ${devicePixelRatio}dppx)`) + dprQuery.addEventListener("change", onDprChange) + } + armDprWatch() + + // fit-derived camera with manual override; k is world→screen scale + const cam = { x: 0, y: 0, k: 1, tx: 0, ty: 0, tk: 1 } + let userCam = false + function fitTarget() { + if (!nodes.length) return + let minX = 1e9, + maxX = -1e9, + minY = 1e9, + maxY = -1e9 + for (const n of nodes) { + minX = Math.min(minX, n.tx) + maxX = Math.max(maxX, n.tx) + minY = Math.min(minY, n.ty) + maxY = Math.max(maxY, n.ty) + } + const padX = 90, // room for labels to the right of leaves + padY = 26 + const spanX = Math.max(maxX - minX, 1) + const spanY = Math.max(maxY - minY, 1) + cam.tk = Math.min((W - padX * 2) / spanX, (H - padY * 2) / spanY, 1.6) + cam.tk = Math.max(cam.tk, 0.25) + cam.tx = (minX + maxX) / 2 + cam.ty = (minY + maxY) / 2 + } + const sx = (wx: number) => (wx - cam.x) * cam.k + W / 2 + const sy = (wy: number) => (wy - cam.y) * cam.k + H / 2 + + /* ---------- tidy layout ---------- */ + function layout(root: ContextTreeNodeInput) { + const nextNodes: TNode[] = [] + const nextById = new Map() + const nextEdges: TEdge[] = [] + const recallPairs: [string, string][] = [] + let row = 0 + maxDepth = 0 + const visit = (input: ContextTreeNodeInput, depth: number, parent?: TNode): TNode => { + maxDepth = Math.max(maxDepth, depth) + const prior = byId.get(input.id) + const n: TNode = prior ?? { + id: input.id, + label: input.label, + kind: input.kind, + active: false, + depth, + tx: 0, + ty: 0, + // newborns condense out of their parent's position, not (0,0) + x: parent ? parent.x : 0, + y: parent ? parent.y : 0, + alpha: 0, + flash: reduceMotion ? 0 : 1, + ringT: -1, + half: 4, + } + n.label = input.label + n.kind = input.kind + n.path = input.path + n.vault = input.vault + n.active = !!input.active + n.depth = depth + n.half = HALF[input.kind] ?? 4 + n.parent = parent + nextNodes.push(n) + nextById.set(n.id, n) + const children = input.children ?? [] + if (!children.length) { + n.tx = depth * COL + n.ty = row * ROW + row++ + } else { + let sum = 0 + for (const c of children) { + const cn = visit(c, depth + 1, n) + nextEdges.push({ s: n, t: cn, recall: false }) + sum += cn.ty + } + n.tx = depth * COL + n.ty = sum / children.length + } + for (const r of input.recalls ?? []) recallPairs.push([input.id, r]) + return n + } + visit(root, 0, undefined) + recallCount = 0 + for (const [from, to] of recallPairs) { + const s = nextById.get(from) + const t = nextById.get(to) + if (!s || !t || s === t) continue + nextEdges.push({ s, t, recall: true }) + recallCount++ + } + nodes = nextNodes + byId = nextById + edges = nextEdges + if (reduceMotion) + for (const n of nodes) { + n.x = n.tx + n.y = n.ty + n.alpha = 1 + } + if (!userCam) fitTarget() + } + + /* ---------- interaction ---------- */ + let hovered: TNode | null = null + function pick(px: number, py: number): string | undefined { + // topmost-last: later nodes (deeper/younger) win overlapping hits + for (let i = nodes.length - 1; i >= 0; i--) { + const n = nodes[i] + const dx = px - sx(n.x) + const dy = py - sy(n.y) + const r = Math.max(n.half * cam.k, 5) + 4 + if (dx * dx + dy * dy <= r * r) return n.id + } + return undefined + } + const selection = (n: TNode): ContextTreeSelection => ({ + id: n.id, + label: n.label, + kind: n.kind, + path: n.path, + vault: n.vault, + }) + let dragging = false + let dragMoved = false + let lastPX = 0, + lastPY = 0 + const local = (e: MouseEvent) => { + const rect = typeof canvas.getBoundingClientRect === "function" ? canvas.getBoundingClientRect() : undefined + if (!rect) return { x: e.clientX, y: e.clientY } + // the rect is post-zoom/post-transform (CSS zoom on the root scales it) + // while W/H are the engine's pre-zoom CSS px — normalize so hit-testing + // stays true at any app zoom level + const kx = rect.width > 0 && W > 0 ? rect.width / W : 1 + const ky = rect.height > 0 && H > 0 ? rect.height / H : 1 + return { x: (e.clientX - rect.left) / kx, y: (e.clientY - rect.top) / ky } + } + const onPointerDown = (e: PointerEvent) => { + dragging = true + dragMoved = false + const p = local(e) + lastPX = p.x + lastPY = p.y + } + const onPointerMove = (e: PointerEvent) => { + const p = local(e) + if (dragging) { + const dx = p.x - lastPX + const dy = p.y - lastPY + if (Math.abs(dx) + Math.abs(dy) > 2) dragMoved = true + if (dragMoved) { + userCam = true + cam.tx -= dx / cam.k + cam.ty -= dy / cam.k + cam.x = cam.tx + cam.y = cam.ty + lastPX = p.x + lastPY = p.y + } + return + } + const id = pick(p.x, p.y) + const n = id ? (byId.get(id) ?? null) : null + if (n !== hovered) { + hovered = n + if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab" + opts.onHover?.(n ? selection(n) : null) + } + } + const onPointerUp = (e: PointerEvent) => { + const wasDrag = dragging && dragMoved + dragging = false + if (wasDrag) return + const p = local(e) + const id = pick(p.x, p.y) + const n = id ? byId.get(id) : undefined + if (n) { + n.ringT = beatNow + opts.onSelect?.(selection(n)) + } + } + const onPointerLeave = () => { + dragging = false + if (hovered) { + hovered = null + opts.onHover?.(null) + } + } + const onWheel = (e: WheelEvent) => { + e.preventDefault() + userCam = true + const p = local(e) + const factor = Math.exp(-e.deltaY * 0.0015) + const k0 = cam.k + const k1 = Math.min(Math.max(k0 * factor, 0.15), 4) + // zoom about the cursor: keep the world point under it fixed + const wx = (p.x - W / 2) / k0 + cam.x + const wy = (p.y - H / 2) / k0 + cam.y + cam.k = k1 + cam.tk = k1 + cam.x = wx - (p.x - W / 2) / k1 + cam.y = wy - (p.y - H / 2) / k1 + cam.tx = cam.x + cam.ty = cam.y + } + const onDblClick = () => { + userCam = false + fitTarget() + } + const listeners: [string, EventListener][] = [ + ["pointerdown", onPointerDown as EventListener], + ["pointermove", onPointerMove as EventListener], + ["pointerup", onPointerUp as EventListener], + ["pointerleave", onPointerLeave as EventListener], + ["wheel", onWheel as EventListener], + ["dblclick", onDblClick as EventListener], + ] + if (typeof canvas.addEventListener === "function") + for (const [ev, fn] of listeners) canvas.addEventListener(ev, fn, ev === "wheel" ? { passive: false } : undefined) + + /* ---------- render ---------- */ + let halted = false + let destroyed = false + let rafId = 0 + let lastMs = 0 + let beatNow = 0 // seconds-ish clock for rings + let glance: { id: string; until: number } | null = null + + function tick(nowMs: number) { + if (halted || !ctx) return + if (!Number.isFinite(nowMs)) return + try { + drawFrame(nowMs) + } catch (err) { + halted = true + console.error("[amico-context-tree] halted on render error:", err) + return + } + if (!halted && animate && typeof requestAnimationFrame !== "undefined") rafId = requestAnimationFrame(tick) + } + + function drawFrame(nowMs: number) { + if (!ctx) return + const dt = Math.min(nowMs - (lastMs || nowMs), 50) + lastMs = nowMs + beatNow += dt / 1000 + + const ease = reduceMotion ? 1 : 1 - Math.pow(0.0015, dt / 1000) + cam.x += (cam.tx - cam.x) * ease + cam.y += (cam.ty - cam.y) * ease + cam.k += (cam.tk - cam.k) * ease + for (const n of nodes) { + n.x += (n.tx - n.x) * ease + n.y += (n.ty - n.y) * ease + n.alpha = Math.min(n.alpha + dt / 320, 1) + if (n.flash > 0) n.flash = Math.max(n.flash - dt / 600, 0) + } + + ctx.setTransform(DPR, 0, 0, DPR, 0, 0) + ctx.clearRect(0, 0, W, H) // transparent ground — the panel surface shows through + + // ---- edges: horizontal S-curves parent→child; recall links dashed + for (const e of edges) { + const x1 = sx(e.s.x), + y1 = sy(e.s.y), + x2 = sx(e.t.x), + y2 = sy(e.t.y) + if ((x1 < -60 && x2 < -60) || (x1 > W + 60 && x2 > W + 60) || (y1 < -60 && y2 < -60) || (y1 > H + 60 && y2 > H + 60)) + continue + const a = Math.min(e.s.alpha, e.t.alpha) + if (a <= 0.02) continue + ctx.beginPath() + ctx.moveTo(x1, y1) + const mid = (x2 - x1) * 0.5 + ctx.bezierCurveTo(x1 + mid, y1, x2 - mid, y2, x2, y2) + if (e.recall) { + ctx.strokeStyle = rgba(css.fg, 0.1 * a) + ctx.setLineDash([3, 4]) + } else { + ctx.strokeStyle = rgba(css.fg, 0.16 * a) + } + ctx.lineWidth = 1 + ctx.stroke() + ctx.setLineDash([]) + } + + // ---- nodes + ctx.textBaseline = "middle" + for (const n of nodes) { + const x = sx(n.x), + y = sy(n.y) + if (x < -80 || x > W + 80 || y < -40 || y > H + 40) continue + const color = css.kind[n.kind] ?? css.fg + const isRoot = n.kind === "root" + let half = Math.max(n.half * Math.min(cam.k, 1.4), isRoot ? 5 : 3) + if (n.flash > 0) half *= 1 + n.flash * 0.35 + const hoveredNow = hovered === n + + ctx.globalAlpha = n.alpha + ctx.beginPath() + ctx.arc(x, y, half, 0, Math.PI * 2) + if (isRoot) { + // the root wears the thought color — the one yellow on the surface + ctx.fillStyle = css.thought + ctx.fill() + } else if (n.kind === "turn") { + ctx.fillStyle = css.nodeFill + ctx.fill() + ctx.strokeStyle = rgba(color, 0.85) + ctx.lineWidth = 1 + ctx.stroke() + } else { + ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75) + ctx.fill() + ctx.strokeStyle = rgba(color, 0.9) + ctx.lineWidth = 1 + ctx.stroke() + } + if (n.kind === "agent") { + // agents wear the double border, same as on the brain + ctx.beginPath() + ctx.arc(x, y, half + 2.5, 0, Math.PI * 2) + ctx.strokeStyle = rgba(color, 0.5) + ctx.lineWidth = 1 + ctx.stroke() + } + // active cursor: where the agent currently works + if (n.active) { + const rr = half + 4 + (reduceMotion ? 0 : Math.sin(nowMs / 600) * 1.5) + ctx.beginPath() + ctx.arc(x, y, rr, 0, Math.PI * 2) + ctx.strokeStyle = rgba(css.thought, 0.85) + ctx.lineWidth = 1.5 + ctx.stroke() + } + // click/glance ring + if (n.ringT >= 0) { + const rt = (beatNow - n.ringT) / 0.7 + if (rt >= 0 && rt < 1) { + ctx.beginPath() + ctx.arc(x, y, half + rt * half * 2.6, 0, Math.PI * 2) + ctx.strokeStyle = rgba(css.thought, (1 - rt) * 0.8) + ctx.lineWidth = 1 + ctx.stroke() + } else if (rt >= 1) n.ringT = -1 + } + // labels: turns + root always; leaves at rest tone, raised on hover + const la = isRoot || n.kind === "turn" ? 0.85 : hoveredNow || n.active ? 1 : 0.6 + ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace` + const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label + const tw = ctx.measureText(text).width + const lx = x + half + 6, + ly = y + ctx.fillStyle = css.labelHalo + ctx.fillRect(lx - 2, ly - 7, tw + 4, 14) + ctx.fillStyle = rgba(hoveredNow ? css.fg : css.fg, Math.min(la, 1) * n.alpha) + ctx.fillText(text, lx, ly) + ctx.globalAlpha = 1 + } + + // a glance from the log fades on its own clock + if (glance && glance.until < beatNow) glance = null + } + + if (animate && typeof requestAnimationFrame !== "undefined") rafId = requestAnimationFrame(tick) + + return { + setTree: (root) => { + if (!destroyed) layout(root) + }, + setTheme: (next) => { + if (next !== "dark" && next !== "light") return + scheme = next + css = PALETTES[scheme] + }, + highlight: (label) => { + if (destroyed) return + const norm = String(label || "") + .toLowerCase() + .replace(/\.(md|jl|json|toml)$/, "") + const n = nodes.find((x) => x.label.toLowerCase().replace(/\.(md|jl|json|toml)$/, "") === norm) + if (n) { + n.ringT = beatNow + glance = { id: n.id, until: beatNow + 3 } + } + }, + fit: () => { + userCam = false + fitTarget() + }, + resize: (width, height) => resize(width, height), + tick, + pause: () => { + halted = true + if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(rafId) + }, + resume: () => { + if (destroyed || !halted) return + halted = false + lastMs = 0 + if (animate && typeof requestAnimationFrame !== "undefined") rafId = requestAnimationFrame(tick) + }, + destroy: () => { + destroyed = true + halted = true + if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(rafId) + ro?.disconnect() + motionQuery?.removeEventListener("change", onMotionChange) + dprQuery?.removeEventListener("change", onDprChange) + if (typeof canvas.removeEventListener === "function") + for (const [ev, fn] of listeners) canvas.removeEventListener(ev, fn) + }, + pick, + locate: (id) => { + const n = byId.get(id) + if (!n) return undefined + return { x: sx(n.x), y: sy(n.y) } + }, + stats: () => ({ + scheme, + nodes: nodes.length, + edges: edges.filter((e) => !e.recall).length, + recalls: recallCount, + depth: maxDepth, + }), + } +} diff --git a/packages/ui/src/amicode/entity-rail.tsx b/packages/ui/src/amicode/entity-rail.tsx index 5eeea85b23..7fb4a0e20c 100644 --- a/packages/ui/src/amicode/entity-rail.tsx +++ b/packages/ui/src/amicode/entity-rail.tsx @@ -32,6 +32,33 @@ interface RailPartState { output?: string } +function countAmicodeParts( + messages: readonly { id: string }[], + partsFor: (messageID: string) => readonly RailPart[] | undefined, +): { any: number; completed: number } { + let any = 0 + let completed = 0 + for (const message of messages) { + for (const part of partsFor(message.id) ?? []) { + if (part?.type !== "tool" || typeof part.tool !== "string") continue + if (!part.tool.startsWith("amicode_")) continue + any++ + if (part.state?.status === "completed") completed++ + } + } + return { any, completed } +} + +/** The rail's session gate, exported so the host chrome can collapse the + * header's chip padding when the rail will not render (no amicode parts — + * the title otherwise sits over an empty reserved band). */ +export function sessionHasAmicodeParts( + messages: readonly { id: string }[], + partsFor: (messageID: string) => readonly RailPart[] | undefined, +): boolean { + return countAmicodeParts(messages, partsFor).any > 0 +} + interface RailPart { type?: string tool?: string @@ -92,19 +119,7 @@ export function AmicodeEntityRail(props: { // exposes are all in scope. // Session gate + refetch key: completed amicode parts bump the counter → refetch. - const amicodeParts = createMemo(() => { - let any = 0 - let completed = 0 - for (const message of props.messages) { - for (const part of props.partsFor(message.id) ?? []) { - if (part?.type !== "tool" || typeof part.tool !== "string") continue - if (!part.tool.startsWith("amicode_")) continue - any++ - if (part.state?.status === "completed") completed++ - } - } - return { any, completed } - }) + const amicodeParts = createMemo(() => countAmicodeParts(props.messages, props.partsFor)) const [problemRaw, { refetch }] = createResource( () => (amicodeParts().any > 0 ? amicodeParts().completed + 1 : undefined), diff --git a/packages/ui/src/amicode/home-cards.tsx b/packages/ui/src/amicode/home-cards.tsx index 3e5d5410ce..49b4b18653 100644 --- a/packages/ui/src/amicode/home-cards.tsx +++ b/packages/ui/src/amicode/home-cards.tsx @@ -1065,7 +1065,7 @@ function LibraryCard(props: { : "Make Amico smarter"}
{error() ?? props.library?.latestName ?? "upload papers — Amico learns your work"}
-
+