From 6e7cc8ee61c2fba0f40f34243698cdd405e4666c Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 27 Jul 2026 03:30:00 -0400 Subject: [PATCH 1/6] feat(amicode): context-tree top panel, vault browser panel, zoom fixes Aaron's 2026-07-26 feedback, all three items: 1. Knowledge graph moves from the inline timeline strip to a pinned top panel, redesigned as the agent's interactive context tree (ADR 0003). 2. The vault and all its files live in the interface: read-only Vault panel over new /amicode/vault-files + /amicode/vault-file routes. 3. Command zoom fixes across hosts: in-app zoom + palette commands for the web/amicode host, canvas DPR-staleness blur fix, desktop zoom persistence, macOS menu-role collision, setZoom notify. Plus the agentic-surfaces design proposal. Co-Authored-By: Claude Fable 5 --- packages/app/CONTEXT.md | 41 +- .../docs/adr/0002-brain-as-chat-background.md | 3 +- .../docs/adr/0003-context-tree-top-panel.md | 69 ++ packages/app/docs/design/agentic-surfaces.md | 77 ++ packages/app/src/components/titlebar.tsx | 23 + packages/app/src/components/vault-panel.tsx | 290 ++++++++ packages/app/src/context/vault-panel.ts | 29 + packages/app/src/desktop-menu.ts | 12 +- packages/app/src/entry.tsx | 2 + packages/app/src/i18n/en.ts | 18 + packages/app/src/pages/layout.tsx | 32 + packages/app/src/pages/session.tsx | 4 + .../app/src/pages/session/brain-strip.tsx | 177 ----- .../src/pages/session/context-tree-panel.tsx | 221 ++++++ .../pages/session/message-timeline.data.ts | 6 - .../src/pages/session/message-timeline.tsx | 25 +- .../pages/session/use-amicode-commands.tsx | 13 + packages/app/src/utils/web-zoom.ts | 46 ++ .../desktop/src/main/desktop-menu-actions.ts | 6 +- packages/desktop/src/main/ipc.ts | 4 +- packages/desktop/src/main/store-keys.ts | 1 + packages/desktop/src/main/windows.ts | 17 +- .../src/server/amicode/vault-browser.ts | 139 ++++ .../server/routes/instance/httpapi/server.ts | 20 + .../test/server/amicode-vault-browser.test.ts | 104 +++ packages/ui/src/amicode/brain-engine.ts | 17 + packages/ui/src/amicode/brain-ref.ts | 10 +- .../ui/src/amicode/context-tree-data.test.ts | 95 +++ packages/ui/src/amicode/context-tree-data.ts | 164 +++++ .../src/amicode/context-tree-engine.test.ts | 190 +++++ .../ui/src/amicode/context-tree-engine.ts | 685 ++++++++++++++++++ .../ui/src/components/context-tree-data.tsx | 3 + .../ui/src/components/context-tree-engine.tsx | 3 + packages/ui/src/v2/components/icon.tsx | 4 + 34 files changed, 2313 insertions(+), 237 deletions(-) create mode 100644 packages/app/docs/adr/0003-context-tree-top-panel.md create mode 100644 packages/app/docs/design/agentic-surfaces.md create mode 100644 packages/app/src/components/vault-panel.tsx create mode 100644 packages/app/src/context/vault-panel.ts delete mode 100644 packages/app/src/pages/session/brain-strip.tsx create mode 100644 packages/app/src/pages/session/context-tree-panel.tsx create mode 100644 packages/app/src/utils/web-zoom.ts create mode 100644 packages/opencode/src/server/amicode/vault-browser.ts create mode 100644 packages/opencode/test/server/amicode-vault-browser.test.ts create mode 100644 packages/ui/src/amicode/context-tree-data.test.ts create mode 100644 packages/ui/src/amicode/context-tree-data.ts create mode 100644 packages/ui/src/amicode/context-tree-engine.test.ts create mode 100644 packages/ui/src/amicode/context-tree-engine.ts create mode 100644 packages/ui/src/components/context-tree-data.tsx create mode 100644 packages/ui/src/components/context-tree-engine.tsx diff --git a/packages/app/CONTEXT.md b/packages/app/CONTEXT.md index 20a9578dd4..ea52d6c2b5 100644 --- a/packages/app/CONTEXT.md +++ b/packages/app/CONTEXT.md @@ -22,17 +22,21 @@ 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 pinned, collapsible top panel between the session header and the **Chat** — a +left-to-right tree of what the agent is actually holding in mind: root = amico, branches = +the session's turns, 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 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 +84,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 +120,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..adbba39f58 --- /dev/null +++ b/packages/app/docs/adr/0003-context-tree-top-panel.md @@ -0,0 +1,69 @@ +# 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. +- 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/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.tsx b/packages/app/src/pages/session.tsx index f5949620a1..ff44bacb69 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -32,6 +32,7 @@ import { showToast } from "@/utils/toast" import { checksum } from "@opencode-ai/core/util/encode" import { useLocation, useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" +import { ContextTreePanel } from "@/pages/session/context-tree-panel" import { useComments } from "@/context/comments" import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" import { useServerSync } from "@/context/server-sync" @@ -1731,6 +1732,9 @@ export default function Page() {
{sessionSync() ?? ""} + + +
"). 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..8c72a18909 --- /dev/null +++ b/packages/app/src/pages/session/context-tree-panel.tsx @@ -0,0 +1,221 @@ +// 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" +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: every assistant message that committed ≥1 touch + const turns = createMemo(() => { + const out: ContextTurn[] = [] + for (const m of messages()) { + if (m.role !== "assistant") continue + const refs: ContextTurn["refs"] = [] + 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 + refs.push(ref) + } + if (!refs.length) continue + const done = typeof m.time?.completed === "number" + out.push({ id: m.id, title: turnTitle(m.parentID), refs, busy: !done && busy() }) + } + return out + }) + + 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() + }) + + return ( +
+
+
{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..a66e7091b6 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -85,7 +85,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 +92,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) { @@ -497,7 +496,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 +520,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 +1347,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