diff --git a/packages/app/e2e/perf/brain-governor-trace.spec.ts b/packages/app/e2e/perf/brain-governor-trace.spec.ts new file mode 100644 index 0000000000..9d73777771 --- /dev/null +++ b/packages/app/e2e/perf/brain-governor-trace.spec.ts @@ -0,0 +1,161 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { fixture, pageMessages } from "../smoke/session-timeline.fixture" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +/* Headless-Chromium rAF-interval trace over the real app — issue #63's + PRE-GATE PROXY. It boots the Chat on the >=40-card fixture thread with the + dev force-full-tempo hook (`?brainForceActive`: Brain pinned busy, perf + governor disabled), scrolls continuously for >=10s in EACH theme, and + emits a p95 / max-frame-time / frames>33ms report. + + THIS IS NOT THE RELEASE GATE. A passing trace never closes #63's perf + criterion: only a manual sign-off on the pinned reference laptop + (battery-powered, VS Code webview, both themes) does — see the perf-gate + checklist on the PR. Accordingly this spec asserts the harness RUNS and + REPORTS (shape, duration, theme coverage, forced worst case); it asserts + no numeric frame-time outcome. */ + +const SCROLL_MS = 11_000 // >=10s of continuous scroll per theme +const PROXY_LABEL = "pre-gate proxy — does NOT close the release gate (manual reference-laptop sign-off required)" + +type ThemeTrace = { + theme: "dark" | "light" + colorScheme: string | undefined + forced: boolean + motion: string | undefined + durationMs: number + frames: number + meanMs: number + p95Ms: number + maxMs: number + over33: number + cardsSeen: number +} + +test.describe("perf proxy: brain governor trace", () => { + test.setTimeout(300_000) + + test("headless rAF trace over a >=40-card thread at forced full tempo, both themes", async ({ page }) => { + expect(fixture.messages[fixture.targetID].length).toBeGreaterThanOrEqual(40) // the long-thread fixture + + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages, + }) + await seedStorage(page, fixture.directory) + + const traces: ThemeTrace[] = [] + for (const theme of ["dark", "light"] as const) { + await page.emulateMedia({ colorScheme: theme }) // chat colorScheme defaults to "system" + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}?brainForceActive`) + await expectSessionTitle(page, fixture.expected.targetTitle) + await expect(page.locator("[data-timeline-row]").first()).toBeVisible() + await expect(page.locator('[data-component="brain-atmosphere"] canvas')).toBeAttached() + + // the force hook must be live: Brain pinned busy, governor disabled + const stats = await page.evaluate( + () => (window as Window & { __amicoBrainStats?: () => { active: boolean; motion: string } }).__amicoBrainStats?.(), + ) + expect(stats?.active).toBe(true) + expect(stats?.motion).toBe("full") + + const trace = await tracedScroll(page, SCROLL_MS) + traces.push({ + theme, + colorScheme: trace.colorScheme, + forced: stats?.active === true && stats?.motion === "full", + motion: stats?.motion, + ...trace.report, + }) + } + + const report = { label: PROXY_LABEL, traces } + console.log(`[brain-perf-proxy] ${JSON.stringify(report, null, 2)}`) + + // the harness ran and reported — shape only, never a numeric perf verdict + expect(report.label).toContain("pre-gate proxy") + expect(traces).toHaveLength(2) + for (const t of traces) { + expect(t.colorScheme).toBe(t.theme) // the theme actually applied + expect(t.forced).toBe(true) // un-eased worst case measured + expect(t.durationMs).toBeGreaterThanOrEqual(10_000) // >=10s continuous scroll + expect(t.frames).toBeGreaterThan(100) + expect(t.p95Ms).toBeGreaterThan(0) + expect(t.maxMs).toBeGreaterThanOrEqual(t.p95Ms) + expect(t.over33).toBeGreaterThanOrEqual(0) + expect(t.cardsSeen).toBeGreaterThanOrEqual(10) // the sweep truly moved through the thread + } + }) +}) + +/** rAF-interval sampler: scrolls the timeline continuously (up through + history, bouncing at the ends) inside the SAME rAF loop that samples the + frame intervals — the trace measures the app under scroll, not idle. */ +async function tracedScroll(page: Page, minMs: number) { + return page.evaluate(async (durationMs) => { + const scroller = [...document.querySelectorAll(".scroll-view__viewport")].find((el) => + el.querySelector("[data-timeline-row]"), + ) + if (!scroller) throw new Error("perf trace: no timeline scroller found") + const seen = new Set() + const intervals: number[] = [] + let dir = -1 // start at the thread's foot, sweep up through history + await new Promise((resolve) => { + let last = -1 + let t0 = -1 + const step = (now: number) => { + if (t0 < 0) { + t0 = now + last = now + } else { + intervals.push(now - last) + last = now + } + scroller.scrollTop += dir * 90 + if (scroller.scrollTop <= 2) dir = 1 + else if (scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2) dir = -1 + if (intervals.length % 20 === 0) { + for (const el of scroller.querySelectorAll("[data-message-id]")) { + if (el.dataset.messageId) seen.add(el.dataset.messageId) + } + } + if (now - t0 >= durationMs) return resolve() + requestAnimationFrame(step) + } + requestAnimationFrame(step) + }) + const sorted = intervals.slice().sort((a, b) => a - b) + const q = (p: number) => sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * p) - 1)] ?? 0 + const round = (v: number) => Math.round(v * 100) / 100 + return { + colorScheme: document.documentElement.dataset.colorScheme, + report: { + durationMs: Math.round(intervals.reduce((a, b) => a + b, 0)), + frames: intervals.length, + meanMs: round(intervals.reduce((a, b) => a + b, 0) / Math.max(intervals.length, 1)), + p95Ms: round(q(0.95)), + maxMs: round(sorted[sorted.length - 1] ?? 0), + over33: intervals.filter((d) => d > 33).length, + cardsSeen: seen.size, + }, + } + }, minMs) +} + +/** the same storage seed the smoke spec uses: a known project + settings */ +async function seedStorage(page: Page, directory: string) { + await page.addInitScript((dir) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: dir, expanded: true }] }, + lastProject: { local: dir }, + }), + ) + }, directory) +} diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index fae743bb81..5be601969a 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -9,6 +9,7 @@ import { type Component, Show } from "solid-js" import { useLocal } from "@/context/local" import { popularProviders, useProviders } from "@/hooks/use-providers" import { ModelTooltip } from "./model-tooltip" +import { ModelVariantRow } from "./dialog-select-model" import { useLanguage } from "@/context/language" type ModelState = ReturnType["model"] @@ -83,6 +84,9 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props )} + {/* thinking effort folded into the model surface (Kate 2026-07-25) — + the unpaid path keeps the control the footer Select used to carry */} +
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index ca2643c3b5..2a2d2fc43d 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,5 +1,5 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js" +import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -85,6 +85,52 @@ const ModelList: Component<{ ) } +/** Thinking effort, INSIDE the model surface (Kate 2026-07-25): one control + for "which brain, how hard". Renders nothing unless the current model + exposes variants; picking one never dismisses the host — effort is an + attribute, not a selection. Shared by the paid popover and the unpaid + dialog so no path loses the control the standalone footer Select had. */ +export const ModelVariantRow: Component<{ model?: ModelState; class?: string }> = (props) => { + const model = props.model ?? useLocal().model + const language = useLanguage() + const options = createMemo(() => { + const list = model.variant.list() + return list.length > 0 ? ["default", ...list] : [] + }) + return ( + 0}> +
+ + {language.t("dialog.model.variant.label")} + +
+ + {(option) => { + const selected = () => (model.variant.current() ?? "default") === option + return ( + + ) + }} + +
+
+
+ ) +} + type ModelSelectorTriggerProps = Omit, "as" | "ref"> type Dismiss = "escape" | "outside" | "select" | "manage" | "provider" @@ -125,6 +171,11 @@ export function ModelSelectorPopover(props: { } const language = useLanguage() + // Kate 2026-07-25: thinking effort lives INSIDE the model menu — see + // ModelVariantRow (shared with the unpaid dialog; the footer Select is gone) + const model = props.model ?? useLocal().model + const hasVariants = createMemo(() => model.variant.list().length > 0) + return ( + {/* glass sweep (#56): the model menu floats on the single glass recipe */} { close("escape") event.preventDefault() @@ -190,6 +243,10 @@ export function ModelSelectorPopover(props: {
} /> + +
+ + diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ea93a9aa29..efd964b015 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -76,6 +76,7 @@ import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" +import { AMICODE_STARTERS } from "@opencode-ai/ui/amicode-getting-started" import { useDirectoryPicker } from "./directory-picker" import { showToast } from "@/utils/toast" import { hiddenProjectWorktree } from "@/utils/amicode-hidden-project" @@ -269,7 +270,6 @@ export const PromptInput: Component = (props) => { draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean - variantOpen: boolean }>({ popover: null, historyIndex: -1, @@ -278,7 +278,6 @@ export const PromptInput: Component = (props) => { draggingType: null, mode: "normal", applyingHistory: false, - variantOpen: false, }) const [picker, setPicker] = createStore({ projectOpen: false, @@ -568,12 +567,55 @@ export const PromptInput: Component = (props) => { onCleanup(() => clearInterval(interval)) }) + // Kate 2026-07-25: cycle the starter prompts as the composer placeholder on + // the empty landing. Pauses when focused (freeze so Tab can accept it) or + // under reduced motion; only while empty (suggest()). Each label holds long + // enough to type out + read before the next. + createEffect(() => { + if (params.id) return + if (props.variant !== "new-session") return + if (focused() || reduceMotionPref() || !suggest()) return + const interval = setInterval(() => { + setStarterIx((prev) => (prev + 1) % AMICODE_STARTERS.length) + }, 4200) + onCleanup(() => clearInterval(interval)) + }) + + // Kate 2026-07-25: TYPEWRITER — type the active starter label out letter by + // letter (~34ms/char). Re-runs when starterIx changes; idle when focused / + // reduced-motion / non-empty (designPlaceholder shows the full label then). + createEffect(() => { + if (params.id) return // starters are the EMPTY LANDING's — never in-session + if (props.variant !== "new-session") return + if (focused() || reduceMotionPref() || !suggest()) return + const label = AMICODE_STARTERS[starterIx() % AMICODE_STARTERS.length]?.label ?? "" + setTyped("") + let i = 0 + const timer = setInterval(() => { + i += 1 + setTyped(label.slice(0, i)) + if (i >= label.length) clearInterval(timer) + }, 34) + onCleanup(() => clearInterval(timer)) + }) + const [composing, setComposing] = createSignal(false) const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 + // Kate 2026-07-25: rotating starter placeholder (replaces the chip wall). + // The composer cycles the real starter prompts while empty + unfocused; on + // focus the current one freezes (Tab accepts it); reduced-motion is static. + const [focused, setFocused] = createSignal(false) + const [starterIx, setStarterIx] = createSignal(0) + const [typed, setTyped] = createSignal("") // the currently typed-out substring + const reducedMotionQuery = typeof window !== "undefined" ? window.matchMedia?.("(prefers-reduced-motion: reduce)") : undefined + const [reduceMotionPref, setReduceMotionPref] = createSignal(reducedMotionQuery?.matches ?? false) + reducedMotionQuery?.addEventListener?.("change", (e) => setReduceMotionPref(e.matches)) + const handleBlur = () => { closePopover() setComposing(false) + setFocused(false) } const handleCompositionStart = () => { @@ -1110,9 +1152,6 @@ export const PromptInput: Component = (props) => { /> ) - const variants = createMemo(() => ["default", ...local.model.variant.list()]) - // Check provider variants directly: `variants` also includes the UI-only default option. - const showVariantControl = createMemo(() => local.model.variant.list().length > 0) const accepting = createMemo(() => { const id = params.id if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) @@ -1144,6 +1183,27 @@ export const PromptInput: Component = (props) => { }) const handleKeyDown = (event: KeyboardEvent) => { + // Kate 2026-07-25: Tab accepts the shown rotating starter suggestion (fills + // the composer, does not submit — the user can edit or send). + if ( + event.key === "Tab" && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + props.variant === "new-session" && + !params.id && // starters are the empty landing's — never in-session + !reduceMotionPref() && + suggest() && + blank() + ) { + const starter = AMICODE_STARTERS[starterIx() % AMICODE_STARTERS.length] + if (starter) { + event.preventDefault() + prompt.set([{ type: "text", content: starter.prompt, start: 0, end: starter.prompt.length }], starter.prompt.length) + return + } + } // Amicode webview: the framed app has no clipboard-read permission, so the // browser dispatches no usable paste event on ⌘V (unlike plain web/desktop, // where onPaste handles it). Intercept the keystroke and read the OS @@ -1348,9 +1408,23 @@ export const PromptInput: Component = (props) => { (p) => p, ) + const GENERIC_PLACEHOLDER = "Ask Amico anything, / for commands, @ for context..." + // Kate 2026-07-26: the session composer shares variant="new-session" (one + // dock, one look) — but starter suggestions belong to the EMPTY LANDING + // alone, so every starter gate also requires no session id. + const starterStage = createMemo(() => props.variant === "new-session" && !params.id) + // the composer is actively typing a starter suggestion (empty landing, not + // focused, motion allowed) — drives the typewriter caret. + const typewriterActive = createMemo(() => starterStage() && !reduceMotionPref() && suggest() && !focused()) const designPlaceholder = () => { if (store.mode === "shell") return placeholder() - return "Ask Amico anything, / for commands, @ for context..." + if (starterStage() && !reduceMotionPref() && suggest()) { + const label = AMICODE_STARTERS[starterIx() % AMICODE_STARTERS.length]?.label ?? GENERIC_PLACEHOLDER + // typewriter substring while rotating; the full frozen label while focused + // (so Tab accepts a complete, readable suggestion) + return focused() ? label : typed() + } + return GENERIC_PLACEHOLDER } const modelControlState = createMemo(() => ({ @@ -1361,6 +1435,9 @@ export const PromptInput: Component = (props) => { model: local.model, providerID: local.model.current()?.provider?.id, modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"), + // a non-default thinking effort stays visible at rest ("· high") — the + // standalone variant control folded into the model menu (Kate 2026-07-25) + variantName: local.model.variant.current() ?? undefined, style: control(), onClose: restoreFocus, onUnpaidClick: () => { @@ -1507,19 +1584,30 @@ export const PromptInput: Component = (props) => { /> -
+ {/* Kate 2026-07-25: on the landing this wrapper must fill the composer + dock's height so the composer reaches the bottom of the screen. */} +
= (props) => { removeLabel={language.t("prompt.attachment.remove")} />
{ const target = e.target if (!(target instanceof HTMLElement)) return @@ -1560,7 +1652,14 @@ export const PromptInput: Component = (props) => { editorRef?.focus() }} > -
(scrollRef = el)}> +
(scrollRef = el)} + >
= (props) => { onPaste={handlePaste} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + onFocus={() => setFocused(true)} onBlur={handleBlur} onKeyDown={handleKeyDown} classList={{ @@ -1601,10 +1701,17 @@ export const PromptInput: Component = (props) => { classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }} > {designPlaceholder()} + {/* typewriter caret — blinks while a starter is being typed */} + +
-
+ {/* Kate 2026-07-25: the footer shares the composer's ONE glass + surface — no separate inset fill (that read as a second + background). */} +
{fileAttachmentInput()} = (props) => { - - - + {/* Kate 2026-07-25: the project/folder picker is removed from the + composer entirely (always, even orphan sessions). */}
{/* amicode chat-redesign (Kate): model + speed live on the RIGHT, clustered with the submit arrow (Kimi-style). */}
+ {/* Kate 2026-07-25: thinking effort folded INTO the model menu — + no standalone variant Select in the footer anymore. */} - -
- - (x === "default" ? language.t("common.default") : x)} - onSelect={(value) => { - local.model.variant.set(value === "default" ? undefined : value) - restoreFocus() - }} - class="capitalize max-w-[160px] text-text-base" - valueClass="truncate text-13-regular text-text-base" - triggerStyle={control()} - triggerProps={{ "data-action": "prompt-model-variant" }} - variant="ghost" - /> - -
-
@@ -2084,6 +2133,8 @@ type ComposerModelControlState = { model: ReturnType["model"] providerID?: string modelName: string + /** active thinking effort (undefined = the provider default, not shown) */ + variantName?: string style: JSX.CSSProperties | undefined onClose: () => void onUnpaidClick: () => void @@ -2152,8 +2203,10 @@ function ComposerPicker(props: { state: ComposerPickerState }) { > + {/* glass sweep (#56): the project picker floats on the single glass recipe */} event.preventDefault()} >
@@ -2238,6 +2291,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) { )} {props.state.modelName} + + {(variantName) => · {variantName()}} + @@ -2267,6 +2323,9 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) { )} {props.state.modelName} + + {(variantName) => · {variantName()}} + diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index 95289f9894..8abed5458e 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -42,9 +42,11 @@ export const PromptContextItems: Component = (props) => {
props.openComment(item)} > diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx index 41962ce536..b5f6b661b3 100644 --- a/packages/app/src/components/prompt-input/drag-overlay.tsx +++ b/packages/app/src/components/prompt-input/drag-overlay.tsx @@ -14,7 +14,10 @@ const kindToIcon = { export const PromptDragOverlay: Component = (props) => { return ( -
+ {/* glass sweep (#56): the drop scrim must MASK the composer beneath, so it + keeps a heavier token tint — but now with the shared blur (glass, just + denser), never a raw 90%-opaque token alone */} +
{props.label} diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx index dd8138e5a4..cea8bace48 100644 --- a/packages/app/src/components/prompt-input/image-attachments.tsx +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -10,12 +10,18 @@ type PromptImageAttachmentsProps = { removeLabel: string } -const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base" +// glass sweep (#56): tiles ride the dense-zone token; the remove pill and the +// filename bar sit OVER arbitrary image pixels, so they keep HEAVIER token +// tints (color-mix over the float/raised tokens) — masking controls, never a +// raw opaque token or the theme-blind bg-black/50 literal. +const fallbackClass = + "size-16 rounded-md bg-[var(--glass-dense-bg)] flex items-center justify-center border border-border-base" const imageClass = "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" const removeClass = - "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" -const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md" + "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_85%,transparent)] border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--accent-fill-soft)]" +const nameClass = + "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-[color-mix(in_srgb,var(--surface-float-base)_60%,transparent)] rounded-b-md" export const PromptImageAttachments: Component = (props) => { return ( diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index d8c4bd035c..c5ee58fe68 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -36,13 +36,15 @@ type PromptPopoverProps = { export const PromptPopover: Component = (props) => { return ( + {/* glass sweep (#56): the autocomplete panel floats on the single glass + recipe — the opaque raised fill and bespoke shadow are gone */}
{ if (props.popover === "slash") props.setSlashPopoverRef(el) }} + data-glass="standard" class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] - bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]" + overflow-auto no-scrollbar flex flex-col p-2" onMouseDown={(e) => e.preventDefault()} > @@ -59,7 +61,7 @@ export const PromptPopover: Component = (props) => { return (
- + {cmd.source === "skill" ? props.t("prompt.slash.badge.skill") : cmd.source === "mcp" diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx index 89396e1c7a..0d58d4b772 100644 --- a/packages/app/src/components/session/session-new-design-view.tsx +++ b/packages/app/src/components/session/session-new-design-view.tsx @@ -1,30 +1,26 @@ import { Show, type JSX } from "solid-js" -import { Logo, MarkDetailed } from "@opencode-ai/ui/logo" -import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" -// amicode#chat-redesign (Kate): composer-as-hero, Kimi-style. Vertically -// centered column — brand mark + wordmark (tight), then the composer as the -// dominant element, then a quiet row of starter chips BELOW it. No tagline / -// how-it-works block: the chips carry that story, everything else is air. +// amicode#chat-redesign (Kate 2026-07-25): the composer is a full-bleed dock +// stuck to the BOTTOM of the screen, ~40% of the viewport tall, edge-to-edge +// (no radius, no border). The rotating latent constellation fills the space +// above it; the starter chips sit just above the dock. No brand mark/wordmark. export function NewSessionDesignView(props: { children: JSX.Element; gettingStarted?: JSX.Element }) { - // bg-base, not bg-deep — the deep tier read too dark/high-contrast in dark - // mode (Kate). + // amicode latent-constellation: NO opaque fill here — a full-bleed background + // would occlude the Brain entirely (the pane's base coat lives below the + // canvas in the host page). Content floats on its own glass. return ( -
-
-
- {/* Kimi-style hero: the low-contrast mark + the AMICODE wordmark - (Logo), full-ink in the neutral text color. */} - {/* mark + wordmark share one ink (Kate 2026-07-23): the Logo wordmark - fills with var(--icon-base) in logo.tsx, so the mark matches it. */} - - - {/* chips sit directly below the mark, above the composer (Kate) */} - -
{props.gettingStarted}
-
-
{props.children}
-
+
+ {/* upper region: the constellation shows through; chips anchor just above + the docked composer */} +
+ +
{props.gettingStarted}
+
+
+ {/* composer: full-width, bottom-stuck, ~35% of the viewport height, + edge-to-edge (radius/border zeroed on the composer itself) */} +
+ {props.children}
) diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index f3c7d40812..b62012de6f 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -77,7 +77,9 @@ export function NewSessionView(props: NewSessionViewProps) {
-
+ {/* latent-constellation: the brand block floats on its own glass + above the rotating web (glass vars — the #60 recipe) */} +
{/* amicode: brand wordmark in the brand typeface (Racing Sans One — restored per review); MarkDetailed is the redesign's detailed mark (kept from trunk) */} @@ -92,7 +94,10 @@ export function NewSessionView(props: NewSessionViewProps) { if (name) startPrompt(`Open the problem "${name}" and continue where we left off`) }} /> -
+ {/* glass sweep (#56) + latent-constellation: muted workspace metadata + rides ONE dense-var glass zone WITH the blur (the question-hint + pattern) — centered, never bare faint text over the moving Brain */} +
{getDirectory(projectRoot())} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d68e3e1c3b..2b19d8bd90 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -115,6 +115,7 @@ export const dict = { "dialog.model.search.placeholder": "Search models", "dialog.model.empty": "No model results", "dialog.model.manage": "Manage models", + "dialog.model.variant.label": "Thinking effort", "dialog.model.manage.description": "Customize which models appear in the model selector.", "dialog.model.manage.provider.toggle": "Toggle all {{provider}} models", diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 8190a1a126..9a7e295aef 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -1,6 +1,7 @@ -import { createEffect, createMemo, createResource, onMount, untrack } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, onMount, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useSearchParams } from "@solidjs/router" +import { BrainAtmosphere } from "@opencode-ai/ui/brain-atmosphere" import { NewSessionDesignView } from "@/components/session" import { useComments } from "@/context/comments" import { usePrompt } from "@/context/prompt" @@ -31,6 +32,14 @@ export default function NewSessionPage() { const composer = createSessionComposerState() + // amicode latent-constellation: the first prompt send ignites the handoff — + // rotation eases to a stop and the latent web starts dissolving. The + // draft→session promotion remounts the canvas (the session route keys its + // own Brain), so the visible dissolve is bounded by the session-create + // round trip; the session then boots its live graph normally, whose core + // ignition (#fff676) is the "first node ignites" beat of the design. + const [ignited, setIgnited] = createSignal(false) + // amicode: register the Amico ops commands here too — the draft page has no // palette, so restart/update-memory are reachable via their direct keybinds. useAmicodeCommands() @@ -78,24 +87,23 @@ export default function NewSessionPage() { return (
-
+ {/* Kate 2026-07-25: no bottom padding on the landing — the composer dock + reaches the very bottom of the screen (top corners stay rounded). */} +
-
- { - const name = resumeProblem()?.name - if (name) startPrompt(`Open the problem "${name}" and continue where we left off`) - }} - /> - } - > + {/* relative isolate: own stacking context so the brain layer (-z-10) + sits above this card's surface but beneath the draft content */} +
+ {/* amicode: the draft page's Brain background — the EMPTY landing + shows the full latent constellation ("everything amico could + think") rotating at rest; the first prompt send ignites the + handoff and the promoted session mounts its live graph */} + + {/* Kate 2026-07-25: the starter-chip wall is removed — it competed + with the constellation + composer. Discovery now lives inside the + composer as a rotating placeholder that cycles the starter prompts + (prompt-input.tsx), so the resting landing is just hero + CTA. */} + setStore("worktree", "main")} - onSubmit={() => comments.clear()} + onSubmit={() => { + setIgnited(true) // first prompt sent: hand the Brain off + comments.clear() + }} onResponseSubmit={() => {}} setPromptDockRef={() => {}} /> diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index f5949620a1..876b2b3f4f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -10,6 +10,7 @@ import { createMemo, createEffect, createComputed, + createSignal, on, onMount, untrack, @@ -22,6 +23,7 @@ import { debounce } from "@solid-primitives/scheduled" import { useLocal } from "@/context/local" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { createStore } from "solid-js/store" +import { BrainAtmosphere } from "@opencode-ai/ui/brain-atmosphere" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Select } from "@opencode-ai/ui/select" import { Tabs } from "@opencode-ai/ui/tabs" @@ -54,6 +56,7 @@ import { shouldFocusTerminalOnKeyDown, shouldShowFileTree, } from "@/pages/session/helpers" +import { createBrainEvents } from "@/pages/session/brain-events" import { MessageTimeline } from "@/pages/session/message-timeline" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" @@ -206,6 +209,18 @@ export default function Page() { const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) + // amicode: the chat-wide Brain background's live feed — the active + // session's derived event stream, empty on the landing (ADR 0002) + const brain = createBrainEvents(() => params.id) + + // amicode latent-constellation: the EMPTY landing (no session id) mounts + // the Brain in constellation mode; the first prompt send ignites the + // handoff. Navigation to the created session flips the keyed mount below + // to a fresh LIVE engine, so the visible dissolve is bounded by the + // session-create round trip — the session's own core ignition (#fff676) + // is the "first node ignites" beat. In-session mounts are untouched. + const [brainIgnited, setBrainIgnited] = createSignal(false) + createEffect(() => { if (!prompt.ready()) return untrack(() => { @@ -610,6 +625,7 @@ export default function Page() { } let inputRef!: HTMLDivElement + let brainPane: HTMLDivElement | undefined // the Brain's host pane — occlusion rects are measured against it let promptDock: HTMLDivElement | undefined let dockHeight = 0 let scroller: HTMLDivElement | undefined @@ -1686,6 +1702,7 @@ export default function Page() { newSessionWorktree={newSessionWorktree()} onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} onSubmit={() => { + if (!params.id) setBrainIgnited(true) // landing: hand the Brain off comments.clear() resumeScroll() }} @@ -1773,12 +1790,51 @@ export default function Page() { }} >
(brainPane = el)} classList={{ - "flex-1 min-h-0 flex flex-col bg-background-stronger": true, + // relative isolate: own stacking context so the brain layer + // (-z-10) sits above this card's surface but beneath all content + "relative isolate flex-1 min-h-0 flex flex-col bg-background-stronger": true, "rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(), "shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id, }} > + {/* amicode (Kate 2026-07-25): ONE full-bleed 3D constellation per + Chat window everywhere — the launch and the session are one + surface. Live session activity pulses through the constellation's + nodes (data-true thought over the shared 3D network). Keyed on the + active session so a tab swap remounts that session's pulses. + Kate 2026-07-26: flares must land where they can be SEEN — the + occlusion fn reports the glass-covered regions (message column + + composer dock) so thought lands in the visible gutters. */} + + {(_key) => ( + { + // Kate 2026-07-26: messages justify to the pane edges and + // the tracked thought comes to CENTER, so the only region + // flares should avoid seating under is the composer dock. + const pane = brainPane + if (!pane) return [] + const base = pane.getBoundingClientRect() + const rects: { x: number; y: number; w: number; h: number }[] = [] + const dock = pane.querySelector( + '[data-slot="session-composer-dock"], [data-slot="new-session-composer-dock"]', + ) + if (dock) { + const r = dock.getBoundingClientRect() + rects.push({ x: r.left - base.left, y: r.top - base.top, w: r.width, h: r.height }) + } + return rects + }} + /> + )} +
@@ -1835,7 +1891,17 @@ export default function Page() {
- {composerRegion("dock")} + {/* Kate 2026-07-26: the session composer IS the launch composer — + the same full-bleed bottom dock (~35% of the pane, edge-to-edge + one-surface glass, no radius/ring). Legacy layout keeps the + floating column card. */} + + +
+ {composerRegion("inline")} +
+
+
diff --git a/packages/app/src/pages/session/brain-events.test.ts b/packages/app/src/pages/session/brain-events.test.ts new file mode 100644 index 0000000000..fa93272577 --- /dev/null +++ b/packages/app/src/pages/session/brain-events.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from "bun:test" +import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2" +import { deriveBrainActive, deriveBrainEvents } from "./brain-events" + +/* The live-feed contract for the chat-wide Brain background: the inline + strip's event derivation, lifted to a pure session-scoped function so it + can be pinned headless against sync-store-shaped fixtures — no Solid + context, no component mount. */ + +type PartsByMessage = Record + +const userMsg = (id: string) => + ({ + id, + sessionID: "s", + role: "user", + time: { created: 1 }, + }) as UserMessage + +const assistantMsg = (id: string, opts: { parentID?: string; completed?: boolean } = {}) => + ({ + id, + sessionID: "s", + role: "assistant", + parentID: opts.parentID, + time: opts.completed ? { created: 2, completed: 3 } : { created: 2 }, + }) as unknown as AssistantMessage + +const textPart = (id: string, messageID: string, text: string) => + ({ id, messageID, sessionID: "s", type: "text", text }) as Part + +const toolPart = (id: string, messageID: string, tool: string, input: Record) => + ({ + id, + messageID, + sessionID: "s", + type: "tool", + tool, + callID: `call-${id}`, + state: { status: "completed", input }, + }) as unknown as Part + +const partsFor = + (parts: PartsByMessage) => + (messageID: string): Part[] => + parts[messageID] ?? [] + +describe("deriveBrainEvents", () => { + test("a fresh session with no assistant activity yields an empty stream", () => { + expect(deriveBrainEvents([], partsFor({}))).toEqual([]) + expect(deriveBrainEvents([userMsg("u1")], partsFor({ u1: [textPart("t1", "u1", "hello")] }))).toEqual([]) + }) + + test("touches land in message order, mapped through the tool→brain-ref lookup", () => { + const messages = [ + userMsg("u1"), + assistantMsg("a1", { parentID: "u1", completed: true }), + assistantMsg("a2", { parentID: "u1" }), + ] + const events = deriveBrainEvents( + messages, + partsFor({ + a1: [ + toolPart("p1", "a1", "read", { filePath: "vault/notes.md" }), + toolPart("p2", "a1", "grep", { pattern: "saveat" }), + ], + a2: [toolPart("p3", "a2", "skill", { name: "tdd" })], + }), + ) + const touches = events.filter((e) => e.kind === "touch") + expect(touches.map((e) => e.id)).toEqual(["p1", "p2", "p3"]) + expect(touches[0]).toMatchObject({ label: "notes.md", type: "note", consider: false }) + expect(touches[1]).toMatchObject({ label: "saveat", type: "resource", consider: true }) + expect(touches[2]).toMatchObject({ label: "tdd", type: "skill", consider: false }) + }) + + test("tools with no brain reference stay out of the visible thought", () => { + const events = deriveBrainEvents( + [assistantMsg("a1", { completed: true })], + partsFor({ a1: [toolPart("p1", "a1", "todowrite", { todos: [] })] }), + ) + expect(events).toEqual([]) + }) + + test("completed turns replay; the in-flight busy turn is live", () => { + const messages = [assistantMsg("a1", { completed: true }), assistantMsg("a2", {})] + const events = deriveBrainEvents( + messages, + partsFor({ + a1: [toolPart("p1", "a1", "read", { filePath: "solve.jl" })], + a2: [toolPart("p2", "a2", "read", { filePath: "setup.jl" })], + }), + ) + expect(events).toMatchObject([ + { kind: "touch", id: "p1", replay: true }, + { kind: "touch", id: "p2", replay: false }, + ]) + }) + + test("a completed turn with ≥2 commits charts a constellation titled from the parent prompt", () => { + const prompt = "Optimize the fluxonium X gate carefully" + const messages = [userMsg("u1"), assistantMsg("a1", { parentID: "u1", completed: true })] + const events = deriveBrainEvents( + messages, + partsFor({ + u1: [textPart("t1", "u1", prompt)], + a1: [ + toolPart("p1", "a1", "read", { filePath: "solve.jl" }), + toolPart("p2", "a1", "read", { filePath: "setup.jl" }), + ], + }), + ) + // the chart marker lands after that message's touches + expect(events.map((e) => e.kind)).toEqual(["touch", "touch", "chart"]) + expect(events[2]).toMatchObject({ id: "chart-a1", title: prompt.trim().slice(0, 28) }) + }) + + test("considers do not count toward the chart threshold", () => { + const events = deriveBrainEvents( + [assistantMsg("a1", { completed: true })], + partsFor({ + a1: [ + toolPart("p1", "a1", "read", { filePath: "solve.jl" }), + toolPart("p2", "a1", "grep", { pattern: "saveat" }), + toolPart("p3", "a1", "glob", { pattern: "**/*.jl" }), + ], + }), + ) + expect(events.filter((e) => e.kind === "chart")).toEqual([]) + }) + + test("an in-flight turn never charts, even with ≥2 commits", () => { + const events = deriveBrainEvents( + [assistantMsg("a1", {})], + partsFor({ + a1: [ + toolPart("p1", "a1", "read", { filePath: "solve.jl" }), + toolPart("p2", "a1", "read", { filePath: "setup.jl" }), + ], + }), + ) + expect(events.filter((e) => e.kind === "chart")).toEqual([]) + }) + + test("each completed ≥2-commit turn charts — one plate per turn", () => { + const messages = [ + userMsg("u1"), + assistantMsg("a1", { parentID: "u1", completed: true }), + userMsg("u2"), + assistantMsg("a2", { parentID: "u2", completed: true }), + ] + const events = deriveBrainEvents( + messages, + partsFor({ + u1: [textPart("t1", "u1", "first ask")], + u2: [textPart("t2", "u2", "second ask")], + a1: [ + toolPart("p1", "a1", "read", { filePath: "one.md" }), + toolPart("p2", "a1", "read", { filePath: "two.md" }), + ], + a2: [ + toolPart("p3", "a2", "read", { filePath: "three.md" }), + toolPart("p4", "a2", "read", { filePath: "four.md" }), + ], + }), + ) + expect(events.filter((e) => e.kind === "chart").map((e) => e.id)).toEqual(["chart-a1", "chart-a2"]) + expect(events.filter((e) => e.kind === "chart").map((e) => "title" in e && e.title)).toEqual([ + "first ask", + "second ask", + ]) + }) + + test("duplicate ids are de-duplicated — the stream never repeats an event", () => { + const dup = toolPart("p1", "a1", "read", { filePath: "solve.jl" }) + const events = deriveBrainEvents( + [assistantMsg("a1", { completed: true })], + partsFor({ a1: [dup, dup, toolPart("p2", "a1", "read", { filePath: "setup.jl" })] }), + ) + expect(events.map((e) => e.id)).toEqual(["p1", "p2", "chart-a1"]) + }) +}) + +describe("deriveBrainActive", () => { + // the Brain's heartbeat signal (#62): the session-busy status routed into + // engine.setActive — full musical tempo while a turn works, rest otherwise + test("a session is active exactly while its status is non-idle", () => { + expect(deriveBrainActive(undefined)).toBe(false) // no session / no status yet: at rest + expect(deriveBrainActive({ type: "idle" })).toBe(false) + expect(deriveBrainActive({ type: "busy" })).toBe(true) + expect(deriveBrainActive({ type: "retry", attempt: 1, message: "rate limited", next: 0 })).toBe(true) + }) +}) diff --git a/packages/app/src/pages/session/brain-events.ts b/packages/app/src/pages/session/brain-events.ts new file mode 100644 index 0000000000..7de722a318 --- /dev/null +++ b/packages/app/src/pages/session/brain-events.ts @@ -0,0 +1,83 @@ +// amicode: derive the chat-wide Brain background's event stream from a +// session's live sync state. Successor to brain-strip.tsx's inline `events` +// memo — the same mapping (tool call → brain-graph reference via +// amicoBrainRef; a chart marker after each completed assistant turn that +// committed ≥2 touches), now feeding the full-bleed +// instead of a timeline row. The stream is cumulative and diffed by id +// inside the component, so completed turns replay into the atlas silently +// and the busy turn animates live. + +import { createMemo, type Accessor } from "solid-js" +import type { Message, Part, SessionStatus } from "@opencode-ai/sdk/v2/client" +import { amicoBrainRef } from "@opencode-ai/ui/brain-ref" +import type { BrainAtmosphereEvent } from "@opencode-ai/ui/brain-atmosphere" +import { useSync } from "@/context/sync" + +/** Pure, session-scoped derivation — the strip's `events` logic lifted out so + * it is unit-testable headless and shared by the chat-wide mount. Touches + * land in message order; completed turns carry `replay: true` (the busy + * turn stays live); ids never repeat. */ +export function deriveBrainEvents( + messages: Message[], + getParts: (messageID: string) => Part[], +): BrainAtmosphereEvent[] { + 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 "" + } + + const seen = new Set() + const out: BrainAtmosphereEvent[] = [] + 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 (seen.has(p.id)) continue + seen.add(p.id) + if (!ref.consider) commits++ + out.push({ kind: "touch", replay: done, id: p.id, label: ref.label, type: ref.type, consider: ref.consider }) + } + if (done && commits >= 2) { + const id = `chart-${m.id}` + if (!seen.has(id)) { + seen.add(id) + out.push({ kind: "chart", id, title: turnTitle(m.parentID) }) + } + } + } + return out +} + +/** The Brain's heartbeat signal (#62): a session is active exactly while its + * status is non-idle (busy or retrying) — the strip's `busy` derivation, + * lifted pure so it is unit-testable headless. Routed into + * `engine.setActive`: full musical tempo while a turn works, rest otherwise. */ +export function deriveBrainActive(status: SessionStatus | undefined): boolean { + return (status?.type ?? "idle") !== "idle" +} + +/** The live feed for a Chat window's Brain: the active session's cumulative + * event stream and busy signal out of the sync store — empty/at-rest on the + * landing (no session). */ +export function createBrainEvents(sessionID: Accessor) { + const sync = useSync() + const events = createMemo(() => { + const id = sessionID() + if (!id) return [] + return deriveBrainEvents(sync.data.message[id] ?? [], (messageID) => sync.data.part[messageID] ?? []) + }) + const active = createMemo(() => { + const id = sessionID() + if (!id) return false + return deriveBrainActive(sync.data.session_status[id]) + }) + return { events, active } +} 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/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 5ff5e99a45..6bda0d7ee8 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -145,15 +145,21 @@ export function SessionComposerRegion(props: { ref={props.setPromptDockRef} data-component="session-prompt-dock" classList={{ - "w-full flex flex-col justify-center items-center pointer-events-none": true, - "shrink-0 pb-3 bg-background-stronger": props.placement !== "inline", + "w-full flex flex-col pointer-events-none": true, + // amicode #61: the dock band stays transparent — an opaque fill here + // would tile the Brain away behind the floating composer (ADR 0002) + "shrink-0 pb-3 justify-center items-center": props.placement !== "inline", + // Kate 2026-07-25: inline = the full-bleed bottom dock — fill the height + // and stretch edge-to-edge (no centered max-width column) + "h-full justify-end items-stretch": props.placement === "inline", }} >
@@ -197,7 +203,12 @@ export function SessionComposerRegion(props: {
)}
-
+ {/* glass sweep (#56): the loading stub rides the glass recipe + (dense: its text is muted-role), not a half-opaque one-off */} +
{handoffPrompt() || language.t("prompt.loading")}
@@ -245,6 +256,10 @@ export function SessionComposerRegion(props: {
{language.t("session.child.promptDisabled")} diff --git a/packages/app/src/pages/session/composer/session-followup-dock.tsx b/packages/app/src/pages/session/composer/session-followup-dock.tsx index 7d744f4e6c..fbd826be8a 100644 --- a/packages/app/src/pages/session/composer/session-followup-dock.tsx +++ b/packages/app/src/pages/session/composer/session-followup-dock.tsx @@ -28,6 +28,8 @@ export function SessionFollowupDock(props: { return ( props.items[0]?.text ?? "") + // glass sweep (#56): revert tray floats on standard glass return ( - +
diff --git a/packages/app/src/pages/session/glass-float.test.ts b/packages/app/src/pages/session/glass-float.test.ts new file mode 100644 index 0000000000..30903524da --- /dev/null +++ b/packages/app/src/pages/session/glass-float.test.ts @@ -0,0 +1,243 @@ +// AMICODE glass float (#61) — app-side source contract. +// +// The ui-package test (packages/ui/src/amicode/glass-float.test.ts) certifies +// the tier map, the contrast mapping over the running-brain reference frame, +// and the ui components' tier attributes. This file pins the app-side seams: +// +// - the composer card (session-composer / session-new-composer) floats on +// the STANDARD tier and no longer paints an opaque layer fill; its dock +// band no longer tiles the Brain away; +// - the diff card (session-turn-diffs-group) floats on the DENSE tier; +// - gaps stay live: the timeline row frame (session-turn) and the message +// container/column wrappers carry no glass and paint no background; +// - the in-timeline sticky session-title header keeps its PRE-EXISTING +// chrome blur, untouched, and carries no data-glass (out-of-scope chrome +// per the issue — the no-literal scan explicitly excludes it). +// +// Solid components cannot be rendered under bun test in this repo (no JSX +// runtime transform), so the render-level companion evidence is the browser +// pass recorded on the issue; these scans keep the contract green in CI. + +import { describe, expect, test } from "bun:test" +import { join } from "node:path" + +const APP_SRC = join(import.meta.dir, "../..") + +async function read(rel: string): Promise { + return await Bun.file(join(APP_SRC, rel)).text() +} + +/** The JSX open tag containing a marker (single `` span). */ +function openTag(source: string, marker: string, from = 0): string { + const idx = source.indexOf(marker, from) + expect(idx).toBeGreaterThan(-1) + const start = source.lastIndexOf("<", idx) + const end = source.indexOf(">", idx) + return source.slice(start, end + 1) +} + +describe("glass float — the composer floats on standard", () => { + test("the composer DockShellForm carries standard glass and drops the opaque layer fill", async () => { + const src = await read("components/prompt-input.tsx") + const idx = src.indexOf('data-component={newSession() ? "session-new-composer" : "session-composer"}') + expect(idx).toBeGreaterThan(-1) + // the form's opening block: from { + const src = await read("pages/session/composer/session-composer-region.tsx") + const idx = src.indexOf("session.child.promptDisabled") + expect(idx).toBeGreaterThan(-1) + const cardStart = src.lastIndexOf(" { + // The footer used to ride its own dense-backed zone; that read as a second + // background (dark text area / lighter footer). It now shares the composer's + // single glass surface — the footer row carries no bg fill of its own. + const src = await read("components/prompt-input.tsx") + const footerIdx = src.indexOf("the footer shares the composer's ONE glass") + expect(footerIdx).toBeGreaterThan(-1) + const footerTag = src.slice(footerIdx, src.indexOf(">", footerIdx)) + expect(footerTag).not.toContain("bg-[var(--glass-dense-bg)]") + }) + + test("the dock band no longer tiles the Brain away behind the composer", async () => { + const src = await read("pages/session/composer/session-composer-region.tsx") + expect(src).not.toContain("bg-background-stronger") + }) +}) + +describe("glass float — the diff card floats on dense", () => { + test("session-turn-diffs-group carries dense glass", async () => { + const src = await read("pages/session/message-timeline.tsx") + const tag = openTag(src, 'data-component="session-turn-diffs-group"') + expect(tag).toContain('data-glass="dense"') + }) +}) + +describe("glass float — gaps stay live", () => { + test("the timeline row frame (session-turn) carries no glass", async () => { + const src = await read("pages/session/message-timeline.tsx") + const tag = openTag(src, 'data-component="session-turn"') + expect(tag).not.toContain("data-glass") + // and paints no background of its own + expect(tag).not.toMatch(/bg-/) + }) + + test("the centered column / message container wrappers carry no glass and no fill", async () => { + const src = await read("pages/session/message-timeline.tsx") + let from = 0 + let count = 0 + for (;;) { + const idx = src.indexOf('data-slot="session-turn-message-container"', from) + if (idx === -1) break + const tag = openTag(src, 'data-slot="session-turn-message-container"', from) + expect(tag).not.toContain("data-glass") + expect(tag).not.toMatch(/\bbg-\S/) + from = idx + 1 + count++ + } + expect(count).toBeGreaterThanOrEqual(5) + }) + + test("the session-turn frame rules paint no background (ui css)", async () => { + const css = await Bun.file( + join(APP_SRC, "../../ui/src/components/session-turn.css"), + ).text() + // extract ONLY the direct declarations of the [data-component="session-turn"] + // block head (before its first nested selector) — the row frame itself + const idx = css.indexOf('[data-component="session-turn"] {') + expect(idx).toBeGreaterThan(-1) + const head = css.slice(idx, css.indexOf("[data-slot", idx)) + expect(head).not.toContain("background") + }) +}) + +describe("glass sweep (#56) — the sticky session-title band rides the glass vars", () => { + test("its gradient + blur are the recipe's own terms, not chrome literals; band form keeps no hook", async () => { + const src = await read("pages/session/message-timeline.tsx") + const idx = src.indexOf("data-session-title") + expect(idx).toBeGreaterThan(-1) + const block = src.slice(idx, idx + 1200) // the band's classList follows the attribute + // converted (#56): derived tint fading to transparent over the shared + // blur+brightness — the opaque --background-stronger gradient and the + // hand-rolled 10px blur are gone + expect(block).toContain("var(--glass-standard-bg)") + expect(block).toContain("blur(var(--glass-blur,8px))") + expect(block).toContain("brightness(var(--glass-brightness,1))") + expect(block).not.toContain("var(--background-stronger)") + expect(block).not.toContain("backdrop-blur-[10px]") + // it stays a BAND (no border/radius/shadow), so no card hook on it + expect(block).not.toContain("data-glass") + }) + + test("timeline data-glass census: diff card (dense) + jump-to-bottom + error card (standard)", async () => { + const src = await read("pages/session/message-timeline.tsx") + const hits = [...src.matchAll(/data-glass="([\w-]+)"/g)].map((m) => m[1]) + expect(hits.sort()).toEqual(["dense", "standard", "standard"]) + }) + + test("comment-strip chips ride the dense-zone token, not an opaque layer", async () => { + const src = await read("pages/session/message-timeline.tsx") + expect(src).toContain("bg-[var(--glass-dense-bg)]") + expect(src).not.toContain("bg-background-stronger") + }) + + test("jump-to-bottom dropped its hand-rolled near-glass for the single recipe", async () => { + const src = await read("pages/session/message-timeline.tsx") + expect(src).not.toContain("backdrop-blur-[0.75px]") + expect(src).not.toContain("color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)") + }) + + test("rail, titlebar and panels carry no data-glass", async () => { + const files = [ + "pages/session/session-side-panel.tsx", + "pages/session/terminal-panel.tsx", + "pages/session/review-tab.tsx", + "components/titlebar.tsx", + ] + for (const rel of files) { + const file = Bun.file(join(APP_SRC, rel)) + if (!(await file.exists())) continue + expect(await file.text()).not.toContain("data-glass") + } + }) +}) + +describe("glass sweep (#56) — docks and composer chrome float on the recipe", () => { + test("todo / followup / revert trays carry standard glass", async () => { + for (const rel of [ + "pages/session/composer/session-todo-dock.tsx", + "pages/session/composer/session-followup-dock.tsx", + "pages/session/composer/session-revert-dock.tsx", + ]) { + expect(await read(rel)).toContain('data-glass="standard"') + } + }) + + test("the todo scroll-fade rides the glass tint, not the opaque page ground", async () => { + const src = await read("pages/session/composer/session-todo-dock.tsx") + expect(src).toContain("linear-gradient(to bottom, var(--glass-standard-bg), transparent)") + expect(src).not.toContain("linear-gradient(to bottom, var(--background-base)") + }) + + test("composer loading stub rides the dense hook, not a half-opaque one-off", async () => { + const src = await read("pages/session/composer/session-composer-region.tsx") + expect(src).not.toContain("bg-background-base/50") + }) + + test("autocomplete popover + project picker + model menu carry standard; highlights use the accent state fill", async () => { + const slash = await read("components/prompt-input/slash-popover.tsx") + expect(slash).toContain('data-glass="standard"') + expect(slash).toContain("bg-[var(--accent-fill-soft)]") + expect(slash).not.toContain("bg-surface-raised-stronger-non-alpha") + const model = await read("components/dialog-select-model.tsx") + expect(model).toContain('data-glass="standard"') + expect(model).not.toContain("bg-surface-raised-stronger-non-alpha") + const prompt = await read("components/prompt-input.tsx") + expect(prompt).not.toContain("bg-v2-background-bg-layer-01") + }) + + test("context chips + attachment tiles: dense-zone/token tints, no theme-blind literals", async () => { + const ctx = await read("components/prompt-input/context-items.tsx") + expect(ctx).toContain("bg-[var(--glass-dense-bg)]") + expect(ctx).not.toContain("bg-background-stronger") + const img = await read("components/prompt-input/image-attachments.tsx") + expect(img).toContain("var(--glass-dense-bg)") + // the filename bar's theme-blind bg-black/50 literal became a float-token mix + expect(img).toContain("bg-[color-mix(in_srgb,var(--surface-float-base)_60%,transparent)]") + expect(img).not.toContain('"bg-black/50') + expect(img).not.toContain("bg-surface-base ") + }) + + test("drag overlay: heavier by function (masking) but token-mix + shared blur, never a raw 90% token", async () => { + const src = await read("components/prompt-input/drag-overlay.tsx") + expect(src).toContain("color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)") + expect(src).toContain("blur(var(--glass-blur,8px))") + expect(src).not.toContain("bg-surface-raised-stronger-non-alpha/90") + }) + + test("the legacy composer path (flag off) floats on standard; its clip-mask stays heavier by function", async () => { + const src = await read("components/prompt-input.tsx") + // both DockShellForm branches carry the hook + const forms = [...src.matchAll(/ { + mock.module("@opencode-ai/ui/message-part", () => ({ + renderable: () => true, + groupParts: (refs: { messageID: string; messageIndex: number; part: Part }[]) => + refs.map((ref) => ({ key: ref.part.id, parts: [ref] })), + })) + const data = await import("./message-timeline.data") + Timeline = data.Timeline + TimelineRow = data.TimelineRow +}) + +const userMessage = () => + ({ + id: "u1", + sessionID: "s", + role: "user", + time: { created: 1 }, + }) as UserMessage + +const assistantMessage = (opts: { completed?: boolean; error?: AssistantMessage["error"] } = {}) => + ({ + id: "a1", + sessionID: "s", + role: "assistant", + parentID: "u1", + time: opts.completed ? { created: 2, completed: 3 } : { created: 2 }, + error: opts.error, + }) as unknown as AssistantMessage + +const noParts = (): Part[] => [] + +const construct = (opts: { + assistant?: AssistantMessage[] + status?: "idle" | "busy" | "retry" + isActive?: boolean +}) => + Timeline.constructMessageRows( + userMessage(), + noParts, + opts.assistant ?? [assistantMessage()], + 0, + false, + opts.status ?? "busy", + opts.isActive ?? true, + ) + +describe("constructMessageRows", () => { + test("a working turn emits the Thinking row — the shimmer survives the strip removal", () => { + const rows = construct({ status: "busy", isActive: true }) + expect(rows.some((row) => row._tag === "Thinking")).toBe(true) + }) + + test("an idle turn emits no Thinking row", () => { + const rows = construct({ status: "idle", isActive: true, assistant: [assistantMessage({ completed: true })] }) + expect(rows.some((row) => row._tag === "Thinking")).toBe(false) + }) + + test("an inactive turn emits no Thinking row even while the session is busy", () => { + const rows = construct({ status: "busy", isActive: false }) + expect(rows.some((row) => row._tag === "Thinking")).toBe(false) + }) + + test("the row model never produces a brain-keyed row", () => { + for (const rows of [ + construct({ status: "busy", isActive: true }), + construct({ status: "idle", isActive: true, assistant: [assistantMessage({ completed: true })] }), + ]) { + expect(rows.map(TimelineRow.key)).not.toContain("brain") + } + }) +}) 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..3c04a5b571 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) { @@ -203,6 +202,9 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
@@ -497,7 +499,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 +523,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) @@ -1198,9 +1196,10 @@ export function MessageTimeline(props: { data-message-id={input.row().userMessageID} data-timeline-row={input.row()._tag} classList={{ + // Kate 2026-07-26: no centered column — rows span the pane so the + // user's messages justify RIGHT and amico's justify LEFT (each card + // still capped at its own reading measure); the Brain owns the middle. "min-w-0 w-full max-w-full": true, - "md:max-w-200 2xl:max-w-[1000px]": props.centered, - "md:mx-auto": props.centered, "pt-6": previousUserMessage(), "pt-3": previousAssistantPart(), }} @@ -1224,9 +1223,11 @@ export function MessageTimeline(props: {
+ {/* glass sweep (#56): comment chips ride the dense-zone token over the Brain */} {(comment) => ( -
+ // Kate 2026-07-25: no border on glass chips — the dense tint alone +
{getFilename(comment().path)} @@ -1345,29 +1346,15 @@ export function MessageTimeline(props: { return (
- + {/* glass sweep (#56): the error card floats on glass with a danger + tint (session-turn.css), never bare over the Brain */} + {errorRow().text}
) } - 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