From d631fc9815fe04af457f9d9d976e003ff9e00dba Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 8 Aug 2026 19:52:25 -0400 Subject: [PATCH] feat(app): relocate context tree from top panel to context side panel (amicode#264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the context tree canvas + keyboard nav + hover-glance into SessionContextTab (top section, above the stats grid) - Delete the ContextTreePanel component and its top-panel rendering from message-timeline.tsx — 192px returns to the chat timeline - Remove the localStorage collapse toggle (amicode-context-tree-open); the panel itself is the dismiss gesture - Engine pause/resume follows component mount/unmount (the parent gates the whole tab) - sessionHasContextItems gates the graph section (no empty canvas) - Node clicks, amicode:brain-hover events, and keyboard navigation continue to work identically - ADR 0004 status updated from Proposed to Accepted - context-tree-engine.ts and context-tree-data.ts remain unchanged; existing tests still pass (21/21) --- .../adr/0004-context-tree-to-side-panel.md | 2 +- .../session/session-context-tab.tsx | 274 +++++++++++++- .../src/pages/session/context-tree-panel.tsx | 342 ------------------ .../session/timeline/message-timeline.tsx | 8 +- 4 files changed, 274 insertions(+), 352 deletions(-) delete mode 100644 packages/app/src/pages/session/context-tree-panel.tsx diff --git a/packages/app/docs/adr/0004-context-tree-to-side-panel.md b/packages/app/docs/adr/0004-context-tree-to-side-panel.md index 787b23812..8ba0a8c05 100644 --- a/packages/app/docs/adr/0004-context-tree-to-side-panel.md +++ b/packages/app/docs/adr/0004-context-tree-to-side-panel.md @@ -1,6 +1,6 @@ # 0004 — Context tree relocates from the top panel to the context side panel -- Status: Proposed +- Status: Accepted - Date: 2026-08-08 - Deciders: Aaron Trowbridge (design), JJ Lee - Supersedes: ADR 0003 (positioning clause only — interactivity, data model, vault browsability unchanged) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 25ad5ab23..c84addfd2 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,10 +1,11 @@ -import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, on, onCleanup, onMount, For, Show } from "solid-js" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" import { checksum } from "@opencode-ai/core/util/encode" import { findLast } from "@opencode-ai/core/util/array" import { same } from "@/utils/same" import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { File } from "@opencode-ai/session-ui/file" @@ -14,7 +15,23 @@ import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" +import { useServer } from "@/context/server" +import { useFile } from "@/context/file" import { useSessionLayout } from "@/pages/session/session-layout" +import { amicodeGet } from "@/utils/amicode-fetch" +import { amicoBrainRef } from "@opencode-ai/ui/brain-ref" +import { + createContextTreeEngine, + contextTreeKindColor, + type ContextTreeEngine, + type ContextTreeKind, + type ContextTreeNodeInput, + 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 { getSessionContext } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" @@ -27,6 +44,29 @@ const BREAKDOWN_COLOR: Record = { other: "var(--syntax-comment)", } +/** Gate for the graph section: ≥1 committed (non-consider) brain ref in the session. */ +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" }, +] + function Stat(props: { label: string; value: JSX.Element }) { return (
@@ -95,8 +135,10 @@ export function SessionContextTab() { const sync = useSync() const language = useLanguage() const sdk = useSDK() + const server = useServer() + const file = useFile() const providers = useProviders(() => sdk().directory) - const { params, view } = useSessionLayout() + const { params, tabs, view } = useSessionLayout() const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) @@ -110,6 +152,8 @@ export function SessionContextTab() { { equals: same }, ) + const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[] + const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages, @@ -220,10 +264,179 @@ export function SessionContextTab() { { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, ] satisfies { label: string; value: () => JSX.Element }[] + // ─── Context tree (relocated from the top panel per ADR 0004) ──────────── + // per-mount browsability from GET /amicode/vaults + const [vaultsRaw] = createResource( + () => server.current, + (conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined), + ) + const browsableMounts = createMemo | undefined>(() => { + const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined + if (!raw || !Array.isArray(raw.mounts)) return undefined + return new Map( + raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]), + ) + }) + const vaultLocked = (mount: string) => { + const map = browsableMounts() + if (!map) return false + if (!map.has(mount)) return true + return map.get(mount) === false + } + + const busy = createMemo(() => (sync().data.session_status[params.id ?? ""]?.type ?? "idle") !== "idle") + + const turns = createMemo(() => { + const byPrompt = new Map() + const out: ContextTurn[] = [] + for (const m of messages()) { + if (m.role !== "assistant") continue + const key = (m as { parentID?: string }).parentID ?? m.id + let turn = byPrompt.get(key) + if (!turn) { + turn = { id: key, 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 as { tool: string }).tool, ((p as { state?: { input?: unknown } }).state?.input ?? {}) as Record) + if (!ref || ref.consider) continue + turn.refs.push(ref) + } + if (typeof (m as { time?: { completed?: number } }).time?.completed !== "number" && busy()) turn.busy = true + } + return out.filter((t) => t.refs.length > 0) + }) + + const treeItemCount = 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 + }) + + const hasTreeItems = createMemo(() => treeItemCount() > 0) + + 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 + 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 onTreeSelect = (node: ContextTreeSelection) => { + if (!node.path || node.locked) return + const vaultRef = vaultRefFromPath(node.path) + if (vaultRef) { + vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel }) + return + } + openTab(file.tab(node.path)) + } + + // theme observer + const themeObserver = new MutationObserver(() => { + setScheme(currentScheme()) + engine()?.setTheme(currentScheme()) + }) + onMount(() => { + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-color-scheme"] }) + engine()?.resize() + engine()?.resume() + }) + onCleanup(() => { + themeObserver.disconnect() + engine()?.destroy() + }) + + // hover glance from tool rows in the log + 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 tree = createMemo(() => buildContextTree(turns(), { vaultLocked })) + createEffect(() => { + const brain = engine() + if (!brain) return + brain.setTree(tree()) + }) + + // keyboard navigation + const flatNodes = createMemo(() => { + const out: ContextTreeSelection[] = [] + const walk = (n: ContextTreeNodeInput) => { + if (n.kind !== "root") + out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked }) + for (const c of n.children ?? []) walk(c) + } + walk(tree()) + return out + }) + const [kbIndex, setKbIndex] = createSignal(-1) + const [announce, setAnnounce] = createSignal("") + const kbFocus = (index: number) => { + const list = flatNodes() + if (!list.length) return + const next = Math.min(Math.max(index, 0), list.length - 1) + setKbIndex(next) + const node = list[next] + engine()?.focus(node.id) + setAnnounce( + `${node.label} — ${node.kind}${ + node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : "" + }`, + ) + } + const onCanvasKeyDown = (e: KeyboardEvent) => { + const list = flatNodes() + if (!list.length) return + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault() + kbFocus(kbIndex() + 1) + return + case "ArrowLeft": + case "ArrowUp": + e.preventDefault() + kbFocus(kbIndex() - 1) + return + case "Home": + e.preventDefault() + kbFocus(0) + return + case "End": + e.preventDefault() + kbFocus(list.length - 1) + return + case "Enter": + case " ": { + if (kbIndex() < 0) return + e.preventDefault() + onTreeSelect(list[kbIndex()]) + return + } + } + } + // ─── End context tree ──────────────────────────────────────────────────── + let scroll: HTMLDivElement | undefined let frame: number | undefined let pending: { x: number; y: number } | undefined - const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[] const restoreScroll = () => { const el = scroll @@ -279,6 +492,61 @@ export function SessionContextTab() { onScroll={handleScroll} >
+ {/* Context tree graph — ADR 0004: relocated from the top panel */} + +
+
+
{language.t("amicode.contextTree.title")}
+
+ {language.t("amicode.contextTree.count", { count: treeItemCount() })} +
+ +
+ engine()?.fit()} + aria-label={language.t("amicode.contextTree.fit")} + /> +
+
+ + setEngine( + createContextTreeEngine(el, { + scheme: currentScheme(), + size: { width: 400, height: 192 }, + onSelect: onTreeSelect, + }), + ) + } + role="application" + aria-roledescription="context tree" + aria-label={language.t("amicode.contextTree.canvasLabel")} + tabIndex={0} + onKeyDown={onCanvasKeyDown} + onBlur={() => setKbIndex(-1)} + class="block h-full w-full focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-border-focus-base" + /> +
+
+ {announce()} +
+
+ +
{(stat) => [0])} value={stat.value()} />} diff --git a/packages/app/src/pages/session/context-tree-panel.tsx b/packages/app/src/pages/session/context-tree-panel.tsx deleted file mode 100644 index 2ab198521..000000000 --- a/packages/app/src/pages/session/context-tree-panel.tsx +++ /dev/null @@ -1,342 +0,0 @@ -// 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, createResource, 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 { useServer } from "@/context/server" -import { amicodeGet } from "@/utils/amicode-fetch" -import { amicoBrainRef } from "@opencode-ai/ui/brain-ref" -import { - createContextTreeEngine, - contextTreeKindColor, - type ContextTreeEngine, - type ContextTreeKind, - type ContextTreeNodeInput, - 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 server = useServer() - const { tabs, view } = useSessionLayout() - - // per-mount browsability from GET /amicode/vaults (`browsable`, stamped by - // the server's fail-closed law) — proprietary mounts mark their nodes - // locked upfront instead of dead-ending in a Vault-panel refusal on click - const [vaultsRaw] = createResource( - () => server.current, - (conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined), - ) - const browsableMounts = createMemo | undefined>(() => { - const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined - if (!raw || !Array.isArray(raw.mounts)) return undefined - return new Map( - raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]), - ) - }) - const vaultLocked = (mount: string) => { - const map = browsableMounts() - // list unavailable → status quo (no lock claims we can't back); - // a mount the server doesn't list can't be browsed → locked; - // `browsable` absent (older server) → unknown, again no lock claim - if (!map) return false - if (!map.has(mount)) return true - return map.get(mount) === false - } - - 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") - - // 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 — group by the - // parent user message instead. Prompt text stays out of the turn entirely - // (the tree charts context, not conversation). - 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, 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 || node.locked) 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)) - - const tree = createMemo(() => buildContextTree(turns(), { vaultLocked })) - createEffect(() => { - const brain = engine() - if (!brain) return - brain.setTree(tree()) - }) - - // keyboard navigation: a flat traversal of the same tree the canvas draws - // (turns then their leaves, in order); arrows walk it, Enter/Space opens - const flatNodes = createMemo(() => { - const out: ContextTreeSelection[] = [] - const walk = (n: ContextTreeNodeInput) => { - if (n.kind !== "root") - out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked }) - for (const c of n.children ?? []) walk(c) - } - walk(tree()) - return out - }) - const [kbIndex, setKbIndex] = createSignal(-1) - const [announce, setAnnounce] = createSignal("") - const kbFocus = (index: number) => { - const list = flatNodes() - if (!list.length) return - const next = Math.min(Math.max(index, 0), list.length - 1) - setKbIndex(next) - const node = list[next] - engine()?.focus(node.id) - setAnnounce( - `${node.label} — ${node.kind}${ - node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : "" - }`, - ) - } - const onCanvasKeyDown = (e: KeyboardEvent) => { - const list = flatNodes() - if (!list.length) return - switch (e.key) { - case "ArrowRight": - case "ArrowDown": - e.preventDefault() - kbFocus(kbIndex() + 1) - return - case "ArrowLeft": - case "ArrowUp": - e.preventDefault() - kbFocus(kbIndex() - 1) - return - case "Home": - e.preventDefault() - kbFocus(0) - return - case "End": - e.preventDefault() - kbFocus(list.length - 1) - return - case "Enter": - case " ": { - if (kbIndex() < 0) return - e.preventDefault() - onSelect(list[kbIndex()]) - return - } - } - } - // 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="application" - aria-roledescription="context tree" - aria-label={language.t("amicode.contextTree.canvasLabel")} - tabIndex={0} - onKeyDown={onCanvasKeyDown} - onBlur={() => setKbIndex(-1)} - class="block h-full w-full focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-border-focus-base" - /> -
-
- {announce()} -
-
- - ) -} diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index ab9812e44..0195d58bc 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -81,7 +81,6 @@ import { useServer } from "@/context/server" import { usePrompt } from "@/context/prompt" import { useSettings } from "@/context/settings" import { useTabs } from "@/context/tabs" -import { ContextTreePanel } from "@/pages/session/context-tree-panel" import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" import { draftPrompt } from "@/utils/start-prompt" import { inAmicode, postAmicode } from "@/pages/session/use-amicode-commands" @@ -2137,11 +2136,8 @@ export function MessageTimeline(props: { .catch(() => void refetchWarrants()) }} /> - {/* amicode: the context tree, folded into the header (ADR 0003) — - closes the sticky block beneath the title row + chip rail */} - - {/* amicode#271: bubble inside the header — naturally below the context - tree in the flow. No offset math needed. */} + {/* amicode#271: bubble inside the header — naturally below the + title row + chip rail */} {(bubble) => (