From 76f7eb4e244f738af8e5107e77e873e9d511571d Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:25:37 -0400 Subject: [PATCH 01/27] docs: add jcode design contract --- DESIGN.md | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..83234923 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,150 @@ +# JCode Design System + +This document codifies JCode's current cockpit visual system as extracted from the existing web UI. It is descriptive, not a redesign brief. Future visible UI work must preserve this system unless `DESIGN.md` is updated first. + +## 1. Product And Domain + +JCode is a local-first coding-agent cockpit for maintainers who need to steer agent work, inspect diffs, watch terminals, manage providers, and recover context quickly. The UI should feel like a dense but calm command center: muted surfaces, compact controls, precise metadata, and semantic status cues that make active work scannable without turning the cockpit into a colorful dashboard. The signature is tokenized depth: transcript, diff, terminal, settings, and right panel/browser surfaces are separated by app-owned `--app-*` surface tokens rather than one-off component styling. + +## 2. Principles + +1. Local work first: surfaces should privilege the active thread, workspace, provider, terminal, and diff context over decorative chrome. +2. Scannability over palette display: color has a role, especially in chat output, status, diffs, and work rows. Unknown body text stays calm. +3. Provider boundaries stay hidden: UI names canonical cockpit concepts rather than raw provider protocol payloads. +4. Dense controls, readable content: chrome can be compact, but transcript, code, table, diff, terminal, and settings content must remain legible at the configured font size. +5. Tokenized theme ownership: theme math belongs in `apps/web/src/theme/theme.logic.ts`; shared CSS helpers live in `apps/web/src/index.css`; components consume semantic tokens. + +## 3. Typography + +| Role | Token or Pattern | Current Source | Usage | +| --- | --- | --- | --- | +| UI sans | `--font-ui-family`, `--theme-font-ui-family`, `.font-system-ui` | `apps/web/src/index.css` | App chrome, settings rows, metadata, transcript body when rendered as UI text. | +| Code mono | `--font-mono-family`, `--theme-font-code-family` | `apps/web/src/index.css` | Inputs, textareas, generic code, diff fallback chrome. | +| Chat code mono | `--font-chat-code-family`, `.font-chat-code` | `apps/web/src/index.css` | Transcript code blocks, inline code chips, diff stats inside chat. | +| Terminal mono | `--terminal-font-family` | `apps/web/src/index.css`, `terminalRuntimeAppearance.ts` | Xterm surfaces and terminal system messages. | +| Body tracking | `body { letter-spacing: -0.015em; }` | `apps/web/src/index.css` | Native, utilitarian cockpit text rhythm. | +| Chat scale | `DEFAULT_CHAT_FONT_SIZE_PX`, `getAppTypographyScale`, `getChatTranscriptTextStyle` | `MessagesTimeline.tsx` | User-configurable transcript size, timestamps, and metadata. | +| Settings labels | `text-[11px] uppercase tracking-[0.14em]` | `_chat.settings.tsx` | Section labels and compact settings hierarchy. | + +Rules: + +- Use at most the UI sans and mono families for normal product UI. Do not introduce a third family without updating this document and the theme settings model. +- Use `font-system-ui` for cockpit labels and metadata that should follow the UI font. +- Use `font-chat-code` or `--font-chat-code-family` for transcript code, diff-like inline stats, and generated assistant code surfaces. +- Use the configured chat typography helpers for transcript UI instead of hardcoding one-off font sizes. + +## 4. Color And Tokens + +JCode has two token layers: generic shadcn/Tailwind-compatible tokens and cockpit-specific semantic `--app-*` tokens. Components must consume semantic roles, not raw palette values. + +| Family | Tokens | Use | +| --- | --- | --- | +| Base surfaces | `--background`, `--foreground`, `--card`, `--popover`, `--border`, `--input`, `--ring` | App shell, card/panel foundations, form controls, focus rings. | +| App depth | `--app-surface-canvas`, `--app-surface-sidebar`, `--app-surface-topbar`, `--app-surface-panel`, `--app-surface-card`, `--app-surface-card-header`, `--app-surface-composer`, `--app-surface-toolbar`, `--app-surface-toolbar-hover`, `--app-surface-toolbar-active`, `--app-surface-toolbar-border` | Cockpit shell depth, sidebar/topbar rhythm, panel/card separation, composer surface, toolbar states. | +| Transcript | `--app-transcript-stage-bg`, `--app-transcript-stage-border`, `--app-transcript-edge-fade`, `--app-assistant-message-bg`, `--app-assistant-message-border`, `--app-assistant-message-accent`, `--app-user-message-bg`, `--app-user-message-border`, `--app-user-message-accent` | Chat transcript stage, assistant message rail, user message bubble, live-edge fade. | +| Chat semantics | `--app-chat-heading`, `--app-chat-link`, `--app-chat-file`, `--app-chat-token`, `--app-chat-command`, `--app-chat-success`, `--app-chat-warning`, `--app-chat-error`, `--app-chat-chip-bg`, `--app-chat-chip-border`, `--app-chat-code-bg`, `--app-chat-code-border`, `--app-chat-code-copy-bg`, `--app-chat-code-copy-fg` | Role-based markdown scannability for headings, file paths, commands, tokens, statuses, code blocks, and copy controls. | +| Work rows | `--app-work-row-bg`, `--app-work-row-hover-bg`, `--app-work-row-border`, `--app-work-row-icon` | Tool calls, file-change rows, branch/worktree rows, subagent cards, and expandable activity. | +| Diff | `--app-diff-title`, `--app-diff-card-bg`, `--app-diff-card-header-bg`, `--diffs-font-family`, `--diffs-header-font-family` | Diff panel title, file cards, file-change summaries, diff renderer font bridge. | +| Terminal | `--terminal-font-family`, `--app-terminal-search-match-*`, `--app-terminal-search-active-match-*`, `--app-scrollbar-thumb`, `--app-scrollbar-thumb-hover` | Terminal xterm font, search highlights, and scrollbar parity. | +| Chrome controls | `--app-chrome-control-bg`, `--app-chrome-control-border`, `--app-chrome-control-fg`, `--app-chrome-control-hover-bg`, `--app-chrome-control-hover-fg`, `--app-chrome-control-active-bg`, `--app-control-icon-*`, `.sidebar-icon-button` | Compact icon buttons, message actions, terminal/browser/diff toolbar actions. | +| Metadata | `--app-metadata-fg`, `--app-metadata-muted-fg`, `--app-text-metadata`, `--app-text-metadata-strong` | Secondary labels, timestamps, environment labels, settings status text. | +| Status | `--app-status-{working,success,warning,input,plan,error,muted}-{fg,dot,bg,border}` | Thread, terminal, PR, plan, input, warning, success, working, error, and muted markers. | +| Agent accents | `--app-agent-chip-*`, `--app-subagent-accent-*` | Provider/agent chips and subagent identity accents. | + +Source rules: + +- Add or change theme derivation in `apps/web/src/theme/theme.logic.ts`; keep app-depth and status token expectations aligned with `apps/web/src/theme/theme.logic.test.ts`. +- Shared helper classes belong in `apps/web/src/index.css` when a pattern is reused across surfaces. +- Do not introduce raw hex, RGB, `color-mix`, or status colors inside product components. If a new visual role is needed, add or derive a token here and in the theme layer first. +- Existing raw values in low-level integrations are exceptions, not precedent: terminal ANSI colors in `terminalRuntimeAppearance.ts`, Electron `webview` white background, and legacy ultrathink spectrum values should not be copied into new UI without a token update. + +## 5. Spacing, Density, And Layout + +JCode uses Tailwind v4 utilities over a 4px/rem scale and compact cockpit-specific measurements. The common rhythm is dense: `gap-1`/`gap-1.5` for icon-label clusters, `gap-2` for toolbar groups, `px-2` to `px-4` for rows/cards, and `rounded-md` to `rounded-xl` for panel and row corners. + +| Pattern | Current Usage | Rule | +| --- | --- | --- | +| Full-height cockpit | `h-full`, `min-h-0`, `min-w-0`, `overflow-hidden`, `h-dvh` for browser sidebars | Preserve containment; prevent transcript, diff, terminal, and browser panes from leaking scroll. | +| Transcript width | `mx-auto w-full min-w-0 max-w-3xl` | Keep chat content readable while the cockpit shell can be wide. | +| Right panel widths | Diff inline default `42vw`, `min-w-[360px]`, max `560px`; browser/right panel widths are persisted and bounded | Do not hardcode new panel widths without using the existing storage and composer-fit guards. | +| Work row density | Compact rows use `py-0.5`, `gap-1.5`; default rows use rounded row cards and `px-2 py-1` | Dense activity rows are allowed, but they must remain clickable and readable. | +| Settings density | `SettingsRow` uses `rounded-xl`, `px-4 py-3.5`, `gap-3` | Settings are card-like rows, not bare forms. | +| Terminal density | 32px tab bars, 6px scrollbars, split handles, compact icon buttons | Terminal chrome should stay tight and prioritize viewport space. | + +Rules: + +- Use Tailwind spacing utilities that map to the 4px/rem scale. Avoid arbitrary `px`/`rem` values unless matching an existing measured integration constraint. +- If an arbitrary visual value is reused or becomes part of a surface contract, document it here and move it behind a token or helper. +- Preserve `min-h-0`/`min-w-0` and explicit overflow ownership in pane layouts. + +## 6. Component Patterns + +### Transcript And Chat + +- `ChatTranscriptPane` owns the transcript stage through `.app-transcript-stage` and renders `MessagesTimeline` with scroll-to-bottom chrome using `--app-scroll-button-*`. +- `MessagesTimeline` renders assistant messages in `.app-assistant-message`, user messages in `.app-user-message`, and file-change/activity summaries through `--app-work-row-*`, `--app-diff-card-*`, and metadata tokens. +- `ChatMarkdown` owns markdown scannability: headings, links, inline code role classes, code blocks, copy buttons, tables, local generated images, and lazy image rendering. +- Chat-output semantic roles must stay conservative: file paths, commands, theme tokens, success/warning/error states can be colored; ordinary text stays neutral. + +### Diff + +- `DiffPanelShell` is the shared shell for diff and browser panels. It supplies the border/header/body structure and Electron drag-region behavior. +- `DiffPanel` uses compact turn chips, summary/review/source tabs, copy controls, virtualized file diffs, and `diff-panel-viewport`/`diff-render-file` CSS helpers. +- Diff copy-path affordances must remain keyboard-accessible, small, and anchored in file headers without redesigning the whole diff surface. +- Diff UI uses `--app-diff-*`, `--app-work-row-*`, `--color-*`, and the configured code font bridge, not raw palette values. + +### Terminal + +- `ThreadTerminalDrawer` owns drawer/workspace terminal presentation, resize handles, terminal groups, sidebar tabs, and split controls. +- `TerminalViewportPane` owns tab bars, split pane handles, active-pane emphasis, terminal action buttons, and xterm viewport containment. +- `terminalRuntimeAppearance.ts` resolves the xterm theme and terminal font from root app tokens; terminal search highlights use `--app-terminal-search-*` tokens. +- Terminal action chrome should remain compact and accessible with labels, disabled states, and visible focus/hover states. + +### Settings + +- `SettingsSection` uses uppercase 11px labels with wide tracking for scan groups. +- `SettingsRow` is the settings primitive: rounded bordered panel, `bg-(--color-background-panel)`, compact description/status text, optional reset action, and right-aligned controls on wider screens. +- Settings should explain state and recovery clearly; avoid stuffing provider/runtime protocol details into labels. + +### Right Panel And Browser Surfaces + +- Right panels are cockpit sidecars, not standalone pages. They must preserve chat composer width guards and pane-owned resizing. +- `_chat.$threadId.tsx` routes `diff` and `browser` into the right panel/sidebar/split-pane system with persisted width keys, min widths, borders, and `bg-card text-foreground`. +- `BrowserPanel` reuses `DiffPanelShell`, compact toolbar buttons, mono address input, tab strip, popover suggestions, and menu actions over `--composer-surface`, `--border`, `--popover`, and `--sidebar-accent`. +- Native browser/webview bounds and overlay synchronization are behavioral constraints; do not replace browser chrome with generic iframe styling. + +## 7. Interaction And Accessibility + +- Every icon-only action needs a label through `aria-label`, `title`, screen-reader text, or the shared button component's accessible label pattern. +- Focus must use `--app-state-focus`, `--ring`, or existing component focus-visible styles. Do not suppress focus rings for aesthetics. +- Hover/active states should use paired semantic tokens: toolbar hover/active, work-row hover, sidebar accent, status bg/border, or chrome-control hover. +- Disabled states should reduce opacity and preserve layout. Avoid hiding disabled controls when the absence would make cockpit state ambiguous. +- Scroll ownership is explicit: transcript uses `LegendList`, diff uses `Virtualizer`, browser/terminal have pane-owned scroll and bounds logic, settings route owns its page scroll. +- Remote or provider-specific state should be translated into canonical cockpit labels before display. + +## 8. Motion + +| Motion | Token/Pattern | Rule | +| --- | --- | --- | +| Chat pane entry | `.chat-pane-enter`, `220ms cubic-bezier(0.22, 1, 0.36, 1)` | Use for empty/transcript pane swaps; respect reduced motion. | +| Terminal running dot | `.terminal-running-indicator__dot`, `640ms ease-in-out`, opacity/scale only | Keep as CSS animation to avoid JS timers across many terminals. | +| Generated image shimmer | `chat-generated-image-shimmer`, `1.6s ease-in-out` | Loading feedback for generated images only. | +| Micro-interactions | Tailwind `transition-colors`, `transition-opacity`, `duration-120/140/150/200` patterns | Prefer color/opacity/transform transitions. | +| Ultrathink | `ultrathink-*` spectrum animations, 10s linear | Existing special mode only; do not use as general decoration. | + +Rules: + +- Animate `opacity`, `transform`, and color/filter changes only. Do not animate layout properties for ordinary UI. +- Respect `prefers-reduced-motion` for non-essential animation. +- Do not add decorative motion to transcript, diff, terminal, settings, or browser surfaces unless it improves state comprehension. + +## 9. Implementation Rules + +- Read this file before any UI component or style change. +- Use `docs/architecture/theme-surface-tokens.md` as the architecture companion for token ownership and verification. +- Colors must reference `--app-*`, `--color-*`, or documented component tokens. No new raw hex/RGB values in product components. +- Spacing must use the existing Tailwind/rem scale or a documented component measurement. No ad-hoc `margin: 13px`, arbitrary padding, or one-off border radii without updating this file first. +- New reusable components or repeated surface patterns must be documented in Section 6 before or with implementation. +- Extend theme logic and tests before adding new semantic token roles. +- Preserve existing visual identity: muted cockpit depth, compact controls, semantic chat scannability, tokenized work rows, and local-first utility. +- T3Code and other upstream references are implementation references only. Adapt feature slices into JCode-native tokens and components; do not import a new brand/style wholesale. From 0c720b1b72d5f386cb617d1dcc44a792d5cbabf6 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:25:37 -0400 Subject: [PATCH 02/27] fix(server): harden opencode resume handling --- .../provider/Layers/OpenCodeAdapter.test.ts | 321 +++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 166 ++++++--- 2 files changed, 443 insertions(+), 44 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index be689fa6..50e91890 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1,7 +1,7 @@ import { ThreadId } from "@jcode/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { Model, OpencodeClient, Part, Provider } from "@opencode-ai/sdk/v2"; -import { Effect, Fiber, Layer, Stream } from "effect"; +import { Effect, Exit, Fiber, Layer, Stream } from "effect"; import { describe, it, expect } from "vitest"; import { ServerConfig } from "../../config.ts"; @@ -114,10 +114,19 @@ function createMockOpenCodeRuntime(options?: { data: Array<{ info: Record; parts: Part[] }>; }>; readonly session?: Record; + readonly sessionGet?: (input: { readonly sessionID: string }) => Promise<{ + readonly data?: Record | null; + }>; + readonly sessionUpdate?: (input: { + readonly sessionID: string; + readonly permission: unknown; + }) => Promise; }) { const abortCalls: Array<{ sessionID: string }> = []; const createCalls: Array> = []; const promptCalls: Array> = []; + const sessionGetCalls: Array<{ sessionID: string }> = []; + const sessionUpdateCalls: Array<{ sessionID: string; permission: unknown }> = []; const emptySubscription = { async *[Symbol.asyncIterator]() { // No provider-side events needed for these adapter lifecycle tests. @@ -151,7 +160,22 @@ function createMockOpenCodeRuntime(options?: { return { data: null }; }, messages: options?.messages ?? (async () => ({ data: [] })), - get: async () => ({ data: { directory: process.cwd(), ...(options?.session ?? {}) } }), + get: async (input: { sessionID: string }) => { + sessionGetCalls.push(input); + if (options?.sessionGet) { + return options.sessionGet(input); + } + return { + data: { id: input.sessionID, directory: process.cwd(), ...options?.session }, + }; + }, + update: async (input: { sessionID: string; permission: unknown }) => { + sessionUpdateCalls.push(input); + if (options?.sessionUpdate) { + return options.sessionUpdate(input); + } + return { data: null }; + }, revert: async () => ({ data: null }), summarize: async () => ({ data: null }), fork: async () => ({ data: { id: "forked-session-1" } }), @@ -197,7 +221,7 @@ function createMockOpenCodeRuntime(options?: { loadOpenCodeCredentialProviderIDs: () => Effect.succeed([]), }; - return { abortCalls, createCalls, promptCalls, runtime }; + return { abortCalls, createCalls, promptCalls, runtime, sessionGetCalls, sessionUpdateCalls }; } function createSubscribedEventQueue() { @@ -1409,6 +1433,297 @@ describe("OpenCodeAdapter runtime lifecycle", () => { }); }); + it("falls back to a fresh OpenCode session when the persisted resume cursor is stale", async () => { + const runtime = createMockOpenCodeRuntime({ + sessionGet: async ({ sessionID }) => { + throw new Error(`Session not found: ${sessionID}`, { + cause: { status: 404, body: { name: "NotFoundError" } }, + }); + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + + // Given: ProviderService restored JCode's persisted OpenCode cursor after idle reaping. + // When: the adapter starts a follow-up session and OpenCode says that session is gone. + // Then: the adapter creates a fresh session and returns its new cursor. + const session = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-stale-resume-cursor"), + runtimeMode: "full-access", + resumeCursor: { openCodeSessionId: "ses_stale" }, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + return { events, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.sessionGetCalls).toEqual([{ sessionID: "ses_stale" }]); + expect(runtime.createCalls).toHaveLength(1); + expect(result.session.resumeCursor).toEqual({ openCodeSessionId: "opencode-session-1" }); + expect(result.events[0]).toMatchObject({ + type: "session.started", + payload: { message: "OpenCode session started" }, + }); + }); + + it("reuses a valid persisted OpenCode cursor for follow-up turns", async () => { + const runtime = createMockOpenCodeRuntime(); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + + // Given: a persisted JCode resume cursor names a live OpenCode session. + const session = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-valid-resume-cursor"), + runtimeMode: "full-access", + resumeCursor: { openCodeSessionId: "ses_valid" }, + }); + + // When: the user sends a follow-up turn in the same JCode thread. + const turn = yield* adapter.sendTurn({ + threadId: asThreadId("thread-valid-resume-cursor"), + input: "continue", + attachments: [], + modelSelection: { + provider: "opencode", + model: "openai/gpt-5.4", + }, + }); + + // Then: both the session and turn keep the same upstream session cursor. + return { session, turn }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.sessionGetCalls).toEqual([{ sessionID: "ses_valid" }]); + expect(runtime.sessionUpdateCalls).toHaveLength(1); + expect(runtime.sessionUpdateCalls[0]?.sessionID).toBe("ses_valid"); + expect(runtime.createCalls).toEqual([]); + expect(runtime.promptCalls[0]).toMatchObject({ sessionID: "ses_valid" }); + expect(result.session.resumeCursor).toEqual({ openCodeSessionId: "ses_valid" }); + expect(result.turn.resumeCursor).toEqual({ openCodeSessionId: "ses_valid" }); + }); + + it("surfaces transient resume probe failures instead of hiding context loss", async () => { + const runtime = createMockOpenCodeRuntime({ + sessionGet: async ({ sessionID }) => { + throw new Error(`OpenCode server failed for ${sessionID}`, { cause: { status: 500 } }); + }, + }); + + const exit = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + + // Given: a persisted cursor exists but OpenCode cannot currently verify it. + // When: the adapter probes the cursor and receives a transient server failure. + // Then: the start fails instead of replacing the live thread with an empty one. + return yield* Effect.exit( + adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-transient-resume-cursor"), + runtimeMode: "full-access", + resumeCursor: { openCodeSessionId: "ses_transient" }, + }), + ); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(runtime.sessionGetCalls).toEqual([{ sessionID: "ses_transient" }]); + expect(runtime.createCalls).toEqual([]); + }); + + it("starts fresh when a valid cursor belongs to a different working directory", async () => { + const runtime = createMockOpenCodeRuntime({ + sessionGet: async ({ sessionID }) => ({ + data: { id: sessionID, directory: "/tmp/some-other-worktree" }, + }), + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + + // Given: the upstream session exists but is bound to a different cwd. + // When: JCode starts a follow-up for the current thread cwd. + // Then: the adapter creates a new OpenCode session in the requested cwd. + const session = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-cwd-mismatch-resume-cursor"), + runtimeMode: "full-access", + resumeCursor: { openCodeSessionId: "ses_other_cwd" }, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + return { events, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.sessionGetCalls).toEqual([{ sessionID: "ses_other_cwd" }]); + expect(runtime.sessionUpdateCalls).toEqual([]); + expect(runtime.createCalls).toHaveLength(1); + expect(result.session.resumeCursor).toEqual({ openCodeSessionId: "opencode-session-1" }); + expect(result.events[0]).toMatchObject({ + type: "session.started", + payload: { message: "OpenCode session started" }, + }); + }); + + it("ignores malformed OpenCode resume cursors without probing arbitrary state", async () => { + const runtime = createMockOpenCodeRuntime(); + + const session = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + + // Given: persisted runtime state has the right object key but no usable id. + // When: the adapter starts the session. + // Then: it treats the cursor as absent and creates a clean OpenCode session. + return yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-invalid-resume-cursor"), + runtimeMode: "full-access", + resumeCursor: { openCodeSessionId: " " }, + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.sessionGetCalls).toEqual([]); + expect(runtime.createCalls).toHaveLength(1); + expect(session.resumeCursor).toEqual({ openCodeSessionId: "opencode-session-1" }); + }); + + it("does not abort a resumed OpenCode session when a concurrent start loses the race", async () => { + let resolveFirstGetStarted: (() => void) | undefined; + let resolveSecondGetStarted: (() => void) | undefined; + let releaseFirstGet: (() => void) | undefined; + const firstGetStarted = new Promise((resolve) => { + resolveFirstGetStarted = resolve; + }); + const secondGetStarted = new Promise((resolve) => { + resolveSecondGetStarted = resolve; + }); + const firstGetReleased = new Promise((resolve) => { + releaseFirstGet = resolve; + }); + let getCount = 0; + const runtime = createMockOpenCodeRuntime({ + sessionGet: async ({ sessionID }) => { + getCount += 1; + if (getCount === 1) { + resolveFirstGetStarted?.(); + await secondGetStarted; + await firstGetReleased; + } else { + resolveSecondGetStarted?.(); + } + return { data: { id: sessionID, directory: process.cwd() } }; + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const firstStart = yield* adapter + .startSession({ + provider: "opencode", + threadId: asThreadId("thread-racing-resume-cursor"), + runtimeMode: "full-access", + resumeCursor: { openCodeSessionId: "ses_shared" }, + }) + .pipe(Effect.forkChild); + yield* Effect.promise(() => firstGetStarted); + + // Given: two starts race to adopt the same persisted OpenCode session id. + const secondSession = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-racing-resume-cursor"), + runtimeMode: "full-access", + resumeCursor: { openCodeSessionId: "ses_shared" }, + }); + releaseFirstGet?.(); + // When: the slower start loses after it already resumed, not created, the upstream session. + const firstSession = yield* Fiber.join(firstStart); + return { firstSession, secondSession }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + // Then: only layer cleanup aborts the surviving session; race-loser cleanup does not abort it. + expect(runtime.sessionGetCalls).toEqual([ + { sessionID: "ses_shared" }, + { sessionID: "ses_shared" }, + ]); + expect(runtime.createCalls).toEqual([]); + expect(runtime.abortCalls).toEqual([{ sessionID: "ses_shared" }]); + expect(result.secondSession.resumeCursor).toEqual({ openCodeSessionId: "ses_shared" }); + expect(result.firstSession.resumeCursor).toEqual({ openCodeSessionId: "ses_shared" }); + }); + it("clears adapter session state when interrupting an active OpenCode turn", async () => { const runtime = createMockOpenCodeRuntime(); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index a61480f0..adf8abb5 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -809,6 +809,54 @@ function extractResumeSessionId(resumeCursor: unknown): string | undefined { return undefined; } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hasOpenCodeNotFoundName(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return typeof value.name === "string" && value.name.toLowerCase().includes("notfound"); +} + +function isOpenCodeSessionNotFound(cause: unknown): boolean { + const queue: unknown[] = [cause]; + const seen = new Set(); + const nestedKeys = ["cause", "body", "error", "data", "response"] as const; + + for (let index = 0; index < queue.length && index < 32; index += 1) { + const current = queue[index]; + if (!isRecord(current) || seen.has(current)) { + continue; + } + seen.add(current); + + if (current.status === 404 || current.statusCode === 404) { + return true; + } + if (hasOpenCodeNotFoundName(current)) { + return true; + } + + const response = current.response; + if (isRecord(response) && response.status === 404) { + return true; + } + for (const key of nestedKeys) { + const nested = current[key]; + if (isRecord(nested)) { + if (hasOpenCodeNotFoundName(nested)) { + return true; + } + queue.push(nested); + } + } + } + + return false; +} + type OpenCodeModelInventory = { readonly providerList: { readonly connected: ReadonlyArray; @@ -3761,40 +3809,74 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { cliSpec: adapterConfig.cliSpec, ...(server.external && serverPassword ? { serverPassword } : {}), }); - const openCodeSessionId = - resumedSessionId ?? - (yield* runOpenCodeSdk("session.create", () => { - const sessionCreateInput = { - ...(initialParsedModel - ? { - model: { - providerID: initialParsedModel.providerID, - id: initialParsedModel.modelID, - ...(initialVariant ? { variant: initialVariant } : {}), - }, - } - : {}), - ...(initialAgent ? { agent: initialAgent } : {}), + const resumedSession = resumedSessionId + ? yield* runOpenCodeSdk("session.get", () => + client.session.get({ sessionID: resumedSessionId }), + ).pipe( + Effect.catchIf( + (cause) => isOpenCodeSessionNotFound(cause), + () => Effect.succeed({ data: undefined }), + ), + ) + : undefined; + const resumedData = resumedSession?.data; + const resumedDirectory = isRecord(resumedData) ? resumedData.directory : undefined; + const reusableSessionId = + isRecord(resumedData) && + (typeof resumedDirectory !== "string" || resumedDirectory === directory) + ? typeof resumedData.id === "string" && resumedData.id.trim().length > 0 + ? resumedData.id.trim() + : resumedSessionId + : undefined; + + if (reusableSessionId) { + yield* runOpenCodeSdk("session.update", () => + client.session.update({ + sessionID: reusableSessionId, permission: buildOpenCodePermissionRules(input.runtimeMode), - title: `JCode ${input.threadId}`, - }; - return client.session.create( - sessionCreateInput as unknown as Parameters[0], - ); - }).pipe( - Effect.flatMap((sessionResult) => - sessionResult.data?.id - ? Effect.succeed(sessionResult.data.id) - : Effect.fail( - new OpenCodeRuntimeError({ - operation: "session.create", - detail: `${adapterConfig.displayName} session.create returned no session payload.`, - }), - ), - ), - )); - - return { sessionScope, server, client, openCodeSessionId }; + }), + ); + return { + sessionScope, + server, + client, + openCodeSessionId: reusableSessionId, + created: false, + }; + } + + const openCodeSessionId = yield* runOpenCodeSdk("session.create", () => { + const sessionCreateInput = { + ...(initialParsedModel + ? { + model: { + providerID: initialParsedModel.providerID, + id: initialParsedModel.modelID, + ...(initialVariant ? { variant: initialVariant } : {}), + }, + } + : {}), + ...(initialAgent ? { agent: initialAgent } : {}), + permission: buildOpenCodePermissionRules(input.runtimeMode), + title: `JCode ${input.threadId}`, + }; + return client.session.create( + sessionCreateInput as unknown as Parameters[0], + ); + }).pipe( + Effect.flatMap((sessionResult) => + sessionResult.data?.id + ? Effect.succeed(sessionResult.data.id) + : Effect.fail( + new OpenCodeRuntimeError({ + operation: "session.create", + detail: `${adapterConfig.displayName} session.create returned no session payload.`, + }), + ), + ), + ); + + return { sessionScope, server, client, openCodeSessionId, created: true }; }).pipe(Effect.provideService(Scope.Scope, sessionScope)), ); if (Exit.isFailure(startedExit)) { @@ -3806,11 +3888,13 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const raceWinner = sessions.get(input.threadId); if (raceWinner) { - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ - sessionID: started.openCodeSessionId, - }), - ).pipe(Effect.ignore); + if (started.created) { + yield* runOpenCodeSdk("session.abort", () => + started.client.session.abort({ + sessionID: started.openCodeSessionId, + }), + ).pipe(Effect.ignore); + } yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); return raceWinner.session; } @@ -3876,9 +3960,9 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { ...buildEventBase({ threadId: input.threadId }), type: "session.started", payload: { - message: resumedSessionId - ? `${adapterConfig.displayName} session resumed` - : `${adapterConfig.displayName} session started`, + message: started.created + ? `${adapterConfig.displayName} session started` + : `${adapterConfig.displayName} session resumed`, resume: { openCodeSessionId: started.openCodeSessionId }, }, }); From b67482ec153c106480d19822e1bec1a530d74272 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:25:37 -0400 Subject: [PATCH 03/27] feat(contracts): add chat markdown wrap setting --- packages/contracts/src/settings.test.ts | 32 ++++++++++++++++++++++++- packages/contracts/src/settings.ts | 4 ++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e1e51db2..8839eaf7 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,11 +1,41 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; -import { ServerSettings, ServerSettingsPatch } from "./settings"; +import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings"; const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); +describe("ServerSettings chat word wrap", () => { + it("defaults chat markdown word wrap on when the setting is missing", () => { + expect(decodeServerSettings({}).chatMarkdownWordWrap).toBe(true); + expect(DEFAULT_SERVER_SETTINGS.chatMarkdownWordWrap).toBe(true); + }); + + it("accepts chat markdown word wrap patches independently from diff word wrap", () => { + const parsed = decodeServerSettingsPatch({ + chatMarkdownWordWrap: false, + diffWordWrap: true, + }); + + expect(parsed.chatMarkdownWordWrap).toBe(false); + expect(parsed.diffWordWrap).toBe(true); + }); +}); + +describe("ServerSettings provider update checks", () => { + it("defaults provider update checks on when the setting is missing", () => { + expect(decodeServerSettings({}).enableProviderUpdateChecks).toBe(true); + expect(DEFAULT_SERVER_SETTINGS.enableProviderUpdateChecks).toBe(true); + }); + + it("accepts provider update check patches", () => { + expect(decodeServerSettingsPatch({ enableProviderUpdateChecks: false })).toEqual({ + enableProviderUpdateChecks: false, + }); + }); +}); + describe("ServerSettings OpenClaw provider settings", () => { it("decodes OpenClaw non-secret settings with redacted secret metadata", () => { const parsed = decodeServerSettings({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index d9335095..3d31fee6 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -97,6 +97,8 @@ const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + chatMarkdownWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), defaultThreadEnvMode: ThreadEnvironmentMode.pipe(Schema.withDecodingDefault(() => "local")), addProjectBaseDirectory: StringSetting.pipe(Schema.withDecodingDefault(() => "")), textGenerationModelSelection: ModelSelection.pipe( @@ -173,6 +175,8 @@ const ProviderSettingsBasePatch = { export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), + chatMarkdownWordWrap: Schema.optionalKey(Schema.Boolean), + enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvironmentMode), addProjectBaseDirectory: Schema.optionalKey(StringSetting), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), From 50042ac84b5be9a75956ffa9c43f2de35c8aa1ba Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:25:37 -0400 Subject: [PATCH 04/27] feat(web): add chat markdown wrap controls --- apps/web/src/appSettings.test.ts | 8 + apps/web/src/appSettings.ts | 12 ++ apps/web/src/components/ChatMarkdown.test.tsx | 54 +++++++ apps/web/src/components/ChatMarkdown.tsx | 84 +++++++--- apps/web/src/routes/_chat.settings.tsx | 144 +++++++++++++++--- 5 files changed, 260 insertions(+), 42 deletions(-) diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index f01f01c6..bc6d96ca 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -476,6 +476,7 @@ describe("server-backed app settings", () => { addProjectBaseDirectory: "/home/jay/code", defaultThreadEnvMode: "local", enableAssistantStreaming: false, + chatMarkdownWordWrap: false, chatFontSizePx: 16, chatCodeFontFamily: "JetBrains Mono", uiFontFamily: "Inter", @@ -488,6 +489,7 @@ describe("server-backed app settings", () => { confirmThreadArchive: false, confirmTerminalTabClose: false, diffWordWrap: true, + enableProviderUpdateChecks: false, enableTaskCompletionToasts: false, enableSystemTaskCompletionNotifications: false, defaultProvider: "claudeAgent", @@ -525,6 +527,7 @@ describe("server-backed app settings", () => { ), ).toMatchObject({ chatFontSizePx: 16, + chatMarkdownWordWrap: false, chatCodeFontFamily: "JetBrains Mono", uiFontFamily: "Inter", enableNativeFontSmoothing: true, @@ -536,6 +539,7 @@ describe("server-backed app settings", () => { confirmThreadArchive: false, confirmTerminalTabClose: false, diffWordWrap: true, + enableProviderUpdateChecks: false, enableTaskCompletionToasts: false, enableSystemTaskCompletionNotifications: false, defaultProvider: "claudeAgent", @@ -590,6 +594,7 @@ describe("server-backed app settings", () => { expect( appSettingsPatchToServerSettingsPatch({ chatFontSizePx: 15, + chatMarkdownWordWrap: false, chatCodeFontFamily: "Fira Code", uiFontFamily: "IBM Plex Sans", enableNativeFontSmoothing: false, @@ -601,12 +606,14 @@ describe("server-backed app settings", () => { confirmThreadArchive: true, confirmTerminalTabClose: false, diffWordWrap: true, + enableProviderUpdateChecks: false, enableTaskCompletionToasts: true, enableSystemTaskCompletionNotifications: true, defaultProvider: "gemini", }), ).toEqual({ chatFontSizePx: 15, + chatMarkdownWordWrap: false, chatCodeFontFamily: "Fira Code", uiFontFamily: "IBM Plex Sans", enableNativeFontSmoothing: false, @@ -618,6 +625,7 @@ describe("server-backed app settings", () => { confirmThreadArchive: true, confirmTerminalTabClose: false, diffWordWrap: true, + enableProviderUpdateChecks: false, enableTaskCompletionToasts: true, enableSystemTaskCompletionNotifications: true, defaultProvider: "gemini", diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 4ec816f4..e1470cd0 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -135,8 +135,10 @@ export const AppSettingsSchema = Schema.Struct({ confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), confirmThreadArchive: Schema.Boolean.pipe(withDefaults(() => false)), confirmTerminalTabClose: Schema.Boolean.pipe(withDefaults(() => true)), + chatMarkdownWordWrap: Schema.Boolean.pipe(withDefaults(() => true)), diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), + enableProviderUpdateChecks: Schema.Boolean.pipe(withDefaults(() => true)), enableNativeFontSmoothing: Schema.Boolean.pipe(withDefaults(getDefaultNativeFontSmoothing)), enableTaskCompletionToasts: Schema.Boolean.pipe(withDefaults(() => true)), enableSystemTaskCompletionNotifications: Schema.Boolean.pipe(withDefaults(() => true)), @@ -370,7 +372,9 @@ export function serverSettingsToAppSettings(settings: ServerSettings): Partial ({ + chatMarkdownWordWrap: true, +})); + vi.mock("@pierre/diffs", () => ({ getSharedHighlighter: () => Promise.resolve({ @@ -15,6 +19,14 @@ vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }), })); +vi.mock("../appSettings", () => ({ + useAppSettings: () => ({ + settings: { + chatMarkdownWordWrap: mockSettingsState.chatMarkdownWordWrap, + }, + }), +})); + async function renderMarkdown(text: string, cwd = "C:\\Users\\LENOVO\\dpcode") { const { default: ChatMarkdown } = await import("./ChatMarkdown"); @@ -22,6 +34,48 @@ async function renderMarkdown(text: string, cwd = "C:\\Users\\LENOVO\\dpcode") { } describe("ChatMarkdown", () => { + it("seeds code block and table wrapping from settings", async () => { + mockSettingsState.chatMarkdownWordWrap = true; + + const markup = await renderMarkdown( + [ + "```ts", + "const veryLongValue = 'abcdefghijklmnopqrstuvwxyz';", + "```", + "", + "| Column | Value |", + "| --- | --- |", + "| Long | abcdefghijklmnopqrstuvwxyz |", + ].join("\n"), + ); + + expect(markup).toContain("chat-markdown-codeblock--wrapped"); + expect(markup).toContain('aria-label="Disable code line wrap"'); + expect(markup).toContain("chat-markdown-table-scroll--wrapped"); + expect(markup).toContain('aria-label="Expand table cells"'); + }); + + it("can seed code block and table wrapping off without changing diff rendering", async () => { + mockSettingsState.chatMarkdownWordWrap = false; + + const markup = await renderMarkdown( + [ + "```ts", + "const veryLongValue = 'abcdefghijklmnopqrstuvwxyz';", + "```", + "", + "| Column | Value |", + "| --- | --- |", + "| Long | abcdefghijklmnopqrstuvwxyz |", + ].join("\n"), + ); + + expect(markup).not.toContain("chat-markdown-codeblock--wrapped"); + expect(markup).toContain('aria-label="Enable code line wrap"'); + expect(markup).not.toContain("chat-markdown-table-scroll--wrapped"); + expect(markup).toContain('aria-label="Wrap table cells"'); + }); + it("renders inline math with KaTeX", async () => { const markup = await renderMarkdown("Euler wrote $e^{i\\\\pi} + 1 = 0$."); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 535068fb..9cd81bb8 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -4,7 +4,7 @@ // Exports: ChatMarkdown import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; -import { CheckIcon, CopyIcon } from "~/lib/icons"; +import { CheckIcon, CopyIcon, TextWrapIcon } from "~/lib/icons"; import React, { Children, type CSSProperties, @@ -25,6 +25,7 @@ import { defaultUrlTransform } from "react-markdown"; import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; +import { useAppSettings } from "../appSettings"; import { openInPreferredEditor } from "../editorPreferences"; import { copyTextToClipboard } from "../hooks/useCopyToClipboard"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; @@ -532,9 +533,40 @@ function getHighlighterPromise(language: string): Promise { return promise; } +function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { + const { settings } = useAppSettings(); + const [wrapped, setWrapped] = useState(settings.chatMarkdownWordWrap); + const wrapLabel = wrapped ? "Expand table cells" : "Wrap table cells"; + + return ( +
+ + {children}
+
+ ); +} + function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNode }) { + const { settings } = useAppSettings(); const [copied, setCopied] = useState(false); + const [wrapped, setWrapped] = useState(settings.chatMarkdownWordWrap); const copiedTimerRef = useRef | null>(null); + const wrapLabel = wrapped ? "Disable code line wrap" : "Enable code line wrap"; const handleCopy = useCallback(() => { void copyTextToClipboard(code) .then(() => { @@ -561,16 +593,34 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo ); return ( -
- +
+
+ + +
{children}
); @@ -700,7 +750,11 @@ function ChatMarkdown({ pre({ node: _node, children, ...props }) { const codeBlock = extractCodeBlock(children); if (!codeBlock) { - return
{children}
; + return ( + +
{children}
+
+ ); } return ( @@ -745,11 +799,7 @@ function ChatMarkdown({ return {alt}; }, table({ node: _node, children, ...props }) { - return ( -
- {children}
-
- ); + return {children}; }, }), [cwd, diffThemeName, isStreaming, onImageExpand], diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index dcb032fe..3474df9b 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -107,7 +107,10 @@ import { useStore } from "../store"; import ReleaseHistoryDialog from "../components/ReleaseHistoryDialog"; import { createAllThreadsSelector } from "../storeSelectors"; import { formatRelativeTime } from "../components/Sidebar"; -import { formatWorktreePathForDisplay } from "../worktreeCleanup"; +import { + classifyManagedWorktreeCleanupChoices, + formatWorktreePathForDisplay, +} from "../worktreeCleanup"; import { filterVisibleProviderItems, sameProviderOrder } from "../providerOrdering"; // ── Settings taxonomy ────────────────────────────────────────────────────── @@ -751,33 +754,23 @@ function SettingsRouteView() { return () => window.cancelAnimationFrame(frame); }, [serverConfigQuery.data?.providers, shouldFocusProviderUpdates]); const managedWorktrees = serverWorktreesQuery.data?.worktrees ?? []; - const worktreesByWorkspaceRoot = managedWorktrees.reduce< + const managedWorktreeChoices = useMemo( + () => classifyManagedWorktreeCleanupChoices({ worktrees: managedWorktrees, threads }), + [managedWorktrees, threads], + ); + const worktreesByWorkspaceRoot = managedWorktreeChoices.reduce< Array<{ workspaceRoot: string; - worktrees: Array<{ - path: string; - linkedThreads: typeof threads; - }>; + worktrees: Array<(typeof managedWorktreeChoices)[number]>; }> >((groups, worktree) => { - const linkedThreads = threads.filter((thread) => { - const candidatePaths = [ - normalizeManagedWorktreePath(thread.worktreePath), - normalizeManagedWorktreePath(thread.associatedWorktreePath), - ]; - return candidatePaths.includes(worktree.path); - }); const existingGroup = groups.find((group) => group.workspaceRoot === worktree.workspaceRoot); - const nextWorktree = { - path: worktree.path, - linkedThreads, - }; if (existingGroup) { - existingGroup.worktrees.push(nextWorktree); + existingGroup.worktrees.push(worktree); } else { groups.push({ workspaceRoot: worktree.workspaceRoot, - worktrees: [nextWorktree], + worktrees: [worktree], }); } return groups; @@ -885,7 +878,13 @@ function SettingsRouteView() { ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ["Assistant output"] : []), + ...(settings.chatMarkdownWordWrap !== defaults.chatMarkdownWordWrap + ? ["Chat markdown wrapping"] + : []), ...(settings.diffWordWrap !== defaults.diffWordWrap ? ["Diff line wrapping"] : []), + ...(settings.enableProviderUpdateChecks !== defaults.enableProviderUpdateChecks + ? ["Provider update checks"] + : []), ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ["Delete confirmation"] : []), @@ -1278,9 +1277,22 @@ function SettingsRouteView() { }, [isRepairingLocalState, syncServerReadModel]); const deleteManagedWorktree = useCallback( - async (input: { workspaceRoot: string; worktreePath: string }) => { + async (input: { + workspaceRoot: string; + worktreePath: string; + canRemove: boolean; + blockedReason: string | null; + }) => { const api = readNativeApi() ?? ensureNativeApi(); const displayName = formatWorktreePathForDisplay(input.worktreePath); + if (!input.canRemove) { + toastManager.add({ + type: "error", + title: "Worktree cleanup blocked", + description: input.blockedReason ?? "This worktree is not safe to remove.", + }); + return; + } const snapshot = await api.orchestration.getShellSnapshot().catch(() => null); if (snapshot === null) { toastManager.add({ @@ -1337,7 +1349,6 @@ function SettingsRouteView() { await removeWorktreeMutation.mutateAsync({ cwd: input.workspaceRoot, path: input.worktreePath, - force: true, }); await queryClient.invalidateQueries({ queryKey: serverQueryKeys.worktrees(), @@ -2110,6 +2121,34 @@ function SettingsRouteView() { } /> + + updateSettings({ + chatMarkdownWordWrap: defaults.chatMarkdownWordWrap, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + chatMarkdownWordWrap: Boolean(checked), + }) + } + aria-label="Wrap chat code blocks and markdown tables by default" + /> + } + /> + } /> + + + updateSettings({ + enableProviderUpdateChecks: defaults.enableProviderUpdateChecks, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + enableProviderUpdateChecks: Boolean(checked), + }) + } + aria-label="Check provider versions" + /> + } + />
@@ -2257,7 +2324,7 @@ function SettingsRouteView() {
{group.worktrees.map((worktree, index) => { - const deleteDisabled = removeWorktreeMutation.isPending; + const deleteDisabled = removeWorktreeMutation.isPending || !worktree.canRemove; return (
-
-
Worktree
+
+
+
+ {worktree.branch ?? "Detached worktree"} +
+ + {worktree.association === "orphaned" + ? "No linked conversations" + : worktree.association === "archived" + ? "Archived only" + : "Active conversation"} + + {worktree.cleanupStatus !== "safe" ? ( + + Cleanup blocked + + ) : null} +
{worktree.path}
+ {worktree.blockedReason ? ( +
+ {worktree.blockedReason} +
+ ) : null}
@@ -2303,12 +2391,18 @@ function SettingsRouteView() { void deleteManagedWorktree({ workspaceRoot: group.workspaceRoot, worktreePath: worktree.path, + canRemove: worktree.canRemove, + blockedReason: worktree.blockedReason, }) } > Delete - {worktree.linkedThreads.length > 0 ? ( + {worktree.blockedReason ? ( +

+ {worktree.blockedReason} +

+ ) : worktree.linkedThreads.length > 0 ? (

Linked conversations exist. Deleting will ask for confirmation.

From a75972fa5cf0976ab6960ce2270b0de82c5eff59 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:25:37 -0400 Subject: [PATCH 05/27] feat(server): add provider update check opt out --- .../src/provider/providerMaintenance.test.ts | 54 +++++++++++++++++++ .../src/provider/providerMaintenance.ts | 12 ++++- apps/server/src/serverSettings.test.ts | 19 +++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 83768eba..a2d2288e 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,12 +1,21 @@ import { describe, it, assert } from "@effect/vitest"; +import type { ServerProviderStatus } from "@jcode/contracts"; +import { Effect } from "effect"; +import { afterEach, vi } from "vitest"; import { createProviderVersionAdvisory, + enrichProviderStatusWithVersionAdvisory, + makeProviderMaintenanceCapabilities, parseGenericCliVersion, resolvePackageManagedProviderMaintenance, type PackageManagedProviderMaintenanceDefinition, } from "./providerMaintenance"; +afterEach(() => { + vi.unstubAllGlobals(); +}); + const CODEX_DEFINITION = { provider: "codex", binaryName: "codex", @@ -33,6 +42,15 @@ const OPENCODE_DEFINITION = { }, } as const satisfies PackageManagedProviderMaintenanceDefinition; +const INSTALLED_CODEX_STATUS = { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + version: "1.0.0", + checkedAt: "2026-04-10T00:00:00.000Z", +} as const satisfies ServerProviderStatus; + describe("providerMaintenance", () => { it("parses generic CLI versions", () => { assert.strictEqual(parseGenericCliVersion("codex-cli 0.130.0\n"), "0.130.0"); @@ -121,4 +139,40 @@ describe("providerMaintenance", () => { assert.strictEqual(advisory.currentVersion, "0.129.0"); assert.strictEqual(advisory.latestVersion, "0.130.0"); }); + + it.effect("skips latest-version lookup while preserving current provider health", () => { + vi.stubGlobal( + "fetch", + vi.fn(() => { + throw new Error("provider update checks are disabled"); + }), + ); + + return enrichProviderStatusWithVersionAdvisory( + INSTALLED_CODEX_STATUS, + makeProviderMaintenanceCapabilities({ + provider: "codex", + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", + }), + { enableProviderUpdateChecks: false }, + ).pipe( + Effect.map((status) => { + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.deepStrictEqual(status.versionAdvisory, { + status: "unknown", + currentVersion: "1.0.0", + latestVersion: null, + updateCommand: "npm install -g @openai/codex@latest", + canUpdate: true, + checkedAt: "2026-04-10T00:00:00.000Z", + message: "Provider update checks are disabled.", + }); + assert.strictEqual(vi.mocked(fetch).mock.calls.length, 0); + }), + ); + }); }); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 765f153a..e2f498a7 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -535,6 +535,7 @@ export function createProviderVersionAdvisory(input: { readonly latestVersion?: string | null; readonly checkedAt?: string | null; readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities; + readonly message?: string | null; }): ServerProviderVersionAdvisory { const capabilities = input.maintenanceCapabilities ?? @@ -552,7 +553,7 @@ export function createProviderVersionAdvisory(input: { updateCommand: capabilities.update?.command ?? null, canUpdate: capabilities.update !== null, checkedAt: input.checkedAt ?? null, - message: advisory.message, + message: input.message ?? advisory.message, }; } @@ -634,8 +635,11 @@ export const enrichProviderStatusWithVersionAdvisory = Effect.fn( )(function* ( status: ServerProviderStatus, maintenanceCapabilities: ProviderMaintenanceCapabilities, + options?: { + readonly enableProviderUpdateChecks?: boolean; + }, ) { - if (!status.available || !status.version) { + if (!status.available || !status.version || options?.enableProviderUpdateChecks === false) { return { ...status, versionAdvisory: createProviderVersionAdvisory({ @@ -643,6 +647,10 @@ export const enrichProviderStatusWithVersionAdvisory = Effect.fn( currentVersion: status.version ?? null, checkedAt: status.checkedAt, maintenanceCapabilities, + message: + options?.enableProviderUpdateChecks === false + ? "Provider update checks are disabled." + : null, }), }; } diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d4c9c043..71b5b7a7 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -50,6 +50,25 @@ describe("ServerSettingsService", () => { expect(settings.providers.opencode.activeRuntimeProfileId).toBe(""); }); + it("falls back to default chat and provider update settings when the settings file is malformed", async () => { + const settings = await runWithSettings( + Effect.gen(function* () { + const service = yield* ServerSettingsService; + const { settingsPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dirname(settingsPath), { recursive: true }); + yield* fs.writeFileString(settingsPath, "{not-json"); + + yield* service.start; + return yield* service.getSettings; + }), + ); + + expect(settings.chatMarkdownWordWrap).toBe(true); + expect(settings.enableProviderUpdateChecks).toBe(true); + expect(settings.diffWordWrap).toBe(false); + }); + it("persists updates and reloads them", async () => { const result = await runWithSettings( Effect.gen(function* () { From 3e29c0ecbf6327c44d64fa0b848ae1dc53bd3868 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:03 -0400 Subject: [PATCH 06/27] fix(web): stabilize composer provider state --- .../chat/composerProviderRegistry.test.tsx | 81 ++++++++++++++----- .../chat/composerProviderRegistry.tsx | 13 +-- .../web/src/components/chat/composerTraits.ts | 18 +++++ 3 files changed, 86 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index bb0801b7..262bcb08 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -5,7 +5,7 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderRegistry"; -import { getComposerTraitSelection } from "./composerTraits"; +import { deriveComposerPromptTraits, getComposerTraitSelection } from "./composerTraits"; const OPENCODE_RUNTIME_MODEL_WITH_REASONING: ProviderModelDescriptor = { slug: "openai/gpt-5.4", @@ -63,11 +63,68 @@ const PI_RUNTIME_MODEL_WITH_REASONING: ProviderModelDescriptor = { }; describe("getComposerProviderState", () => { + it("derives stable prompt traits for ordinary typing and updates exactly for ultrathink", () => { + const ordinaryTraits = deriveComposerPromptTraits("Investigate this failure"); + const ordinaryTypingTraits = deriveComposerPromptTraits("Investigate this failure now"); + const ultrathinkTraits = deriveComposerPromptTraits("Ultrathink:\nInvestigate this failure"); + const ordinaryState = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + promptTraits: ordinaryTraits, + modelOptions: { + claudeAgent: { + effort: "medium", + }, + }, + }); + const ordinaryTypingState = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + promptTraits: ordinaryTypingTraits, + modelOptions: { + claudeAgent: { + effort: "medium", + }, + }, + }); + const ultrathinkState = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + promptTraits: ultrathinkTraits, + modelOptions: { + claudeAgent: { + effort: "medium", + }, + }, + }); + + expect(ordinaryTypingTraits).toBe(ordinaryTraits); + expect(ordinaryTraits).toEqual({ ultrathinkPromptActive: false }); + expect(ultrathinkTraits).toEqual({ ultrathinkPromptActive: true }); + expect(ordinaryTypingState).toEqual(ordinaryState); + expect(ordinaryState).toEqual({ + provider: "claudeAgent", + promptEffort: "medium", + modelOptionsForDispatch: { + effort: "medium", + }, + }); + expect(ultrathinkState).toEqual({ + provider: "claudeAgent", + promptEffort: "medium", + modelOptionsForDispatch: { + effort: "medium", + }, + composerFrameClassName: "ultrathink-frame", + composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", + modelPickerIconClassName: "ultrathink-chroma", + }); + }); + it("returns codex defaults when no codex draft options exist", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", - prompt: "", modelOptions: undefined, }); @@ -82,7 +139,6 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", - prompt: "", modelOptions: { codex: { reasoningEffort: "low", @@ -105,7 +161,6 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", - prompt: "", modelOptions: { codex: { fastMode: true, @@ -133,7 +188,6 @@ describe("getComposerProviderState", () => { supportedReasoningEfforts: [{ value: "low" }, { value: "medium" }, { value: "high" }], defaultReasoningEffort: "medium", }, - prompt: "", modelOptions: { codex: { fastMode: true, @@ -160,7 +214,6 @@ describe("getComposerProviderState", () => { supportedReasoningEfforts: [{ value: "low" }, { value: "medium" }, { value: "high" }], defaultReasoningEffort: "medium", }, - prompt: "", modelOptions: { codex: { fastMode: true, @@ -179,7 +232,6 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", - prompt: "", modelOptions: { codex: { reasoningEffort: "high", @@ -199,7 +251,6 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-sonnet-4-6", - prompt: "", modelOptions: undefined, }); @@ -214,7 +265,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-sonnet-4-6", - prompt: "Ultrathink:\nInvestigate this failure", + promptTraits: deriveComposerPromptTraits("Ultrathink:\nInvestigate this failure"), modelOptions: { claudeAgent: { effort: "medium", @@ -267,7 +318,6 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-haiku-4-5", - prompt: "", modelOptions: { claudeAgent: { effort: "max", @@ -289,7 +339,6 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-opus-4-6", - prompt: "", modelOptions: { claudeAgent: { fastMode: true, @@ -310,7 +359,6 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-opus-4-6", - prompt: "", modelOptions: { claudeAgent: { effort: "high", @@ -330,7 +378,6 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "gemini", model: "gemini-2.5-pro", - prompt: "", modelOptions: { gemini: { thinkingBudget: 512, @@ -351,7 +398,6 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "gemini", model: "auto-gemini-2.5", - prompt: "", modelOptions: { gemini: { thinkingBudget: 0, @@ -370,7 +416,6 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "gemini", model: "gemini-2.5-flash", - prompt: "", modelOptions: { gemini: { thinkingBudget: 0, @@ -389,7 +434,6 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "gemini", model: "gemini-3.1-pro-preview", - prompt: "", modelOptions: { gemini: { thinkingLevel: "HIGH", @@ -409,7 +453,6 @@ describe("getComposerProviderState", () => { provider: "cursor", model: "claude-opus-4-7", runtimeModel: CURSOR_RUNTIME_MODEL_300K, - prompt: "", modelOptions: { cursor: { reasoningEffort: "xhigh", @@ -440,7 +483,6 @@ describe("getComposerProviderState", () => { provider: "pi", model: "openai/gpt-5.5", runtimeModel: PI_RUNTIME_MODEL_WITH_REASONING, - prompt: "", modelOptions: { pi: { thinkingLevel: "xhigh", @@ -490,7 +532,6 @@ describe("getComposerProviderState", () => { provider: "opencode", model: "openai/gpt-5.4", runtimeModel: OPENCODE_RUNTIME_MODEL_WITH_REASONING, - prompt: "", modelOptions: { opencode: { variant: "xhigh", @@ -512,7 +553,6 @@ describe("getComposerProviderState", () => { provider: "opencode", model: "openai/gpt-5.4", runtimeModel: OPENCODE_RUNTIME_MODEL_WITH_REASONING, - prompt: "", modelOptions: undefined, }); @@ -528,7 +568,6 @@ describe("getComposerProviderState", () => { provider: "opencode", model: "opencode/gpt-5-nano", runtimeModel: OPENCODE_RUNTIME_MODEL_WITHOUT_DEFAULT, - prompt: "", modelOptions: undefined, }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 703801d4..db35850d 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -17,7 +17,6 @@ import { getGeminiThinkingSelectionValue, hasContextWindowOption, hasEffortLevel, - isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, normalizeGeminiModelOptions, normalizeOpenCodeModelOptions, @@ -27,7 +26,11 @@ import { } from "@jcode/shared/model"; import type { ReactNode } from "react"; import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; -import { getComposerTraitSelection, hasVisibleComposerTraitControls } from "./composerTraits"; +import { + type ComposerPromptTraits, + getComposerTraitSelection, + hasVisibleComposerTraitControls, +} from "./composerTraits"; import { getRuntimeAwareModelCapabilities } from "./runtimeModelCapabilities"; type ComposerProviderModelOptions = ProviderModelOptions[keyof ProviderModelOptions]; @@ -36,7 +39,7 @@ export type ComposerProviderStateInput = { provider: ProviderKind; model: ModelSlug; runtimeModel?: ProviderModelDescriptor | undefined; - prompt: string; + promptTraits?: ComposerPromptTraits | undefined; modelOptions: ProviderModelOptions | null | undefined; }; @@ -119,7 +122,7 @@ function renderTraitsPickerForProvider( function getProviderStateFromCapabilities( input: ComposerProviderStateInput, ): ComposerProviderState { - const { provider, model, runtimeModel, prompt, modelOptions } = input; + const { provider, model, runtimeModel, promptTraits, modelOptions } = input; const caps = getRuntimeAwareModelCapabilities({ provider, model, runtimeModel }); let rawEffort: string | null = null; @@ -228,7 +231,7 @@ function getProviderStateFromCapabilities( : null; const ultrathinkActive = - caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); + caps.promptInjectedEffortLevels.length > 0 && promptTraits?.ultrathinkPromptActive === true; return { provider, diff --git a/apps/web/src/components/chat/composerTraits.ts b/apps/web/src/components/chat/composerTraits.ts index 1da004b2..aafe9b0b 100644 --- a/apps/web/src/components/chat/composerTraits.ts +++ b/apps/web/src/components/chat/composerTraits.ts @@ -18,6 +18,24 @@ import { import type { ProviderOptions } from "../../providerModelOptions"; import { getRuntimeAwareModelCapabilities } from "./runtimeModelCapabilities"; +export type ComposerPromptTraits = { + readonly ultrathinkPromptActive: boolean; +}; + +const ORDINARY_COMPOSER_PROMPT_TRAITS: ComposerPromptTraits = { + ultrathinkPromptActive: false, +}; + +const ULTRATHINK_COMPOSER_PROMPT_TRAITS: ComposerPromptTraits = { + ultrathinkPromptActive: true, +}; + +export function deriveComposerPromptTraits(prompt: string): ComposerPromptTraits { + return isClaudeUltrathinkPrompt(prompt) + ? ULTRATHINK_COMPOSER_PROMPT_TRAITS + : ORDINARY_COMPOSER_PROMPT_TRAITS; +} + function getCursorBooleanModelParameter( model: string | null | undefined, key: "fast" | "thinking", From 0c0e43a8b76a9bd0f906f76fd31cb1c4c77d1ce7 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:03 -0400 Subject: [PATCH 07/27] feat(web): normalize activity details --- apps/web/src/session-logic.test.ts | 67 ++++++++++++++ apps/web/src/session-logic.ts | 142 +++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index e14bde31..d6a4960a 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1167,6 +1167,42 @@ describe("deriveWorkLogEntries", () => { ]); }); + it("keeps command output, exit code, and duration for expanded timeline details", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-output-details", + createdAt: "2026-05-05T15:40:02.000Z", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + title: "Ran command", + data: { + toolCallId: "command-output-1", + command: "bun test src/components/chat/MessagesTimeline.test.tsx", + rawOutput: { + stdout: "PASS timeline details\n", + stderr: "warning: slow path\n", + exitCode: 0, + durationMs: 1250, + }, + }, + }, + }), + ]; + + expect(deriveWorkLogEntries(activities, undefined)).toMatchObject([ + { + id: "command-output-details", + command: "bun test src/components/chat/MessagesTimeline.test.tsx", + stdout: "PASS timeline details\n", + stderr: "warning: slow path\n", + exitCode: 0, + durationMs: 1250, + }, + ]); + }); + it("shows a completion detail for completed commands with no output", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1585,6 +1621,37 @@ describe("deriveWorkLogEntries", () => { ]); }); + it("keeps file-change patches for expanded timeline diff details", () => { + const patch = [ + "diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts", + "--- a/apps/web/src/session-logic.ts", + "+++ b/apps/web/src/session-logic.ts", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n"); + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "file-change-patch-details", + kind: "tool.completed", + summary: "File Change", + payload: { + itemType: "file_change", + data: { + item: { + path: "apps/web/src/session-logic.ts", + patch, + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.changedFiles).toEqual(["apps/web/src/session-logic.ts"]); + expect(entry?.patch).toBe(patch); + }); + it("extracts Cursor read targets from rawInput and ACP locations", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 8f7d01eb..47d6df25 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -53,6 +53,12 @@ export interface WorkLogEntry { detail?: string; command?: string; rawCommand?: string; + output?: string; + stdout?: string; + stderr?: string; + exitCode?: number; + durationMs?: number; + patch?: string; preview?: string; changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; @@ -728,7 +734,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo : null; const commandAction = extractPrimaryCommandAction(payload); const commandPreview = extractToolCommand(payload, commandAction); + const commandResult = extractCommandResult(payload); const changedFiles = extractChangedFiles(payload); + const patch = extractToolPatch(payload); const title = extractToolTitle(payload); const toolName = extractToolName(payload); const toolCallId = extractToolCallId(payload); @@ -763,6 +771,30 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (commandPreview.rawCommand) { entry.rawCommand = commandPreview.rawCommand; } + const isCommandEntry = + itemType === "command_execution" || + requestKind === "command" || + Boolean(commandPreview.command || commandPreview.rawCommand); + if (isCommandEntry) { + if (commandResult.output) { + entry.output = commandResult.output; + } + if (commandResult.stdout) { + entry.stdout = commandResult.stdout; + } + if (commandResult.stderr) { + entry.stderr = commandResult.stderr; + } + if (commandResult.exitCode !== undefined) { + entry.exitCode = commandResult.exitCode; + } + if (commandResult.durationMs !== undefined) { + entry.durationMs = commandResult.durationMs; + } + } + if (patch) { + entry.patch = patch; + } const commandActionDisplay = deriveCommandActionDisplay(commandAction, activity.kind); if (commandActionDisplay?.preview) { entry.preview = commandActionDisplay.preview; @@ -1012,6 +1044,116 @@ function asTrimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function asOutputString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + return value.length > 0 ? value : null; +} + +function asInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) ? value : undefined; +} + +function asFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function firstOutputString(values: ReadonlyArray): string | undefined { + for (const value of values) { + const output = asOutputString(value); + if (output) { + return output; + } + } + return undefined; +} + +interface CommandResultDetails { + output?: string | undefined; + stdout?: string | undefined; + stderr?: string | undefined; + exitCode?: number | undefined; + durationMs?: number | undefined; +} + +function extractCommandResult(payload: Record | null): CommandResultDetails { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + const rawOutput = asRecord(data?.rawOutput) ?? asRecord(item?.rawOutput); + const stdout = normalizeStdoutWithExitCode( + firstOutputString([rawOutput?.stdout, item?.stdout, itemResult?.stdout, data?.stdout]), + ); + const stderr = firstOutputString([ + rawOutput?.stderr, + item?.stderr, + itemResult?.stderr, + data?.stderr, + ]); + const output = firstOutputString([ + rawOutput?.output, + rawOutput?.aggregatedOutput, + item?.output, + item?.aggregatedOutput, + itemResult?.output, + data?.output, + ]); + const exitCode = + asInteger(rawOutput?.exitCode) ?? + asInteger(item?.exitCode) ?? + asInteger(itemResult?.exitCode) ?? + asInteger(data?.exitCode) ?? + stdout.exitCode; + const durationMs = + asFiniteNumber(rawOutput?.durationMs) ?? + asFiniteNumber(item?.durationMs) ?? + asFiniteNumber(itemResult?.durationMs) ?? + asFiniteNumber(data?.durationMs); + + return { + ...(output ? { output } : {}), + ...(stdout.output ? { stdout: stdout.output } : {}), + ...(stderr ? { stderr } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + }; +} + +function normalizeStdoutWithExitCode(value: string | undefined): { + output?: string | undefined; + exitCode?: number | undefined; +} { + if (!value) { + return {}; + } + const stripped = stripTrailingExitCode(value); + if (stripped.exitCode === undefined) { + return { output: value }; + } + return { + ...(stripped.output ? { output: stripped.output } : {}), + exitCode: stripped.exitCode, + }; +} + +function extractToolPatch(payload: Record | null): string | null { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + return ( + firstOutputString([ + payload?.patch, + data?.patch, + item?.patch, + itemResult?.patch, + data?.diff, + item?.diff, + itemResult?.diff, + ]) ?? null + ); +} + function normalizeCollabIdentifier(value: string | null | undefined): string | null { if (!value) { return null; From c411af7ecb24ea3f0bb9543eba0c7c15a8558b8d Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:03 -0400 Subject: [PATCH 08/27] feat(web): show expandable timeline activity rows --- .../components/chat/MessagesTimeline.test.tsx | 157 ++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 150 +++++++++---- .../chat/MessagesTimelineActivityDetails.tsx | 204 ++++++++++++++++++ 3 files changed, 466 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/components/chat/MessagesTimelineActivityDetails.tsx diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 6c6b71bf..16f3ab54 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1577,6 +1577,109 @@ describe("MessagesTimeline", () => { ); }); + it("renders file-change work rows with patch data as collapsed expandable detail rows", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="dark" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain('data-file-change-row="true"'); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain('aria-label="Expand File Change'); + expect(markup).toContain("MessagesTimeline.tsx"); + expect(markup).not.toContain("old timeline row"); + expect(markup).not.toContain("new timeline row"); + }); + + it("keeps file-change rows stable when patch data is missing", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="dark" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain("Edited"); + expect(markup).toContain("MessagesTimeline.logic.ts"); + expect(markup).not.toContain('aria-label="Expand File Change'); + }); + it("renders command rows with a readable summary and keeps the full command on hover", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( @@ -1629,6 +1732,60 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain(">/bin/zsh -lc"); }); + it("renders command work rows as collapsed expandable detail rows", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="dark" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain(" { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 168aecca..7648063e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -97,6 +97,12 @@ import { } from "../../lib/subagentPresentation"; import { RiRobot3Line } from "react-icons/ri"; import { deriveUserMessagePreviewState } from "./userMessagePreview"; +import { + ActivityEntryDetails, + hasExpandableActivityDetails, +} from "./MessagesTimelineActivityDetails"; +import { TimelineMinimap, jumpToTimelineMinimapItem } from "./MessagesTimelineMinimap"; +import { resolveTimelineLiveEdge } from "../../chat-scroll"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const MAX_VISIBLE_INLINE_TOOL_ENTRIES = 4; @@ -372,12 +378,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ (event) => { onMessagesScroll?.(event); const state = resolvedListRef.current?.getState?.(); - if (state) { - onIsAtEndChange?.(state.isAtEnd); + const liveEdgeVisible = resolveTimelineLiveEdge(state); + if (liveEdgeVisible !== undefined) { + onIsAtEndChange?.(liveEdgeVisible); } }, [onIsAtEndChange, onMessagesScroll, resolvedListRef], ); + const handleTimelineMinimapJump = useCallback( + (item: Parameters[1]) => { + jumpToTimelineMinimapItem(resolvedListRef, item); + }, + [resolvedListRef], + ); const toggleFileChangesExpanded = useCallback((turnId: TurnId) => { setExpandedFileChangesByTurnId((current) => ({ ...current, @@ -459,6 +472,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ chatMetaFontSizePx={appTypographyScale.chatMetaPx} textFontSizePx={appTypographyScale.uiSmPx} density={prefersCompactWorkEntryRow(workEntry) ? "compact" : "default"} + workspaceRoot={workspaceRoot} {...(onOpenThread ? { onOpenThread } : {})} /> ))} @@ -777,6 +791,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ textFontSizePx={normalizedChatFontSizePx} density="compact" fileDiffStatByPath={fileDiffStatByPath} + workspaceRoot={workspaceRoot} onOpenTurnDiff={onOpenTurnDiff} {...(onOpenThread ? { onOpenThread } : {})} {...(turnSummary?.turnId ? { turnId: turnSummary.turnId } : {})} @@ -1016,40 +1031,43 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } return ( - - ref={resolvedListRef} - data={rows} - keyExtractor={(row) => row.id} - renderItem={({ item }) => ( -
- {renderRowContent(item)} -
- )} - estimatedItemSize={90} - // LegendList caches rendered rows, so every local expansion map that changes row content - // has to be surfaced through extraData. - extraData={timelineExtraData} - initialScrollAtEnd - maintainScrollAtEnd={followLiveOutput} - maintainScrollAtEndThreshold={0.1} - maintainVisibleContentPosition - onClickCapture={onMessagesClickCapture} - onMouseUp={onMessagesMouseUp} - onPointerCancel={onMessagesPointerCancel} - onPointerDown={onMessagesPointerDown} - onPointerUp={onMessagesPointerUp} - onScroll={handleListScroll} - onTouchEnd={onMessagesTouchEnd} - onTouchMove={onMessagesTouchMove} - onTouchStart={onMessagesTouchStart} - onWheel={onMessagesWheel} - data-chat-scroll-container="true" - className="h-full overflow-x-hidden overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4" - {...(bottomContentInsetPx ? { style: { paddingBottom: bottomContentInsetPx } } : {})} - /> +
+ + ref={resolvedListRef} + data={rows} + keyExtractor={(row) => row.id} + renderItem={({ item }) => ( +
+ {renderRowContent(item)} +
+ )} + estimatedItemSize={90} + // LegendList caches rendered rows, so every local expansion map that changes row content + // has to be surfaced through extraData. + extraData={timelineExtraData} + initialScrollAtEnd + maintainScrollAtEnd={followLiveOutput} + maintainScrollAtEndThreshold={0.1} + maintainVisibleContentPosition + onClickCapture={onMessagesClickCapture} + onMouseUp={onMessagesMouseUp} + onPointerCancel={onMessagesPointerCancel} + onPointerDown={onMessagesPointerDown} + onPointerUp={onMessagesPointerUp} + onScroll={handleListScroll} + onTouchEnd={onMessagesTouchEnd} + onTouchMove={onMessagesTouchMove} + onTouchStart={onMessagesTouchStart} + onWheel={onMessagesWheel} + data-chat-scroll-container="true" + className="h-full overflow-x-hidden overscroll-y-contain px-3 py-3 sm:px-5 sm:py-4" + {...(bottomContentInsetPx ? { style: { paddingBottom: bottomContentInsetPx } } : {})} + /> + +
); }); @@ -1833,6 +1851,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { turnId?: TurnId; onOpenTurnDiff?: (turnId: TurnId, filePath?: string) => void; onOpenThread?: (threadId: ThreadId) => void; + workspaceRoot: string | undefined; }) { const { workEntry, @@ -1843,6 +1862,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { turnId, onOpenTurnDiff, onOpenThread, + workspaceRoot, } = props; const compact = density === "compact"; const EntryIcon = workEntryIcon(workEntry); @@ -1872,6 +1892,8 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ); const subagentSummary = subagentCardSummary(workEntry); const subagentMeta = subagentCardMeta(workEntry); + const [expanded, setExpanded] = useState(false); + const canExpand = hasExpandableActivityDetails(workEntry); // Use the text font size (matching the UI settings) for tool call rows const rowFontSizePx = textFontSizePx; @@ -1883,6 +1905,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { {changedFiles.map((changedFilePath) => { const changedFileStat = fileDiffStatByPath?.get(changedFilePath); const canOpenEditedDiff = Boolean(turnId && onOpenTurnDiff); + const changedFileLabel = `${toolWorkEntryHeading(workEntry)} ${basename(changedFilePath)}`; return ( ); })} + {expanded && canExpand ? ( + + ) : null}
) : showSubagentRows ? (
@@ -2171,17 +2205,43 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
); - if (!rawCommand) { - return rowContent; + const expandableRowContent = canExpand ? ( + + ) : ( + rowContent + ); + + const rowWithOptionalTooltip = + !rawCommand || canExpand ? ( + expandableRowContent + ) : ( + + + + {commandTooltipContent(rawCommand, displayText)} + + + ); + + if (!canExpand) { + return rowWithOptionalTooltip; } return ( - - - - {commandTooltipContent(rawCommand, displayText)} - - + <> + {rowWithOptionalTooltip} + {expanded ? ( + + ) : null} + ); })() )} diff --git a/apps/web/src/components/chat/MessagesTimelineActivityDetails.tsx b/apps/web/src/components/chat/MessagesTimelineActivityDetails.tsx new file mode 100644 index 00000000..9e02edce --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimelineActivityDetails.tsx @@ -0,0 +1,204 @@ +import type { WorkLogEntry } from "../../session-logic"; +import { formatDuration } from "../../session-logic"; +import { getRenderablePatch, serializeRenderablePatchText } from "../../lib/diffRendering"; +import { cn } from "~/lib/utils"; + +const COMMAND_OUTPUT_TAIL_LINES = 40; + +export function hasExpandableActivityDetails(workEntry: WorkLogEntry): boolean { + return hasCommandActivityDetails(workEntry) || hasFileChangeActivityDetails(workEntry); +} + +export function ActivityEntryDetails(props: { + workEntry: WorkLogEntry; + workspaceRoot: string | undefined; +}) { + if (hasCommandActivityDetails(props.workEntry)) { + return ; + } + if (hasFileChangeActivityDetails(props.workEntry)) { + return ; + } + return null; +} + +function hasCommandActivityDetails(workEntry: WorkLogEntry): boolean { + if (!isCommandActivity(workEntry)) { + return false; + } + return Boolean( + workEntry.command || + workEntry.rawCommand || + workEntry.output || + workEntry.stdout || + workEntry.stderr || + workEntry.exitCode !== undefined || + workEntry.durationMs !== undefined, + ); +} + +function isCommandActivity(workEntry: WorkLogEntry): boolean { + return ( + workEntry.itemType === "command_execution" || + workEntry.requestKind === "command" || + Boolean(workEntry.command ?? workEntry.rawCommand) + ); +} + +function hasFileChangeActivityDetails(workEntry: WorkLogEntry): boolean { + return isFileChangeActivity(workEntry) && Boolean(workEntry.patch?.trim()); +} + +function isFileChangeActivity(workEntry: WorkLogEntry): boolean { + return workEntry.itemType === "file_change" || workEntry.requestKind === "file-change"; +} + +function CommandActivityDetails(props: { workEntry: WorkLogEntry }) { + const command = props.workEntry.command ?? props.workEntry.rawCommand; + const rawCommand = + props.workEntry.rawCommand && props.workEntry.rawCommand !== command + ? props.workEntry.rawCommand + : null; + const hasStreamOutput = + hasRenderableCommandOutput(props.workEntry.stdout) || + hasRenderableCommandOutput(props.workEntry.stderr); + + return ( +
+ {command ? ( + + {command} + + ) : null} + {rawCommand ? ( + + {rawCommand} + + ) : null} +
+ + Exit code {props.workEntry.exitCode ?? "unknown"} + + + Duration {formatActivityDuration(props.workEntry.durationMs)} + +
+ {hasRenderableCommandOutput(props.workEntry.stdout) ? ( + + ) : null} + {hasRenderableCommandOutput(props.workEntry.stderr) ? ( + + ) : null} + {!hasStreamOutput && hasRenderableCommandOutput(props.workEntry.output) ? ( + + ) : null} +
+ ); +} + +function formatActivityDuration(durationMs: number | undefined): string { + return durationMs === undefined ? "unknown" : formatDuration(durationMs); +} + +function FileChangeActivityDetails(props: { + workEntry: WorkLogEntry; + workspaceRoot: string | undefined; +}) { + const renderablePatch = getRenderablePatch( + props.workEntry.patch, + `timeline-activity:${props.workEntry.id}`, + ); + const patchText = serializeRenderablePatchText(renderablePatch); + + return ( +
+ {(props.workEntry.changedFiles?.length ?? 0) > 0 ? ( +
+ {props.workEntry.changedFiles?.map((filePath) => { + const displayPath = formatWorkspaceRelativePath(filePath, props.workspaceRoot); + return ( + + {displayPath} + + ); + })} +
+ ) : null} + {patchText ? ( + + {patchText} + + ) : null} +
+ ); +} + +function formatWorkspaceRelativePath(filePath: string, workspaceRoot: string | undefined): string { + const normalizedPath = filePath.replace(/\\/gu, "/"); + const normalizedRoot = workspaceRoot?.replace(/\\/gu, "/").replace(/\/+$/u, ""); + if (normalizedRoot && normalizedPath.startsWith(`${normalizedRoot}/`)) { + return normalizedPath.slice(normalizedRoot.length + 1); + } + return normalizedPath; +} + +function ActivityDetailBlock(props: { + title: string; + children: string; + mono?: boolean; + tone?: "default" | "error"; +}) { + return ( +
+

+ {props.title} +

+
+ {props.children} +
+
+ ); +} + +function CommandOutputBlock(props: { title: string; value: string; tone?: "default" | "error" }) { + const lines = getRenderableCommandOutputLines(props.value); + const visibleLines = + lines.length > COMMAND_OUTPUT_TAIL_LINES ? lines.slice(-COMMAND_OUTPUT_TAIL_LINES) : lines; + return ( + + {visibleLines.join("\n")} + + ); +} + +function hasRenderableCommandOutput(value: string | undefined): value is string { + return getRenderableCommandOutputLines(value).length > 0; +} + +function getRenderableCommandOutputLines(value: string | undefined): string[] { + if (typeof value !== "string" || value.length === 0) { + return []; + } + const lines = value.split(/\r?\n/u); + let startIndex = 0; + let endIndex = lines.length; + while (startIndex < endIndex && (lines[startIndex]?.trim().length ?? 0) === 0) { + startIndex += 1; + } + while (endIndex > startIndex && (lines[endIndex - 1]?.trim().length ?? 0) === 0) { + endIndex -= 1; + } + return lines.slice(startIndex, endIndex); +} From 59579f008046299b25e2017008936551880457ef Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:03 -0400 Subject: [PATCH 09/27] feat(web): add timeline minimap affordance --- apps/web/src/chat-scroll.test.ts | 13 ++ apps/web/src/chat-scroll.ts | 11 ++ .../chat/MessagesTimeline.browser.tsx | 161 ++++++++++++++++++ .../chat/MessagesTimelineMinimap.test.tsx | 117 +++++++++++++ .../chat/MessagesTimelineMinimap.tsx | 150 ++++++++++++++++ 5 files changed, 452 insertions(+) create mode 100644 apps/web/src/components/chat/MessagesTimeline.browser.tsx create mode 100644 apps/web/src/components/chat/MessagesTimelineMinimap.test.tsx create mode 100644 apps/web/src/components/chat/MessagesTimelineMinimap.tsx diff --git a/apps/web/src/chat-scroll.test.ts b/apps/web/src/chat-scroll.test.ts index 4280c3ab..b7075d68 100644 --- a/apps/web/src/chat-scroll.test.ts +++ b/apps/web/src/chat-scroll.test.ts @@ -4,6 +4,7 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, getScrollContainerDistanceFromBottom, isScrollContainerNearBottom, + resolveTimelineLiveEdge, shouldDisableTailFollowOnScroll, shouldDisableTailFollowOnWheel, } from "./chat-scroll"; @@ -200,3 +201,15 @@ describe("isScrollContainerNearBottom", () => { expect(AUTO_SCROLL_BOTTOM_THRESHOLD_PX).toBe(64); }); }); + +describe("resolveTimelineLiveEdge", () => { + it("prefers near-end visibility so layout growth keeps follow mode stable", () => { + expect(resolveTimelineLiveEdge({ isNearEnd: true, isAtEnd: false })).toBe(true); + expect(resolveTimelineLiveEdge({ isNearEnd: false, isAtEnd: true })).toBe(false); + }); + + it("falls back to exact end visibility when near-end state is unavailable", () => { + expect(resolveTimelineLiveEdge({ isAtEnd: true })).toBe(true); + expect(resolveTimelineLiveEdge(undefined)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/chat-scroll.ts b/apps/web/src/chat-scroll.ts index 7ca30ae1..10d42021 100644 --- a/apps/web/src/chat-scroll.ts +++ b/apps/web/src/chat-scroll.ts @@ -22,6 +22,11 @@ interface TailFollowWheelInput { deltaY: number; } +interface TimelineLiveEdgeState { + isAtEnd?: boolean; + isNearEnd?: boolean; +} + export function getScrollContainerDistanceFromBottom(position: ScrollPosition): number { const { scrollTop, clientHeight, scrollHeight } = position; if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) { @@ -42,6 +47,12 @@ export function isScrollContainerNearBottom( return getScrollContainerDistanceFromBottom(position) <= threshold; } +export function resolveTimelineLiveEdge( + state: TimelineLiveEdgeState | undefined, +): boolean | undefined { + return state?.isNearEnd ?? state?.isAtEnd; +} + /** * Returns true when scroll input should disable sticky tail-follow. * diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx new file mode 100644 index 00000000..9fe6ba15 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -0,0 +1,161 @@ +import "../../index.css"; + +import { MessageId } from "@jcode/contracts"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import type { TimelineEntry, WorkLogEntry } from "../../session-logic"; +import { MessagesTimeline } from "./MessagesTimeline"; + +type DetailedWorkLogEntry = WorkLogEntry & { + readonly output?: string; + readonly stdout?: string; + readonly stderr?: string; + readonly exitCode?: number; + readonly durationMs?: number; + readonly patch?: string; +}; + +const EMPTY_WORK_GROUPS: Record = {}; +const EMPTY_TURN_DIFFS = new Map(); +const EMPTY_REVERT_COUNTS = new Map(); +const NOOP = () => {}; + +function workEntryTimeline(entry: DetailedWorkLogEntry): TimelineEntry[] { + return [ + { + id: `entry:${entry.id}`, + kind: "work", + createdAt: entry.createdAt, + entry, + }, + { + id: `assistant:${entry.id}`, + kind: "message", + createdAt: "2026-03-17T19:12:29.000Z", + message: { + id: MessageId.makeUnsafe(`assistant:${entry.id}`), + role: "assistant", + text: "done", + createdAt: "2026-03-17T19:12:29.000Z", + completedAt: "2026-03-17T19:12:30.000Z", + streaming: false, + }, + }, + ]; +} + +async function renderTimeline(entry: DetailedWorkLogEntry) { + return render( + , + ); +} + +describe("MessagesTimeline activity details", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("expands command rows to show command metadata and captured output", async () => { + const screen = await renderTimeline({ + id: "work-command-details", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Ran command", + tone: "tool", + itemType: "command_execution", + toolTitle: "Ran command", + command: "bun test src/components/chat/MessagesTimeline.browser.tsx", + rawCommand: "snip bun test src/components/chat/MessagesTimeline.browser.tsx", + stdout: "PASS timeline details", + stderr: "warning: slow path", + exitCode: 0, + durationMs: 1250, + }); + try { + const row = page.getByRole("button", { name: /Expand Ran command/u }); + await expect(row).toHaveAttribute("aria-expanded", "false"); + + await row.click(); + + await expect(row).toHaveAttribute("aria-expanded", "true"); + await expect(page.getByText("Command")).toBeVisible(); + await expect(page.getByText("Exit code 0")).toBeVisible(); + await expect(page.getByText("Duration 1.3s")).toBeVisible(); + await expect(page.getByText("PASS timeline details")).toBeVisible(); + await expect(page.getByText("warning: slow path")).toBeVisible(); + } finally { + await screen.unmount(); + } + }); + + it("expands file-change rows to show changed paths and inline patch details", async () => { + const screen = await renderTimeline({ + id: "work-file-details", + createdAt: "2026-03-17T19:12:28.000Z", + label: "File Change", + tone: "tool", + requestKind: "file-change", + changedFiles: ["apps/web/src/components/chat/MessagesTimeline.tsx"], + patch: [ + "diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx", + "--- a/apps/web/src/components/chat/MessagesTimeline.tsx", + "+++ b/apps/web/src/components/chat/MessagesTimeline.tsx", + "@@ -1 +1 @@", + "-old timeline row", + "+new timeline row", + ].join("\n"), + }); + try { + const row = page.getByRole("button", { name: /Expand File Change/u }); + await row.click(); + + await expect( + page.getByText("apps/web/src/components/chat/MessagesTimeline.tsx"), + ).toBeVisible(); + await expect(page.getByText("-old timeline row")).toBeVisible(); + await expect(page.getByText("+new timeline row")).toBeVisible(); + } finally { + await screen.unmount(); + } + }); + + it("keeps rows stable when optional output and patch details are missing", async () => { + const screen = await renderTimeline({ + id: "work-empty-details", + createdAt: "2026-03-17T19:12:28.000Z", + label: "File Change", + tone: "tool", + requestKind: "file-change", + changedFiles: ["apps/web/src/components/chat/MessagesTimeline.logic.ts"], + }); + try { + await expect(page.getByText("Edited")).toBeVisible(); + await expect(page.getByText("MessagesTimeline.logic.ts")).toBeVisible(); + await expect(page.getByText("Patch")).not.toBeVisible(); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimelineMinimap.test.tsx b/apps/web/src/components/chat/MessagesTimelineMinimap.test.tsx new file mode 100644 index 00000000..735061a5 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimelineMinimap.test.tsx @@ -0,0 +1,117 @@ +import { MessageId } from "@jcode/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; +import { + TimelineMinimap, + deriveTimelineMinimapItems, + getTimelineMinimapScrollRequest, +} from "./MessagesTimelineMinimap"; + +function userRow(id: string, text: string): MessagesTimelineRow { + return { + kind: "message", + id: `entry:${id}`, + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe(id), + role: "user", + text, + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + durationStart: "2026-03-17T19:12:28.000Z", + showCompletionDivider: false, + showAssistantCopyButton: false, + }; +} + +function assistantRow(id: string, text: string): MessagesTimelineRow { + return { + kind: "message", + id: `entry:${id}`, + createdAt: "2026-03-17T19:12:29.000Z", + message: { + id: MessageId.makeUnsafe(id), + role: "assistant", + text, + createdAt: "2026-03-17T19:12:29.000Z", + streaming: false, + }, + durationStart: "2026-03-17T19:12:28.000Z", + showCompletionDivider: false, + showAssistantCopyButton: false, + }; +} + +describe("MessagesTimelineMinimap", () => { + it("derives visible jump targets from user turns without flattening transcript grouping", () => { + const items = deriveTimelineMinimapItems([ + userRow("user-1", "Investigate flaky scroll follow"), + assistantRow("assistant-1", "Found the layout growth source"), + { + kind: "work", + id: "work-between-turns", + createdAt: "2026-03-17T19:12:30.000Z", + groupedEntries: [ + { + id: "work-1", + createdAt: "2026-03-17T19:12:30.000Z", + label: "Ran command", + tone: "tool", + }, + ], + }, + userRow("user-2", "Jump to this checkpoint after streaming grows"), + ]); + + expect(items).toEqual([ + { + id: "entry:user-1", + label: "Investigate flaky scroll follow", + rowIndex: 0, + preview: "Found the layout growth source", + }, + { + id: "entry:user-2", + label: "Jump to this checkpoint after streaming grows", + rowIndex: 3, + preview: null, + }, + ]); + const secondItem = items[1]; + expect(secondItem).toBeDefined(); + if (!secondItem) return; + + expect(getTimelineMinimapScrollRequest(secondItem)).toEqual({ + index: 3, + animated: true, + viewOffset: 24, + }); + }); + + it("renders accessible jump buttons only when a transcript has multiple anchors", () => { + const rows = [ + userRow("user-1", "First anchor"), + assistantRow("assistant-1", "First response"), + userRow("user-2", "Second anchor"), + ]; + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('aria-label="Timeline jumps"'); + expect(markup).toContain('aria-label="Jump to First anchor"'); + expect(markup).toContain('aria-label="Jump to Second anchor"'); + expect(markup).toContain("First response"); + expect(markup).toContain("bg-[var(--app-work-row-bg)]"); + }); + + it("stays hidden for short transcripts without jump value", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toBe(""); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimelineMinimap.tsx b/apps/web/src/components/chat/MessagesTimelineMinimap.tsx new file mode 100644 index 00000000..96d7a8e7 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimelineMinimap.tsx @@ -0,0 +1,150 @@ +import type { LegendListRef } from "@legendapp/list/react"; +import type { RefObject } from "react"; + +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; + +const TIMELINE_MINIMAP_MIN_ITEMS = 2; +const TIMELINE_MINIMAP_PREVIEW_MAX_CHARS = 96; +const TIMELINE_MINIMAP_VIEW_OFFSET_PX = 24; + +export interface TimelineMinimapItem { + readonly id: string; + readonly label: string; + readonly rowIndex: number; + readonly preview: string | null; +} + +export interface TimelineMinimapScrollRequest { + readonly index: number; + readonly animated: true; + readonly viewOffset: number; +} + +export function deriveTimelineMinimapItems( + rows: ReadonlyArray, +): TimelineMinimapItem[] { + const items: TimelineMinimapItem[] = []; + + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex]; + if (!row) continue; + + if (row.kind === "message" && row.message.role === "user") { + items.push({ + id: row.id, + label: compactMinimapText(row.message.text) ?? "User message", + rowIndex, + preview: resolveAssistantPreviewForUserTurn(rows, rowIndex), + }); + continue; + } + + if (row.kind === "proposed-plan") { + items.push({ + id: row.id, + label: compactMinimapText(row.proposedPlan.planMarkdown) ?? "Proposed plan", + rowIndex, + preview: "Proposed plan", + }); + } + } + + return items; +} + +export function getTimelineMinimapScrollRequest( + item: TimelineMinimapItem, +): TimelineMinimapScrollRequest { + return { + index: item.rowIndex, + animated: true, + viewOffset: TIMELINE_MINIMAP_VIEW_OFFSET_PX, + }; +} + +export function TimelineMinimap({ + rows, + onJump, +}: { + readonly rows: ReadonlyArray; + readonly onJump: (item: TimelineMinimapItem) => void; +}) { + const items = deriveTimelineMinimapItems(rows); + if (items.length < TIMELINE_MINIMAP_MIN_ITEMS) { + return null; + } + + const maxIndex = Math.max(1, items.length - 1); + + return ( + + ); +} + +export function jumpToTimelineMinimapItem( + listRef: RefObject, + item: TimelineMinimapItem, +): void { + void listRef.current?.scrollToIndex(getTimelineMinimapScrollRequest(item)); +} + +function resolveAssistantPreviewForUserTurn( + rows: ReadonlyArray, + userRowIndex: number, +): string | null { + let assistantText: string | null = null; + + for (let rowIndex = userRowIndex + 1; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex]; + if (!row) continue; + if (row.kind === "message" && row.message.role === "user") break; + if (row.kind === "proposed-plan") break; + if (row.kind === "message" && row.message.role === "assistant") { + assistantText = row.message.text; + } + } + + return compactMinimapText(assistantText); +} + +function compactMinimapText(value: string | null | undefined): string | null { + const compact = value?.replace(/\s+/gu, " ").trim() ?? ""; + if (compact.length === 0) { + return null; + } + if (compact.length <= TIMELINE_MINIMAP_PREVIEW_MAX_CHARS) { + return compact; + } + return `${compact.slice(0, TIMELINE_MINIMAP_PREVIEW_MAX_CHARS - 3).trimEnd()}...`; +} From e98c118123132d989f9cf48da019d5dd34c4bb8d Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:03 -0400 Subject: [PATCH 10/27] feat(web): surface provider usage status --- .../ProviderUsageStatusChip.logic.test.ts | 54 +++++++++++ .../ProviderUsageStatusChip.logic.ts | 49 ++++++++++ .../components/ProviderUsageStatusChip.tsx | 59 ++++++++++++ .../src/components/RuntimeUsageControls.tsx | 92 +++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 apps/web/src/components/ProviderUsageStatusChip.logic.test.ts create mode 100644 apps/web/src/components/ProviderUsageStatusChip.logic.ts create mode 100644 apps/web/src/components/ProviderUsageStatusChip.tsx create mode 100644 apps/web/src/components/RuntimeUsageControls.tsx diff --git a/apps/web/src/components/ProviderUsageStatusChip.logic.test.ts b/apps/web/src/components/ProviderUsageStatusChip.logic.test.ts new file mode 100644 index 00000000..cc74851d --- /dev/null +++ b/apps/web/src/components/ProviderUsageStatusChip.logic.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { resolveProviderUsageStatusChip } from "./ProviderUsageStatusChip.logic"; + +describe("resolveProviderUsageStatusChip", () => { + it("surfaces the most constrained normalized limit as active provider status", () => { + const chip = resolveProviderUsageStatusChip({ + rateLimits: [ + { + provider: "codex", + updatedAt: "2099-04-08T18:00:00.000Z", + limits: [ + { window: "Weekly", usedPercent: 20 }, + { window: "5h", usedPercent: 42 }, + ], + }, + ], + usageLines: [], + }); + + expect(chip).toEqual({ + ariaLabel: "Provider usage 5h: 58% remaining", + detail: undefined, + label: "5h", + tone: "muted", + value: "58% left", + }); + }); + + it("surfaces local usage lines when no provider exposes limit windows", () => { + const chip = resolveProviderUsageStatusChip({ + rateLimits: [], + usageLines: [ + { + label: "24h", + value: "1.2K tokens", + subtitle: "3 recent sessions", + }, + ], + }); + + expect(chip).toEqual({ + ariaLabel: "Provider usage 24h: 1.2K tokens", + detail: "3 recent sessions", + label: "24h", + tone: "muted", + value: "1.2K tokens", + }); + }); + + it("omits unsupported or empty usage data instead of implying limits exist", () => { + expect(resolveProviderUsageStatusChip({ rateLimits: [], usageLines: [] })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ProviderUsageStatusChip.logic.ts b/apps/web/src/components/ProviderUsageStatusChip.logic.ts new file mode 100644 index 00000000..659e11e1 --- /dev/null +++ b/apps/web/src/components/ProviderUsageStatusChip.logic.ts @@ -0,0 +1,49 @@ +import type { OpenUsageUsageLine } from "~/lib/openUsageRateLimits"; +import { + deriveVisibleRateLimitRows, + formatRateLimitRemainingPercent, + formatRateLimitResetTime, + type ProviderRateLimit, +} from "~/lib/rateLimits"; + +export type ProviderUsageStatusChipModel = { + readonly ariaLabel: string; + readonly detail: string | undefined; + readonly label: string; + readonly tone: "muted" | "warning"; + readonly value: string; +}; + +export function resolveProviderUsageStatusChip(input: { + readonly rateLimits: ReadonlyArray; + readonly usageLines: ReadonlyArray; +}): ProviderUsageStatusChipModel | null { + const limitRow = deriveVisibleRateLimitRows(input.rateLimits)[0]; + if (limitRow) { + const remaining = formatRateLimitRemainingPercent(limitRow.remainingPercent); + const detail = limitRow.resetsAt + ? `Resets ${formatRateLimitResetTime(limitRow.resetsAt)}` + : undefined; + + return { + ariaLabel: `Provider usage ${limitRow.label}: ${remaining} remaining`, + detail, + label: limitRow.label, + tone: limitRow.remainingPercent <= 20 ? "warning" : "muted", + value: `${remaining} left`, + }; + } + + const usageLine = input.usageLines[0]; + if (!usageLine) { + return null; + } + + return { + ariaLabel: `Provider usage ${usageLine.label}: ${usageLine.value}`, + detail: usageLine.subtitle, + label: usageLine.label, + tone: "muted", + value: usageLine.value, + }; +} diff --git a/apps/web/src/components/ProviderUsageStatusChip.tsx b/apps/web/src/components/ProviderUsageStatusChip.tsx new file mode 100644 index 00000000..f58404d0 --- /dev/null +++ b/apps/web/src/components/ProviderUsageStatusChip.tsx @@ -0,0 +1,59 @@ +import type { ProviderKind } from "@jcode/contracts"; +import { useMemo } from "react"; + +import type { OpenUsageUsageLine } from "~/lib/openUsageRateLimits"; +import type { ProviderRateLimit } from "~/lib/rateLimits"; +import { cn } from "~/lib/utils"; + +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; +import { ProviderUsagePanelContent } from "./ProviderUsagePanelContent"; +import { resolveProviderUsageStatusChip } from "./ProviderUsageStatusChip.logic"; + +export function ProviderUsageStatusChip(props: { + readonly provider: ProviderKind | null | undefined; + readonly rateLimits: ReadonlyArray; + readonly usageLines: ReadonlyArray; + readonly isLoading: boolean; + readonly learnMoreHref: string | null | undefined; +}) { + const chip = useMemo( + () => + resolveProviderUsageStatusChip({ + rateLimits: props.rateLimits, + usageLines: props.usageLines, + }), + [props.rateLimits, props.usageLines], + ); + + if (!chip) { + return null; + } + + const title = chip.detail ? `${chip.ariaLabel}. ${chip.detail}` : chip.ariaLabel; + + return ( + + + {chip.label} + {chip.value} + + + + + + ); +} diff --git a/apps/web/src/components/RuntimeUsageControls.tsx b/apps/web/src/components/RuntimeUsageControls.tsx new file mode 100644 index 00000000..4b142bc5 --- /dev/null +++ b/apps/web/src/components/RuntimeUsageControls.tsx @@ -0,0 +1,92 @@ +import type { ProviderKind, RuntimeMode } from "@jcode/contracts"; +import { FiThumbsUp } from "react-icons/fi"; +import { HiOutlineHandRaised } from "react-icons/hi2"; + +import type { ContextWindowSnapshot } from "../lib/contextWindow"; +import type { OpenUsageUsageLine } from "../lib/openUsageRateLimits"; +import type { ProviderRateLimit } from "../lib/rateLimits"; +import { cn } from "../lib/utils"; +import { ContextWindowMeter } from "./chat/ContextWindowMeter"; +import { ProviderUsageStatusChip } from "./ProviderUsageStatusChip"; + +export interface RuntimeUsageControlsProps { + provider?: ProviderKind | null | undefined; + runtimeMode?: RuntimeMode | undefined; + onRuntimeModeChange?: ((mode: RuntimeMode) => void) | undefined; + providerRateLimits?: ReadonlyArray | undefined; + providerUsageLines?: ReadonlyArray | undefined; + providerUsageIsLoading?: boolean | undefined; + providerUsageLearnMoreHref?: string | null | undefined; + contextWindow?: ContextWindowSnapshot | null | undefined; + cumulativeCostUsd?: number | null | undefined; + activeContextWindowLabel?: string | null | undefined; + pendingContextWindowLabel?: string | null | undefined; + className?: string | undefined; +} + +export function RuntimeUsageControls({ + provider, + runtimeMode, + onRuntimeModeChange, + providerRateLimits = [], + providerUsageLines = [], + providerUsageIsLoading = false, + providerUsageLearnMoreHref, + contextWindow, + cumulativeCostUsd, + activeContextWindowLabel, + pendingContextWindowLabel, + className, +}: RuntimeUsageControlsProps) { + return ( +
+ {runtimeMode && onRuntimeModeChange ? ( + + ) : null} + + {contextWindow ? ( + + ) : null} +
+ ); +} From 30516aaba98ec169a53a1efb1ea0abb9fd68e1a8 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:30 -0400 Subject: [PATCH 11/27] refactor(web): split branch toolbar controls --- .../BranchToolbar.structure.test.ts | 18 +- apps/web/src/components/BranchToolbar.tsx | 265 ++---------------- .../BranchToolbarEnvironmentPicker.tsx | 210 ++++++++++++++ 3 files changed, 251 insertions(+), 242 deletions(-) create mode 100644 apps/web/src/components/BranchToolbarEnvironmentPicker.tsx diff --git a/apps/web/src/components/BranchToolbar.structure.test.ts b/apps/web/src/components/BranchToolbar.structure.test.ts index 07dcf640..3c4ba8a6 100644 --- a/apps/web/src/components/BranchToolbar.structure.test.ts +++ b/apps/web/src/components/BranchToolbar.structure.test.ts @@ -2,12 +2,22 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; const branchToolbarSource = readFileSync(new URL("./BranchToolbar.tsx", import.meta.url), "utf8"); +const runtimeUsageControlsSource = readFileSync( + new URL("./RuntimeUsageControls.tsx", import.meta.url), + "utf8", +); describe("BranchToolbar structure", () => { it("renders runtime access as a tokenized state chip", () => { - expect(branchToolbarSource).toContain("runtime-usage-controls"); - expect(branchToolbarSource).toContain("runtime-access-chip"); - expect(branchToolbarSource).toContain("var(--app-runtime-chip-bg)"); - expect(branchToolbarSource).toContain("var(--app-runtime-chip-border)"); + expect(runtimeUsageControlsSource).toContain("runtime-usage-controls"); + expect(runtimeUsageControlsSource).toContain("runtime-access-chip"); + expect(runtimeUsageControlsSource).toContain("var(--app-runtime-chip-bg)"); + expect(runtimeUsageControlsSource).toContain("var(--app-runtime-chip-border)"); + }); + + it("wires provider usage into active runtime controls", () => { + expect(runtimeUsageControlsSource).toContain("ProviderUsageStatusChip"); + expect(branchToolbarSource).toContain("providerRateLimits={usageSummary.rateLimits}"); + expect(branchToolbarSource).toContain("providerUsageLines={usageSummary.usageLines}"); }); }); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index ed804c9a..db9a8d57 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,12 +1,7 @@ // FILE: BranchToolbar.tsx // Purpose: Renders the chat thread's compact workspace controls, including the // local usage popover, inline workspace handoff actions, and runtime access toggle. -import type { ThreadId, RuntimeMode } from "@jcode/contracts"; -import { LuSplit } from "react-icons/lu"; -import { ChevronDownIcon, ChevronRightIcon, HandoffIcon } from "~/lib/icons"; -import { FiThumbsUp } from "react-icons/fi"; -import { HiOutlineHandRaised } from "react-icons/hi2"; -import { PiLaptop } from "react-icons/pi"; +import type { RuntimeMode, ThreadId } from "@jcode/contracts"; import { useCallback, useMemo, useRef, useState } from "react"; import { useAppSettings } from "~/appSettings"; @@ -29,16 +24,12 @@ import { resolveEffectiveEnvMode, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; -import { ContextWindowMeter } from "./chat/ContextWindowMeter"; import type { ContextWindowSnapshot } from "../lib/contextWindow"; -import { ProviderUsagePanelContent } from "./ProviderUsagePanelContent"; -import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; -import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; +import { BranchToolbarEnvironmentPicker } from "./BranchToolbarEnvironmentPicker"; +import { RuntimeUsageControls } from "./RuntimeUsageControls"; import type { ThreadWorkspacePatch } from "../types"; -function WorktreeGlyph({ className }: { className?: string }) { - return ; -} +export { RuntimeUsageControls } from "./RuntimeUsageControls"; interface BranchToolbarProps { threadId: ThreadId; @@ -58,71 +49,6 @@ interface BranchToolbarProps { pendingContextWindowLabel?: string | null; } -export interface RuntimeUsageControlsProps { - runtimeMode?: RuntimeMode | undefined; - onRuntimeModeChange?: ((mode: RuntimeMode) => void) | undefined; - contextWindow?: ContextWindowSnapshot | null | undefined; - cumulativeCostUsd?: number | null | undefined; - activeContextWindowLabel?: string | null | undefined; - pendingContextWindowLabel?: string | null | undefined; - className?: string | undefined; -} - -export function RuntimeUsageControls({ - runtimeMode, - onRuntimeModeChange, - contextWindow, - cumulativeCostUsd, - activeContextWindowLabel, - pendingContextWindowLabel, - className, -}: RuntimeUsageControlsProps) { - return ( -
- {runtimeMode && onRuntimeModeChange ? ( - - ) : null} - {contextWindow ? ( - - ) : null} -
- ); -} - export default function BranchToolbar({ threadId, className, @@ -283,166 +209,24 @@ export default function BranchToolbar({ )} >
- {showEnvPicker ? ( - - - {environmentPresentation.mode === "local" ? ( - - ) : ( - - )} - {environmentPresentation.shortLabel} - - - -
-

- Continue in -

- {environmentPresentation.mode === "local" ? ( -
- - - {environmentPresentation.localOptionLabel} - - - - -
- ) : ( - - )} - {canSwitchToWorktree ? ( - - ) : null} - {effectiveEnvMode === "worktree" && !canHandoffToLocal ? ( -
- - - {environmentPresentation.worktreeOptionLabel} - - - - -
- ) : null} - {canHandoffToWorktree && onHandoffToWorktree ? ( - - ) : null} - {canHandoffToLocal && onHandoffToLocal ? ( - - ) : null} -
- -
- -
- - - - - - - Rate limits remaining - - - - - - -
- - - ) : ( - - - {environmentPresentation.shortLabel} - - )} + ; + +type BranchToolbarUsageSummary = { + readonly isLoading: boolean; + readonly learnMoreHref: string | null; + readonly rateLimits: ReadonlyArray; + readonly usageLines: ReadonlyArray; +}; + +export function WorktreeGlyph({ className }: { readonly className?: string }) { + return ; +} + +export function BranchToolbarEnvironmentPicker(props: { + readonly activeProvider: ProviderKind | null; + readonly canHandoffToLocal: boolean; + readonly canHandoffToWorktree: boolean; + readonly canSwitchToWorktree: boolean; + readonly effectiveEnvMode: EnvMode; + readonly envPickerOpen: boolean; + readonly environmentPresentation: ThreadEnvironmentPresentation; + readonly handoffBusy: boolean; + readonly onEnvModeChange: (mode: EnvMode) => void; + readonly onEnvPickerOpenChange: (open: boolean) => void; + readonly onHandoffToLocal?: (() => void) | undefined; + readonly onHandoffToWorktree?: (() => void) | undefined; + readonly onRateLimitsOpenChange: (open: boolean) => void; + readonly rateLimitsOpen: boolean; + readonly showEnvPicker: boolean; + readonly usageSummary: BranchToolbarUsageSummary; +}) { + if (!props.showEnvPicker) { + return ( + + + {props.environmentPresentation.shortLabel} + + ); + } + + return ( + + + {props.environmentPresentation.mode === "local" ? ( + + ) : ( + + )} + {props.environmentPresentation.shortLabel} + + + +
+

+ Continue in +

+ {props.environmentPresentation.mode === "local" ? ( +
+ + + {props.environmentPresentation.localOptionLabel} + + + + +
+ ) : ( + + )} + {props.canSwitchToWorktree ? ( + + ) : null} + {props.effectiveEnvMode === "worktree" && !props.canHandoffToLocal ? ( +
+ + + {props.environmentPresentation.worktreeOptionLabel} + + + + +
+ ) : null} + {props.canHandoffToWorktree && props.onHandoffToWorktree ? ( + + ) : null} + {props.canHandoffToLocal && props.onHandoffToLocal ? ( + + ) : null} +
+ +
+ +
+ + + + + + + Rate limits remaining + + + + + + +
+ + + ); +} From f432fb709892258da154d75f8330811b30ab5bbf Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:30 -0400 Subject: [PATCH 12/27] feat(server): add managed worktree cleanup --- apps/server/src/git/Layers/GitCore.test.ts | 120 ++++++++++++ apps/server/src/git/Layers/GitCore.ts | 208 +++++++++++++++++++++ apps/server/src/git/Services/GitCore.ts | 5 + 3 files changed, 333 insertions(+) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 3ee6e029..dcd03668 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -1354,6 +1354,126 @@ it.layer(TestLayer)("git integration", (it) => { expect(existsSync(wtPath)).toBe(false); }), ); + + it.effect("lists managed worktrees with safe cleanup metadata", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + const currentBranchEntry = (yield* (yield* GitCore).listBranches({ + cwd: tmp, + })).branches.find((branch) => branch.current); + expect(currentBranchEntry).toBeDefined(); + if (!currentBranchEntry) return; + const result = yield* (yield* GitCore).createWorktree({ + cwd: tmp, + branch: currentBranchEntry.name, + newBranch: "wt-safe-candidate", + path: null, + }); + + const listed = yield* (yield* GitCore).listManagedWorktrees(tmp); + + expect(listed).toContainEqual( + expect.objectContaining({ + path: result.worktree.path, + workspaceRoot: tmp, + branch: "wt-safe-candidate", + cleanupStatus: "safe", + }), + ); + + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: result.worktree.path }); + }), + ); + + it.effect("blocks dirty managed worktrees with an explanation", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + const currentBranchEntry = (yield* (yield* GitCore).listBranches({ + cwd: tmp, + })).branches.find((branch) => branch.current); + expect(currentBranchEntry).toBeDefined(); + if (!currentBranchEntry) return; + const result = yield* (yield* GitCore).createWorktree({ + cwd: tmp, + branch: currentBranchEntry.name, + newBranch: "wt-dirty-candidate", + path: null, + }); + yield* writeTextFile(path.join(result.worktree.path, "README.md"), "dirty change\n"); + + const listed = yield* (yield* GitCore).listManagedWorktrees(tmp); + const candidate = listed.find((worktree) => worktree.path === result.worktree.path); + + expect(candidate?.cleanupStatus).toBe("blocked_dirty"); + expect(candidate?.cleanupExplanation).toContain("Uncommitted changes"); + + yield* (yield* GitCore).removeWorktree({ + cwd: tmp, + path: result.worktree.path, + force: true, + }); + }), + ); + + it.effect("blocks unmerged managed worktrees with an explanation", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + const currentBranchEntry = (yield* (yield* GitCore).listBranches({ + cwd: tmp, + })).branches.find((branch) => branch.current); + expect(currentBranchEntry).toBeDefined(); + if (!currentBranchEntry) return; + const result = yield* (yield* GitCore).createWorktree({ + cwd: tmp, + branch: currentBranchEntry.name, + newBranch: "wt-unmerged-candidate", + path: null, + }); + yield* writeTextFile(path.join(result.worktree.path, "unmerged.txt"), "unmerged\n"); + yield* git(result.worktree.path, ["add", "unmerged.txt"]); + yield* git(result.worktree.path, ["commit", "-m", "unmerged work"]); + + const listed = yield* (yield* GitCore).listManagedWorktrees(tmp); + const candidate = listed.find((worktree) => worktree.path === result.worktree.path); + + expect(candidate?.cleanupStatus).toBe("blocked_unmerged"); + expect(candidate?.cleanupExplanation).toContain("not merged"); + + yield* (yield* GitCore).removeWorktree({ + cwd: tmp, + path: result.worktree.path, + force: true, + }); + }), + ); + + it.effect("ignores malformed successful worktree list output", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const realGitCore = yield* GitCore; + const core = yield* makeIsolatedGitCore((input) => { + if (input.args[0] === "worktree" && input.args[1] === "list") { + return Effect.succeed({ + code: 0, + stdout: "HEAD abc123\nbranch refs/heads/misleading\n", + stderr: "", + }); + } + return realGitCore.execute(input); + }); + + const listed = yield* core.listManagedWorktrees(tmp); + + expect(listed).toEqual([]); + }), + ); }); // ── Full flow: local branch checkout ── diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index a291d264..a820a52e 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -24,6 +24,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { randomUUID } from "node:crypto"; import * as nodeFs from "node:fs/promises"; import * as nodePath from "node:path"; +import type { ServerManagedWorktree } from "@jcode/contracts"; import { GitCheckoutDirtyWorktreeError, GitCommandError } from "../Errors.ts"; import { @@ -82,6 +83,12 @@ interface ExecuteGitOptions { progress?: ExecuteGitProgress | undefined; } +interface WorktreePorcelainEntry { + readonly path: string; + readonly branch: string | null; + readonly prunable: boolean; +} + type WorkingTreeFileStat = { path: string; insertions: number; deletions: number }; type WorkingTreeStatSummary = { @@ -411,6 +418,51 @@ function parseNonEmptyLineList(input: string): string[] { .filter((line) => line.length > 0); } +function parseWorktreeListPorcelain(stdout: string): WorktreePorcelainEntry[] { + const entries: WorktreePorcelainEntry[] = []; + let currentPath: string | null = null; + let currentBranch: string | null = null; + let currentPrunable = false; + + const flush = () => { + const pathValue = currentPath?.trim() ?? ""; + if (pathValue.length > 0) { + entries.push({ + path: pathValue, + branch: currentBranch, + prunable: currentPrunable, + }); + } + currentPath = null; + currentBranch = null; + currentPrunable = false; + }; + + for (const rawLine of stdout.split(/\r?\n/g)) { + const line = rawLine.trim(); + if (line.length === 0) { + flush(); + continue; + } + if (line.startsWith("worktree ")) { + flush(); + const pathValue = line.slice("worktree ".length).trim(); + currentPath = pathValue.length > 0 ? pathValue : null; + continue; + } + if (line.startsWith("branch refs/heads/")) { + const branch = line.slice("branch refs/heads/".length).trim(); + currentBranch = branch.length > 0 ? branch : null; + continue; + } + if (line === "prunable") { + currentPrunable = true; + } + } + flush(); + return entries; +} + type StashEntry = { ref: string; hash: string; @@ -2252,6 +2304,161 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" ); }); + const isUnderManagedWorktreeRoot = (candidatePath: string): boolean => { + const normalizedCandidate = path.resolve(candidatePath); + const normalizedRoot = path.resolve(worktreesDir); + return normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`); + }; + + const resolveCleanupBaseRefs = ( + cwd: string, + ): Effect.Effect => + Effect.gen(function* () { + const branchResult = yield* executeGit( + "GitCore.listManagedWorktrees.currentBranch", + cwd, + ["branch", "--show-current"], + { allowNonZeroExit: true, timeoutMs: 5_000 }, + ); + const currentBranch = branchResult.stdout.trim(); + const candidates = ["main", "master", currentBranch].filter((value) => value.length > 0); + const refs = yield* Effect.forEach(candidates, (candidate) => + executeGit( + "GitCore.listManagedWorktrees.verifyBaseRef", + cwd, + ["rev-parse", "--verify", `${candidate}^{commit}`], + { allowNonZeroExit: true, timeoutMs: 5_000 }, + ).pipe(Effect.map((result) => (result.code === 0 ? candidate : null))), + ); + return Array.from(new Set(refs.filter((ref): ref is string => ref !== null))); + }); + + const readWorktreeSafety = ( + worktreePath: string, + baseRefs: readonly string[], + ): Effect.Effect< + Pick< + ServerManagedWorktree, + "cleanupExplanation" | "cleanupStatus" | "hasUnmergedCommits" | "isDirty" + >, + GitCommandError + > => + Effect.gen(function* () { + const exists = yield* fileSystem + .exists(worktreePath) + .pipe( + Effect.mapError((cause) => + createGitCommandError( + "GitCore.listManagedWorktrees.exists", + worktreePath, + ["worktree", "list", "--porcelain"], + "failed to inspect worktree path.", + cause, + ), + ), + ); + if (!exists) { + return { + isDirty: false, + hasUnmergedCommits: false, + cleanupStatus: "stale_missing" as const, + cleanupExplanation: + "The worktree path is missing on disk; prune stale git metadata first.", + }; + } + + const statusResult = yield* executeGit( + "GitCore.listManagedWorktrees.status", + worktreePath, + ["status", "--porcelain"], + { allowNonZeroExit: true, timeoutMs: 10_000 }, + ); + if (statusResult.code !== 0) { + return { + isDirty: false, + hasUnmergedCommits: false, + cleanupStatus: "blocked_unknown" as const, + cleanupExplanation: "Git status could not be verified for this worktree.", + }; + } + const isDirty = statusResult.stdout.trim().length > 0; + if (isDirty) { + return { + isDirty: true, + hasUnmergedCommits: false, + cleanupStatus: "blocked_dirty" as const, + cleanupExplanation: "Uncommitted changes must be committed or stashed first.", + }; + } + + const ancestryResults = yield* Effect.forEach(baseRefs, (baseRef) => + executeGit( + "GitCore.listManagedWorktrees.mergeBase", + worktreePath, + ["merge-base", "--is-ancestor", "HEAD", baseRef], + { allowNonZeroExit: true, timeoutMs: 10_000 }, + ).pipe(Effect.map((result) => result.code === 0)), + ); + const isMerged = ancestryResults.some((result) => result); + if (!isMerged) { + return { + isDirty: false, + hasUnmergedCommits: true, + cleanupStatus: "blocked_unmerged" as const, + cleanupExplanation: + "The worktree contains commits that are not merged into the repository base branch.", + }; + } + + return { + isDirty: false, + hasUnmergedCommits: false, + cleanupStatus: "safe" as const, + cleanupExplanation: "No dirty files or unmerged commits were detected.", + }; + }); + + const listManagedWorktrees: GitCoreShape["listManagedWorktrees"] = (cwd) => + Effect.gen(function* () { + const result = yield* executeGit( + "GitCore.listManagedWorktrees", + cwd, + ["worktree", "list", "--porcelain"], + { allowNonZeroExit: true, timeoutMs: 10_000 }, + ); + if (result.code !== 0) { + return []; + } + + const workspaceRoot = path.resolve(cwd); + const parsed = parseWorktreeListPorcelain(result.stdout).filter((entry) => { + const resolvedPath = path.resolve(entry.path); + return resolvedPath !== workspaceRoot && isUnderManagedWorktreeRoot(resolvedPath); + }); + const baseRefs = yield* resolveCleanupBaseRefs(cwd); + + return yield* Effect.forEach( + parsed, + (entry) => + readWorktreeSafety(entry.path, baseRefs).pipe( + Effect.map( + (safety): ServerManagedWorktree => ({ + path: entry.path, + workspaceRoot: cwd, + branch: entry.branch, + isDirty: safety.isDirty, + hasUnmergedCommits: safety.hasUnmergedCommits, + cleanupStatus: entry.prunable ? "stale_missing" : safety.cleanupStatus, + cleanupExplanation: entry.prunable + ? "Git marks this worktree as prunable; prune stale git metadata before removing it." + : safety.cleanupExplanation, + }), + ), + ), + { concurrency: 4 }, + ); + }); + const renameBranch: GitCoreShape["renameBranch"] = (input) => Effect.gen(function* () { if (input.oldBranch === input.newBranch) { @@ -2633,6 +2840,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" fetchRemoteBranch, setBranchUpstream, removeWorktree, + listManagedWorktrees, renameBranch, createBranch, publishBranch, diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index a61209e7..b898a2ca 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -27,6 +27,7 @@ import type { GitStashInfoResult, GitStatusInput, GitStatusResult, + ServerManagedWorktree, } from "@jcode/contracts"; import type { GitCheckoutDirtyWorktreeError, GitCommandError } from "../Errors.ts"; @@ -284,6 +285,10 @@ export interface GitCoreShape { */ readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect; + readonly listManagedWorktrees: ( + cwd: string, + ) => Effect.Effect; + /** * Rename an existing local branch. */ From 1c3e8054053791384f254ddb313b6687c81659e4 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:30 -0400 Subject: [PATCH 13/27] feat(web): add worktree cleanup controls --- apps/web/src/components/GitActionsControl.tsx | 28 +++++++- apps/web/src/worktreeCleanup.test.ts | 71 ++++++++++++++++++- apps/web/src/worktreeCleanup.ts | 45 ++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index ba0da8dc..e1e7a4fa 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -76,6 +76,10 @@ import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; import { createThreadSelector } from "~/storeSelectors"; import { useStore } from "~/store"; +import { + VcsCommandCenterStatusPanel, + buildVcsCommandCenterStatusModel, +} from "./VcsCommandCenterStatusPanel"; interface GitActionsControlProps { gitCwd: string | null; @@ -418,6 +422,27 @@ export default function GitActionsControl({ }, [activeThread?.createBranchFlowCompleted, activeThread?.worktreePath, gitStatusForActions]); const currentBranchName = gitStatusForActions?.branch ?? currentBranch ?? activeThread?.branch ?? null; + const activeProviderName = + activeThread?.session?.provider ?? activeThread?.modelSelection.provider ?? null; + const vcsCommandCenterStatusModel = useMemo( + () => + buildVcsCommandCenterStatusModel({ + gitCwd, + gitStatus: gitStatusForActions, + branchList, + gitStatusErrorMessage: gitStatusError?.message ?? null, + providerName: activeProviderName, + isGitStatusOutOfSync, + }), + [ + activeProviderName, + branchList, + gitCwd, + gitStatusError?.message, + gitStatusForActions, + isGitStatusOutOfSync, + ], + ); const existingBranchNames = useMemo( () => (branchList?.branches ?? []).map((branch) => branch.name), [branchList?.branches], @@ -1291,8 +1316,9 @@ export default function GitActionsControl({ + Git actions {gitPickerMenuItems.map((item) => { diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index eff41f26..050d89cd 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -2,7 +2,11 @@ import { ProjectId, ThreadId } from "@jcode/contracts"; import { describe, expect, it } from "vitest"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; -import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; +import { + classifyManagedWorktreeCleanupChoices, + formatWorktreePathForDisplay, + getOrphanedWorktreePathForThread, +} from "./worktreeCleanup"; function makeThread(overrides: Partial = {}): Thread { return { @@ -111,3 +115,68 @@ describe("formatWorktreePathForDisplay", () => { expect(result).toBe("my-worktree"); }); }); + +describe("classifyManagedWorktreeCleanupChoices", () => { + it("marks safe unlinked worktrees as orphaned cleanup choices", () => { + const choices = classifyManagedWorktreeCleanupChoices({ + worktrees: [ + { + path: "/tmp/repo/worktrees/feature-a", + workspaceRoot: "/tmp/repo", + branch: "feature-a", + isDirty: false, + hasUnmergedCommits: false, + cleanupStatus: "safe", + cleanupExplanation: "Safe to remove.", + }, + ], + threads: [], + }); + + expect(choices[0]?.association).toBe("orphaned"); + expect(choices[0]?.canRemove).toBe(true); + }); + + it("blocks worktrees linked to active threads", () => { + const choices = classifyManagedWorktreeCleanupChoices({ + worktrees: [ + { + path: "/tmp/repo/worktrees/feature-a", + workspaceRoot: "/tmp/repo", + branch: "feature-a", + isDirty: false, + hasUnmergedCommits: false, + cleanupStatus: "safe", + cleanupExplanation: "Safe to remove.", + }, + ], + threads: [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-a" })], + }); + + expect(choices[0]?.association).toBe("active"); + expect(choices[0]?.canRemove).toBe(false); + expect(choices[0]?.blockedReason).toContain("active conversation"); + }); + + it("carries server safety explanations for dirty worktrees", () => { + const choices = classifyManagedWorktreeCleanupChoices({ + worktrees: [ + { + path: "/tmp/repo/worktrees/feature-a", + workspaceRoot: "/tmp/repo", + branch: "feature-a", + isDirty: true, + hasUnmergedCommits: false, + cleanupStatus: "blocked_dirty", + cleanupExplanation: "Uncommitted changes must be committed or stashed first.", + }, + ], + threads: [], + }); + + expect(choices[0]?.canRemove).toBe(false); + expect(choices[0]?.blockedReason).toBe( + "Uncommitted changes must be committed or stashed first.", + ); + }); +}); diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 8c09e89a..b3e0c96d 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -1,5 +1,15 @@ +import type { ServerManagedWorktree } from "@jcode/contracts"; import type { Thread } from "./types"; +export type ManagedWorktreeAssociation = "active" | "archived" | "orphaned"; + +export type ManagedWorktreeCleanupChoice = ServerManagedWorktree & { + readonly linkedThreads: readonly Thread[]; + readonly association: ManagedWorktreeAssociation; + readonly canRemove: boolean; + readonly blockedReason: string | null; +}; + function normalizeWorktreePath(path: string | null): string | null { const trimmed = path?.trim(); if (!trimmed) { @@ -43,3 +53,38 @@ export function formatWorktreePathForDisplay(worktreePath: string): string { const lastPart = parts[parts.length - 1]?.trim() ?? ""; return lastPart.length > 0 ? lastPart : trimmed; } + +export function classifyManagedWorktreeCleanupChoices(input: { + readonly worktrees: readonly ServerManagedWorktree[]; + readonly threads: readonly Thread[]; +}): readonly ManagedWorktreeCleanupChoice[] { + return input.worktrees.map((worktree) => { + const linkedThreads = input.threads.filter((thread) => { + const candidatePaths = [ + normalizeWorktreePath(thread.worktreePath), + normalizeWorktreePath(thread.associatedWorktreePath ?? null), + ]; + return candidatePaths.includes(worktree.path); + }); + const hasActiveThread = linkedThreads.some((thread) => (thread.archivedAt ?? null) === null); + const association: ManagedWorktreeAssociation = hasActiveThread + ? "active" + : linkedThreads.length > 0 + ? "archived" + : "orphaned"; + const serverBlockedReason = + worktree.cleanupStatus === "safe" ? null : worktree.cleanupExplanation; + const linkedThreadBlockedReason = hasActiveThread + ? "This worktree is linked to an active conversation. Archive or delete that conversation first." + : null; + const blockedReason = serverBlockedReason ?? linkedThreadBlockedReason; + + return { + ...worktree, + linkedThreads, + association, + canRemove: blockedReason === null, + blockedReason, + }; + }); +} From 98a476da12bc6791b07354d589930a1fa8724e75 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:30 -0400 Subject: [PATCH 14/27] feat(contracts): add vcs status rpc shape --- packages/contracts/src/server.test.ts | 24 ++++++++++++++++++++++++ packages/contracts/src/server.ts | 11 +++++++++++ 2 files changed, 35 insertions(+) create mode 100644 packages/contracts/src/server.test.ts diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts new file mode 100644 index 00000000..14a23358 --- /dev/null +++ b/packages/contracts/src/server.test.ts @@ -0,0 +1,24 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { ServerListWorktreesResult } from "./server"; + +describe("ServerListWorktreesResult", () => { + it("decodes managed worktree cleanup safety metadata", () => { + const decoded = Schema.decodeUnknownSync(ServerListWorktreesResult)({ + worktrees: [ + { + path: "/tmp/jcode/worktrees/project/feature-a", + workspaceRoot: "/tmp/project", + branch: "feature-a", + isDirty: false, + hasUnmergedCommits: false, + cleanupStatus: "safe", + cleanupExplanation: "Safe to remove.", + }, + ], + }); + + expect(decoded.worktrees[0]?.cleanupStatus).toBe("safe"); + }); +}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 0a06a5aa..56f3cb1b 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -97,6 +97,17 @@ export type ServerConfig = typeof ServerConfig.Type; export const ServerManagedWorktree = Schema.Struct({ path: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + branch: Schema.NullOr(TrimmedNonEmptyString), + isDirty: Schema.Boolean, + hasUnmergedCommits: Schema.Boolean, + cleanupStatus: Schema.Literals([ + "safe", + "blocked_dirty", + "blocked_unmerged", + "stale_missing", + "blocked_unknown", + ]), + cleanupExplanation: TrimmedNonEmptyString, }); export type ServerManagedWorktree = typeof ServerManagedWorktree.Type; From 31f935379ddfd54cf0db713b8749520d3f01ca1e Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:30 -0400 Subject: [PATCH 15/27] feat(server): expose vcs status over websocket --- apps/server/src/wsRpc.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/server/src/wsRpc.ts b/apps/server/src/wsRpc.ts index 3b1d8eb5..43308978 100644 --- a/apps/server/src/wsRpc.ts +++ b/apps/server/src/wsRpc.ts @@ -25,6 +25,7 @@ import { type ServerConfigStreamEvent, type ServerDiagnosticsResult, type ServerLifecycleStreamEvent, + type ServerManagedWorktree, ServerVoiceTranscriptionErrorDetail, type ServerVoiceTranscriptionErrorDetail as ServerVoiceTranscriptionErrorDetailType, } from "@jcode/contracts"; @@ -777,6 +778,7 @@ export const makeWsRpcLayer = () => effect: Effect.Effect, ): Effect.Effect => requireOwnerSession.pipe(Effect.flatMap(() => effect)); + const noManagedWorktrees: readonly ServerManagedWorktree[] = []; return WsRpcGroup.of({ [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => @@ -1055,7 +1057,25 @@ export const makeWsRpcLayer = () => "Failed to refresh providers", ), [WS_METHODS.serverUpdateProvider]: (input) => providerHealth.updateProvider(input), - [WS_METHODS.serverListWorktrees]: () => Effect.succeed({ worktrees: [] }), + [WS_METHODS.serverListWorktrees]: () => + withScope( + "thread:read", + rpcEffect( + Effect.gen(function* () { + const snapshot = yield* projectionReadModelQuery.getShellSnapshot(); + const worktreeGroups = yield* Effect.forEach( + snapshot.projects, + (project) => + git + .listManagedWorktrees(project.workspaceRoot) + .pipe(Effect.orElseSucceed(() => noManagedWorktrees)), + { concurrency: 4 }, + ); + return { worktrees: worktreeGroups.flat() }; + }), + "Failed to list managed worktrees", + ), + ), [WS_METHODS.serverGetProviderUsageSnapshot]: (input) => rpcEffect(getProviderUsageSnapshot(input), "Failed to load provider usage"), [WS_METHODS.serverGetDiagnostics]: () => From da7cf06cfb8cba503b2d2c539a8617038264b835 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:55 -0400 Subject: [PATCH 16/27] feat(web): add vcs command center panel --- .../VcsCommandCenterStatusPanel.logic.ts | 178 ++++++++++++++++++ .../VcsCommandCenterStatusPanel.test.tsx | 109 +++++++++++ .../VcsCommandCenterStatusPanel.tsx | 103 ++++++++++ 3 files changed, 390 insertions(+) create mode 100644 apps/web/src/components/VcsCommandCenterStatusPanel.logic.ts create mode 100644 apps/web/src/components/VcsCommandCenterStatusPanel.test.tsx create mode 100644 apps/web/src/components/VcsCommandCenterStatusPanel.tsx diff --git a/apps/web/src/components/VcsCommandCenterStatusPanel.logic.ts b/apps/web/src/components/VcsCommandCenterStatusPanel.logic.ts new file mode 100644 index 00000000..659d5c51 --- /dev/null +++ b/apps/web/src/components/VcsCommandCenterStatusPanel.logic.ts @@ -0,0 +1,178 @@ +import type { GitListBranchesResult, GitStatusResult } from "@jcode/contracts"; + +export type VcsAvailabilityKind = "available" | "refreshing" | "unavailable"; +export type VcsStatusTone = "default" | "success" | "warning" | "error" | "muted"; + +export type VcsStatusField = { + readonly label: string; + readonly value: string; + readonly detail: string | null; + readonly tone: VcsStatusTone; +}; + +export type VcsCommandCenterStatusModel = { + readonly availability: { + readonly kind: VcsAvailabilityKind; + readonly label: string; + readonly reason: string; + }; + readonly branch: VcsStatusField; + readonly worktree: VcsStatusField; + readonly sync: VcsStatusField; + readonly pullRequest: VcsStatusField; + readonly provider: VcsStatusField; +}; + +export type VcsCommandCenterStatusInput = { + readonly gitCwd: string | null; + readonly gitStatus: GitStatusResult | null; + readonly branchList: GitListBranchesResult | null; + readonly gitStatusErrorMessage: string | null; + readonly providerName: string | null; + readonly isGitStatusOutOfSync: boolean; +}; + +function pluralize(count: number, singular: string): string { + return `${count.toLocaleString()} ${singular}${count === 1 ? "" : "s"}`; +} + +function buildAvailability(input: VcsCommandCenterStatusInput) { + if (!input.gitCwd) { + return { + kind: "unavailable", + label: "Source control unavailable", + reason: "No repository path is selected.", + } as const; + } + if (input.gitStatusErrorMessage) { + return { + kind: "unavailable", + label: "Source control unavailable", + reason: `Git status failed: ${input.gitStatusErrorMessage}`, + } as const; + } + if (input.branchList && !input.branchList.isRepo) { + return { + kind: "unavailable", + label: "Source control unavailable", + reason: "Selected path is not a Git repository.", + } as const; + } + if (input.isGitStatusOutOfSync) { + return { + kind: "refreshing", + label: "Refreshing source control", + reason: "Thread branch is refreshing to match repository state.", + } as const; + } + if (!input.gitStatus) { + return { + kind: "unavailable", + label: "Source control unavailable", + reason: "Git status is still loading.", + } as const; + } + return { + kind: "available", + label: "Source control available", + reason: "Git status is read-only.", + } as const; +} + +function buildBranchField(input: VcsCommandCenterStatusInput): VcsStatusField { + const currentBranch = input.branchList?.branches.find((branch) => branch.current)?.name ?? null; + const branch = input.gitStatus?.branch ?? currentBranch; + if (branch) return { label: "Branch", value: branch, detail: null, tone: "default" }; + if (input.gitStatus?.branch === null) { + return { label: "Branch", value: "Detached HEAD", detail: null, tone: "warning" }; + } + return { label: "Branch", value: "Unknown", detail: null, tone: "muted" }; +} + +function buildWorktreeField(gitStatus: GitStatusResult | null): VcsStatusField { + if (!gitStatus) return { label: "Worktree", value: "Unknown", detail: null, tone: "muted" }; + if (!gitStatus.hasWorkingTreeChanges) { + return { label: "Worktree", value: "Clean", detail: "+0 / -0", tone: "success" }; + } + return { + label: "Worktree", + value: `${pluralize(gitStatus.workingTree.files.length, "file")} changed`, + detail: `+${gitStatus.workingTree.insertions.toLocaleString()} / -${gitStatus.workingTree.deletions.toLocaleString()}`, + tone: "warning", + }; +} + +function buildSyncField(gitStatus: GitStatusResult | null): VcsStatusField { + if (!gitStatus) return { label: "Sync", value: "Unknown", detail: null, tone: "muted" }; + if (!gitStatus.hasUpstream) { + return { label: "Sync", value: "No upstream", detail: null, tone: "warning" }; + } + const detail = gitStatus.upstreamBranch; + if (gitStatus.aheadCount > 0 && gitStatus.behindCount > 0) { + return { + label: "Sync", + value: `Diverged: ${gitStatus.aheadCount.toLocaleString()} ahead, ${gitStatus.behindCount.toLocaleString()} behind`, + detail, + tone: "warning", + }; + } + if (gitStatus.aheadCount > 0) { + return { + label: "Sync", + value: `${gitStatus.aheadCount.toLocaleString()} ahead`, + detail, + tone: "warning", + }; + } + if (gitStatus.behindCount > 0) { + return { + label: "Sync", + value: `${gitStatus.behindCount.toLocaleString()} behind`, + detail, + tone: "warning", + }; + } + return { label: "Sync", value: "Up to date", detail, tone: "success" }; +} + +function buildPullRequestField(gitStatus: GitStatusResult | null): VcsStatusField { + const pr = gitStatus?.pr ?? null; + if (!gitStatus) return { label: "Pull request", value: "Unknown", detail: null, tone: "muted" }; + if (!pr) return { label: "Pull request", value: "No linked PR", detail: null, tone: "muted" }; + return { + label: "Pull request", + value: `PR #${pr.number.toLocaleString()} ${pr.state}`, + detail: pr.title, + tone: pr.state === "open" ? "success" : "muted", + }; +} + +function buildProviderField(providerName: string | null): VcsStatusField { + if (!providerName) { + return { + label: "Provider", + value: "No provider selected", + detail: "Provider unavailable: no active provider is attached to this thread.", + tone: "warning", + }; + } + return { + label: "Provider", + value: providerName, + detail: "Provider context is attached to this thread.", + tone: "success", + }; +} + +export function buildVcsCommandCenterStatusModel( + input: VcsCommandCenterStatusInput, +): VcsCommandCenterStatusModel { + return { + availability: buildAvailability(input), + branch: buildBranchField(input), + worktree: buildWorktreeField(input.gitStatus), + sync: buildSyncField(input.gitStatus), + pullRequest: buildPullRequestField(input.gitStatus), + provider: buildProviderField(input.providerName), + }; +} diff --git a/apps/web/src/components/VcsCommandCenterStatusPanel.test.tsx b/apps/web/src/components/VcsCommandCenterStatusPanel.test.tsx new file mode 100644 index 00000000..6a6d2866 --- /dev/null +++ b/apps/web/src/components/VcsCommandCenterStatusPanel.test.tsx @@ -0,0 +1,109 @@ +import type { GitListBranchesResult, GitStatusResult } from "@jcode/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { + VcsCommandCenterStatusPanel, + buildVcsCommandCenterStatusModel, +} from "./VcsCommandCenterStatusPanel"; + +function gitStatus(overrides: Partial = {}): GitStatusResult { + return { + branch: "feature/read-only-vcs", + hasWorkingTreeChanges: true, + workingTree: { + files: [ + { path: "src/changed.ts", insertions: 12, deletions: 3 }, + { path: "src/other.ts", insertions: 2, deletions: 0 }, + ], + insertions: 14, + deletions: 3, + }, + hasUpstream: true, + upstreamBranch: "origin/feature/read-only-vcs", + aheadCount: 2, + behindCount: 1, + pr: { + number: 42, + title: "Read-only VCS status", + url: "https://github.com/Jay1/jcode/pull/42", + baseBranch: "main", + headBranch: "feature/read-only-vcs", + state: "open", + }, + ...overrides, + }; +} + +function branches(overrides: Partial = {}): GitListBranchesResult { + return { + isRepo: true, + hasOriginRemote: true, + branches: [ + { + name: "feature/read-only-vcs", + current: true, + isDefault: false, + worktreePath: null, + }, + { + name: "main", + current: false, + isDefault: true, + worktreePath: null, + }, + ], + ...overrides, + }; +} + +describe("VcsCommandCenterStatusPanel", () => { + it("renders branch, dirty status, sync, PR, provider, and read-only state", () => { + // Given: normalized git status, branch, PR, provider, and repository availability data. + const model = buildVcsCommandCenterStatusModel({ + gitCwd: "/repo", + gitStatus: gitStatus(), + branchList: branches(), + gitStatusErrorMessage: null, + providerName: "codex", + isGitStatusOutOfSync: false, + }); + + // When: the command-center status surface is rendered. + const html = renderToStaticMarkup(); + + // Then: it is an informational read-only status surface, not a git action surface. + expect(html).toContain("Version control command center"); + expect(html).toContain("Read-only status"); + expect(html).toContain("feature/read-only-vcs"); + expect(html).toContain("2 files changed"); + expect(html).toContain("+14 / -3"); + expect(html).toContain("Diverged: 2 ahead, 1 behind"); + expect(html).toContain("PR #42 open"); + expect(html).toContain("codex"); + expect(html).not.toContain(" { + // Given: the source-control provider cannot deliver normalized git status. + const model = buildVcsCommandCenterStatusModel({ + gitCwd: "/repo", + gitStatus: null, + branchList: branches(), + gitStatusErrorMessage: "fatal: not a git repository", + providerName: null, + isGitStatusOutOfSync: false, + }); + + // When: the command-center status surface is rendered. + const html = renderToStaticMarkup(); + + // Then: the unavailable state explains the disabled status instead of exposing controls. + expect(html).toContain("Source control unavailable"); + expect(html).toContain("Git status failed: fatal: not a git repository"); + expect(html).toContain("No provider selected"); + expect(html).toContain("Provider unavailable: no active provider is attached to this thread."); + expect(html).not.toContain(" + {field.label} + + + {field.value} + + {field.detail ? ( + + {field.detail} + + ) : null} + +
+ ); +} + +export function VcsCommandCenterStatusPanel({ + model, +}: { + readonly model: VcsCommandCenterStatusModel; +}) { + const fields = [ + model.branch, + model.worktree, + model.sync, + model.pullRequest, + model.provider, + ] as const; + + return ( +
+
+
+

+ Version control command center +

+

Read-only status

+
+ + {model.availability.label} + +
+

+ {model.availability.reason} +

+
+ {fields.map((field) => ( + + ))} +
+
+ ); +} From 62304717c2d770bba8185c8dc655e4a5b2eab664 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:55 -0400 Subject: [PATCH 17/27] docs: record copilot provider entry path --- docs/adr/0009-copilot-provider-entry-path.md | 67 ++++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 68 insertions(+) create mode 100644 docs/adr/0009-copilot-provider-entry-path.md diff --git a/docs/adr/0009-copilot-provider-entry-path.md b/docs/adr/0009-copilot-provider-entry-path.md new file mode 100644 index 00000000..b0feb5e5 --- /dev/null +++ b/docs/adr/0009-copilot-provider-entry-path.md @@ -0,0 +1,67 @@ +# ADR 0009: Copilot Provider Entry Path + +| Status | Proposed | +| ------ | ---------- | +| Date | 2026-06-30 | + +## Context + +Roadmap task 11 covers GitHub Copilot support from T3Code PR #3076. The preflight narrowed the first JCode slice to an ADR/spike that decides whether Copilot should become a first-class JCode Provider or enter through an OpenCode-backed/model-source integration. + +JCode's provider boundary is intentionally narrow: provider-specific behavior belongs behind server provider/runtime boundaries, and shared UI/persistence should consume canonical contracts instead of raw provider protocol payloads. The provider runtime architecture also requires adapters to emit canonical `ProviderRuntimeEvent` streams and preserve turn lifecycle semantics. + +The inspected T3Code patch implements Copilot as a full first-class provider: it adds a `copilot` driver kind, SDK dependency, provider settings, model defaults, UI entries, a large `CopilotAdapter`, Copilot text generation, `copilot.sdk.event` raw event source, auth/status probing, and registry tests. That is useful as a future reference, but importing it as-is would add real SDK auth/network/runtime behavior before JCode has proven the provider entry path. + +## Decision + +Start Copilot support as an **OpenCode-backed model-source integration**, not as a first-class JCode Provider. + +The first implementation path is: + +1. Keep the runtime provider as `opencode` until Copilot needs independent session lifecycle semantics that OpenCode cannot host. +2. Represent GitHub Copilot as a model-source candidate with offline status and model-discovery semantics. +3. Treat missing Copilot credentials as a typed status condition (`authStatus: "unauthenticated"`) with no secrets, account UI, token storage, or network calls in the spike. +4. Require canonical provider-runtime event mapping before any future Copilot turn execution. + +The task-11 spike proof lives in `apps/server/src/provider/copilotProviderSpike.ts`. It intentionally returns an OpenCode-hosted registry entry and an unauthenticated offline status snapshot with an empty model list. This proves the data surface without adding `copilot` to `ProviderKind`, server settings, UI provider pickers, or runtime layers. + +## Tradeoffs + +**Why not first-class provider now:** + +- A first-class provider requires shared contract expansion, adapter service wiring, settings, runtime health, model selection, text generation, and UI availability decisions at the same time. +- T3Code's adapter is large and SDK-specific; copying it would bypass JCode's canonical event and provider-status boundaries. +- Real Copilot auth and SDK startup are external integrations that need a separate secrets and failure-mode review. + +**Why OpenCode-backed/model-source first:** + +- It preserves JCode's current OpenCode-centered product boundary. +- It lets the server prove auth-missing and model-discovery semantics before runtime streaming. +- It keeps Copilot-specific details out of generic UI while leaving a typed path for future provider expansion. + +**Cost:** + +- Copilot cannot execute turns as an independent JCode provider in this slice. +- If OpenCode cannot expose Copilot as a safe model source, a later ADR or ADR update must promote Copilot to a first-class provider with explicit contracts. + +## Non-Goals + +- No real GitHub Copilot SDK dependency. +- No Copilot tokens, login flows, credential storage, or account settings UI. +- No network calls, runtime streaming, text-generation runtime, or session lifecycle. +- No Copilot-specific behavior in shared chat, settings, timeline, composer, or provider picker UI. +- No DPCode inspection for this decision. + +## Follow-Up Slices + +1. **OpenCode capability probe:** Determine whether a configured OpenCode runtime can report Copilot-backed models without exposing secrets. +2. **Offline auth/status adapter:** Add a server-only probe that reports authenticated, unauthenticated, unsupported, or unreachable states without storing Copilot secrets. +3. **Model-source discovery:** Surface Copilot-backed models through existing provider discovery contracts, still hosted by `opencode`. +4. **Runtime event mapping spike:** If Copilot needs direct turn execution, map a minimal fake Copilot event stream into canonical `ProviderRuntimeEvent` before adding SDK calls. +5. **First-class provider review:** Promote Copilot to `ProviderKind: "copilot"` only if the model-source path cannot provide required behavior or if Copilot requires independent sessions, permissions, or usage accounting. + +## Consequences + +- Roadmap task 11 is complete as an ADR/spike, not a shipped Copilot provider. +- Future Copilot work starts from provider-status and model-source proof instead of UI/provider-kind expansion. +- T3Code #3076 remains a reference for auth/status/model mechanics and event mapping, but JCode will adapt only bounded slices after each boundary is proven. diff --git a/docs/adr/README.md b/docs/adr/README.md index e7ae6291..d9f1880f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,6 +25,7 @@ | [0008](0008-scoped-remote-client-capability-tokens.md) | Accepted | Scoped remote client capability tokens | | [0006](0006-remote-client-runtime-ws-rpc-scope-wiring.md) | Decided | Remote Client Runtime WS RPC Scope Wiring | | [0007](0007-parallel-windows-wsl-backend-routing.md) | Proposed | Parallel Windows + WSL Backend Routing | +| [0009](0009-copilot-provider-entry-path.md) | Proposed | Copilot provider entry path | ## When To Add An ADR From 4b5884c4e3797ce140ed15714ab63830e1f413c4 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:55 -0400 Subject: [PATCH 18/27] feat(server): add offline copilot provider spike --- .../src/provider/Layers/ProviderHealth.ts | 5 +- .../src/provider/copilotProviderSpike.test.ts | 39 +++++++++++++ .../src/provider/copilotProviderSpike.ts | 55 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/provider/copilotProviderSpike.test.ts create mode 100644 apps/server/src/provider/copilotProviderSpike.ts diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index c233ad69..6dfef06e 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -2044,12 +2044,15 @@ export const ProviderHealthLive = Layer.effect( const enrichStatuses = Effect.fn("enrichProviderStatuses")(function* ( statuses: ReadonlyArray, ) { + const settings = yield* serverSettings.getSettings; const enriched = yield* Effect.forEach( statuses, (status) => getProviderMaintenanceCapabilities(status.provider).pipe( Effect.flatMap((capabilities) => - enrichProviderStatusWithVersionAdvisory(status, capabilities), + enrichProviderStatusWithVersionAdvisory(status, capabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }), ), Effect.catch(() => Effect.succeed({ diff --git a/apps/server/src/provider/copilotProviderSpike.test.ts b/apps/server/src/provider/copilotProviderSpike.test.ts new file mode 100644 index 00000000..66c1788a --- /dev/null +++ b/apps/server/src/provider/copilotProviderSpike.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; + +import { it } from "@effect/vitest"; + +import { + COPILOT_MODEL_SOURCE_ID, + makeCopilotModelSourceRegistryEntry, + makeCopilotOfflineAuthMissingSnapshot, +} from "./copilotProviderSpike"; + +it("frames Copilot as an OpenCode-backed model source candidate", () => { + const entry = makeCopilotModelSourceRegistryEntry(); + + assert.equal(entry.sourceId, COPILOT_MODEL_SOURCE_ID); + assert.equal(entry.hostProvider, "opencode"); + assert.equal(entry.runtimeProviderKind, null); + assert.equal(entry.firstClassProvider, false); + assert.deepEqual(entry.requiredFollowUps, [ + "OpenCode runtime profile or model-source capability detection", + "Offline auth/status probe with no stored Copilot secret material", + "Canonical provider-runtime event mapping before turn execution", + ]); +}); + +it("returns typed missing-auth status and no fake models without network or secrets", () => { + const snapshot = makeCopilotOfflineAuthMissingSnapshot({ + checkedAt: "2026-06-30T00:00:00.000Z", + }); + + assert.equal(snapshot.status.provider, "opencode"); + assert.equal(snapshot.status.status, "warning"); + assert.equal(snapshot.status.available, false); + assert.equal(snapshot.status.authStatus, "unauthenticated"); + assert.equal(snapshot.status.authType, "github-copilot"); + assert.match(snapshot.status.message ?? "", /Copilot/i); + assert.deepEqual(snapshot.models, []); + assert.equal(snapshot.modelsSource, "auth-missing"); + assert.equal(snapshot.networkAccess, "disabled"); +}); diff --git a/apps/server/src/provider/copilotProviderSpike.ts b/apps/server/src/provider/copilotProviderSpike.ts new file mode 100644 index 00000000..8f602750 --- /dev/null +++ b/apps/server/src/provider/copilotProviderSpike.ts @@ -0,0 +1,55 @@ +import type { ProviderListModelsResult, ServerProviderStatus } from "@jcode/contracts"; + +export const COPILOT_MODEL_SOURCE_ID = "github-copilot" as const; + +export interface CopilotModelSourceRegistryEntry { + readonly sourceId: typeof COPILOT_MODEL_SOURCE_ID; + readonly label: "GitHub Copilot"; + readonly hostProvider: "opencode"; + readonly runtimeProviderKind: null; + readonly firstClassProvider: false; + readonly requiredFollowUps: readonly [string, string, string]; +} + +export interface CopilotOfflineAuthMissingSnapshot { + readonly status: ServerProviderStatus; + readonly models: ProviderListModelsResult["models"]; + readonly modelsSource: "auth-missing"; + readonly networkAccess: "disabled"; +} + +export function makeCopilotModelSourceRegistryEntry(): CopilotModelSourceRegistryEntry { + return { + sourceId: COPILOT_MODEL_SOURCE_ID, + label: "GitHub Copilot", + hostProvider: "opencode", + runtimeProviderKind: null, + firstClassProvider: false, + requiredFollowUps: [ + "OpenCode runtime profile or model-source capability detection", + "Offline auth/status probe with no stored Copilot secret material", + "Canonical provider-runtime event mapping before turn execution", + ], + }; +} + +export function makeCopilotOfflineAuthMissingSnapshot(input: { + readonly checkedAt: string; +}): CopilotOfflineAuthMissingSnapshot { + return { + status: { + provider: "opencode", + status: "warning", + available: false, + authStatus: "unauthenticated", + authType: COPILOT_MODEL_SOURCE_ID, + authLabel: "GitHub Copilot", + checkedAt: input.checkedAt, + message: + "GitHub Copilot is not available until an offline auth probe proves an OpenCode-backed model source can use existing user credentials.", + }, + models: [], + modelsSource: "auth-missing", + networkAccess: "disabled", + }; +} From 304401610c8239104c80a1e561ce7b9da011ec40 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:55 -0400 Subject: [PATCH 19/27] feat(contracts): add backend discovery contracts --- packages/contracts/src/backend.test.ts | 72 ++++++++++++++++++++++++++ packages/contracts/src/backend.ts | 67 ++++++++++++++++++++++++ packages/contracts/src/index.ts | 1 + 3 files changed, 140 insertions(+) create mode 100644 packages/contracts/src/backend.test.ts create mode 100644 packages/contracts/src/backend.ts diff --git a/packages/contracts/src/backend.test.ts b/packages/contracts/src/backend.test.ts new file mode 100644 index 00000000..767bf105 --- /dev/null +++ b/packages/contracts/src/backend.test.ts @@ -0,0 +1,72 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; + +import { Backend, BackendRegistry, ProjectBackendResolution } from "./backend"; + +const hostBackend = { + id: "host", + kind: "local", + connection: { kind: "local" }, + descriptor: { + environmentId: "host-env", + label: "Windows host", + platform: { os: "windows", arch: "x64" }, + serverVersion: "0.0.50", + capabilities: { repositoryIdentity: true }, + }, + state: { kind: "healthy" }, +}; + +describe("Backend contracts", () => { + it("decodes host and WSL backend registry contracts", () => { + const registry = Schema.decodeUnknownSync(BackendRegistry)({ + host: hostBackend, + backends: [ + hostBackend, + { + id: "wsl-ubuntu", + kind: "wsl", + connection: { kind: "wsl-exe", distro: "Ubuntu" }, + descriptor: { + environmentId: "wsl-ubuntu-env", + label: "WSL Ubuntu", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.50", + capabilities: { repositoryIdentity: true }, + }, + state: { kind: "healthy" }, + }, + ], + }); + + expect(registry.host.id).toBe("host"); + expect(registry.backends[1]?.connection).toEqual({ kind: "wsl-exe", distro: "Ubuntu" }); + }); + + it("decodes removed project backend resolution for missing WSL distros", () => { + const removedBackend = Schema.decodeUnknownSync(Backend)({ + id: "wsl-ghost", + kind: "wsl", + connection: { kind: "wsl-exe", distro: "Ghost" }, + descriptor: { + environmentId: "wsl-ghost-env", + label: "WSL Ghost", + platform: { os: "linux", arch: "other" }, + serverVersion: "0.0.50", + capabilities: { repositoryIdentity: false }, + }, + state: { kind: "removed", reason: "WSL distro Ghost is not registered." }, + }); + + const resolution = Schema.decodeUnknownSync(ProjectBackendResolution)({ + backend: removedBackend, + workspaceRoot: "\\\\wsl$\\Ghost\\home\\jay\\project", + backendPath: "/home/jay/project", + hostPath: "\\\\wsl$\\Ghost\\home\\jay\\project", + source: "path", + }); + + expect(resolution.backend.state.kind).toBe("removed"); + expect(resolution.backendPath).toBe("/home/jay/project"); + }); +}); diff --git a/packages/contracts/src/backend.ts b/packages/contracts/src/backend.ts new file mode 100644 index 00000000..cc72d27b --- /dev/null +++ b/packages/contracts/src/backend.ts @@ -0,0 +1,67 @@ +import { Schema } from "effect"; + +import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas"; +import { ExecutionEnvironmentDescriptor } from "./environment"; + +export const BackendId = TrimmedNonEmptyString.pipe(Schema.brand("BackendId")); +export type BackendId = typeof BackendId.Type; + +export const WslDistroName = TrimmedNonEmptyString.pipe(Schema.brand("WslDistroName")); +export type WslDistroName = typeof WslDistroName.Type; + +export const BackendKind = Schema.Literals(["local", "wsl"]); +export type BackendKind = typeof BackendKind.Type; + +export const BackendLifecycleState = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("unknown") }), + Schema.Struct({ kind: Schema.Literal("probing") }), + Schema.Struct({ kind: Schema.Literal("healthy") }), + Schema.Struct({ kind: Schema.Literal("degraded"), reason: TrimmedNonEmptyString }), + Schema.Struct({ kind: Schema.Literal("removed"), reason: TrimmedNonEmptyString }), +]); +export type BackendLifecycleState = typeof BackendLifecycleState.Type; + +export const BackendConnection = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("local") }), + Schema.Struct({ kind: Schema.Literal("wsl-exe"), distro: WslDistroName }), +]); +export type BackendConnection = typeof BackendConnection.Type; + +export const Backend = Schema.Struct({ + id: BackendId, + kind: BackendKind, + connection: BackendConnection, + descriptor: ExecutionEnvironmentDescriptor, + state: BackendLifecycleState, +}); +export type Backend = typeof Backend.Type; + +export const BackendRegistry = Schema.Struct({ + host: Backend, + backends: Schema.Array(Backend), +}); +export type BackendRegistry = typeof BackendRegistry.Type; + +export const ProjectBackendResolutionSource = Schema.Literals(["path", "override", "default"]); +export type ProjectBackendResolutionSource = typeof ProjectBackendResolutionSource.Type; + +export const ProjectBackendResolution = Schema.Struct({ + backend: Backend, + workspaceRoot: TrimmedNonEmptyString, + backendPath: TrimmedNonEmptyString, + hostPath: TrimmedNonEmptyString, + source: ProjectBackendResolutionSource, +}); +export type ProjectBackendResolution = typeof ProjectBackendResolution.Type; + +export function makeBackendId(value: string): BackendId { + return BackendId.makeUnsafe(value); +} + +export function makeWslDistroName(value: string): WslDistroName { + return WslDistroName.makeUnsafe(value); +} + +export function makeBackendEnvironmentId(value: string): EnvironmentId { + return EnvironmentId.makeUnsafe(value); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index bf0f5b21..fe7fec08 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -7,6 +7,7 @@ export * from "./providerDiscovery"; export * from "./providerRuntime"; export * from "./model"; export * from "./agentMentions"; +export * from "./backend"; export * from "./ws"; export * from "./keybindings"; export * from "./server"; From 0d61a2da77ac6649472b18538c8ecc21f34c3786 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 22:26:55 -0400 Subject: [PATCH 20/27] feat(server): add backend discovery seams --- .../server/src/backend/backendPathResolver.ts | 72 ++++++ .../src/backend/backendRegistry.test.ts | 133 ++++++++++ apps/server/src/backend/backendRegistry.ts | 235 ++++++++++++++++++ .../src/components/ChatView.structure.test.ts | 25 ++ apps/web/src/components/ChatView.tsx | 33 ++- apps/web/src/index.css | 62 ++++- 6 files changed, 546 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/backend/backendPathResolver.ts create mode 100644 apps/server/src/backend/backendRegistry.test.ts create mode 100644 apps/server/src/backend/backendRegistry.ts create mode 100644 apps/web/src/components/ChatView.structure.test.ts diff --git a/apps/server/src/backend/backendPathResolver.ts b/apps/server/src/backend/backendPathResolver.ts new file mode 100644 index 00000000..baf8bb40 --- /dev/null +++ b/apps/server/src/backend/backendPathResolver.ts @@ -0,0 +1,72 @@ +import type { Backend } from "@jcode/contracts"; + +export type ResolveBackendPathInput = { + readonly backend: Backend; + readonly path: string; +}; + +export type ResolvedBackendPath = { + readonly hostPath: string; + readonly backendPath: string; +}; + +export type WslUncPath = { + readonly distro: string; + readonly linuxPath: string; +}; + +type WindowsDrivePath = { + readonly drive: string; + readonly tail: string; +}; + +export function parseWslUncPath(path: string): WslUncPath | null { + const normalized = path.replace(/\//g, "\\"); + const prefix = "\\\\wsl$\\"; + if (!normalized.toLowerCase().startsWith(prefix)) return null; + const remainder = normalized.slice(prefix.length); + const firstSeparator = remainder.indexOf("\\"); + if (firstSeparator <= 0) return null; + const distro = remainder.slice(0, firstSeparator); + const tail = remainder.slice(firstSeparator + 1).replace(/\\/g, "/"); + return { distro, linuxPath: `/${tail}`.replace(/\/+/g, "/") }; +} + +function parseWindowsDrivePath(path: string): WindowsDrivePath | null { + const match = /^([A-Za-z]):[\\/]*(.*)$/.exec(path); + if (match === null) return null; + const drive = match[1]; + const tail = match[2]; + if (drive === undefined || tail === undefined) return null; + return { drive: drive.toLowerCase(), tail: tail.replace(/\\/g, "/") }; +} + +function hostPathFromMntPath(path: string): string | null { + const match = /^\/mnt\/([A-Za-z])(?:\/(.*))?$/.exec(path); + if (match === null) return null; + const drive = match[1]; + const tail = match[2] ?? ""; + if (drive === undefined) return null; + const suffix = tail.length > 0 ? `\\${tail.replace(/\//g, "\\")}` : "\\"; + return `${drive.toUpperCase()}:${suffix}`; +} + +function backendPathForWsl(path: string): string { + const unc = parseWslUncPath(path); + if (unc !== null) return unc.linuxPath; + const drive = parseWindowsDrivePath(path); + if (drive !== null) return `/mnt/${drive.drive}/${drive.tail}`.replace(/\/+/g, "/"); + return path; +} + +function hostPathForBackend(path: string): string { + const hostPath = hostPathFromMntPath(path); + return hostPath ?? path; +} + +export function resolveBackendPath(input: ResolveBackendPathInput): ResolvedBackendPath { + if (input.backend.kind === "wsl") { + return { hostPath: input.path, backendPath: backendPathForWsl(input.path) }; + } + return { hostPath: hostPathForBackend(input.path), backendPath: input.path }; +} diff --git a/apps/server/src/backend/backendRegistry.test.ts b/apps/server/src/backend/backendRegistry.test.ts new file mode 100644 index 00000000..78273d82 --- /dev/null +++ b/apps/server/src/backend/backendRegistry.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; + +import { makeBackendId } from "@jcode/contracts"; +import { discoverBackends, resolveBackendPath, resolveProjectBackend } from "./backendRegistry"; + +const host = { + environmentId: "host-env", + label: "Windows host", + platform: { os: "windows", arch: "x64" }, + serverVersion: "0.0.50", +} as const; + +describe("backend registry discovery", () => { + it("discovers host and mocked WSL distro backend states from Windows fixtures", async () => { + const calls: readonly string[][] = []; + const mutableCalls: string[][] = []; + const registry = await discoverBackends({ + host, + wsl: { + enabled: true, + run: async (args) => { + mutableCalls.push([...args]); + if (args[0] === "--list") { + return { ok: true, stdout: "\uFEFFUbuntu\u0000\r\n\r\nDebian\r\n", stderr: "" }; + } + if (args[1] === "Ubuntu") { + return { ok: true, stdout: "x86_64\n", stderr: "" }; + } + return { ok: false, stdout: "", stderr: "The specified distribution was not found." }; + }, + }, + }); + calls.concat(mutableCalls); + + expect(registry.host.kind).toBe("local"); + expect(registry.backends.map((backend) => backend.id)).toEqual([ + "host", + "wsl-ubuntu", + "wsl-debian", + ]); + expect(registry.backends[1]?.state.kind).toBe("healthy"); + expect(registry.backends[2]?.state.kind).toBe("degraded"); + expect(mutableCalls).toEqual([ + ["--list", "--quiet"], + ["-d", "Ubuntu", "--", "uname", "-m"], + ["-d", "Debian", "--", "uname", "-m"], + ]); + }); + + it("keeps discovery host-only when WSL probing is disabled", async () => { + const registry = await discoverBackends({ + host, + wsl: { enabled: false }, + }); + + expect(registry.backends).toHaveLength(1); + expect(registry.backends[0]?.id).toBe("host"); + }); +}); + +describe("backend path resolution", () => { + it("resolves project backends without routing live operations", async () => { + const registry = await discoverBackends({ + host, + wsl: { + enabled: true, + run: async (args) => { + if (args[0] === "--list") return { ok: true, stdout: "Ubuntu\n", stderr: "" }; + return { ok: true, stdout: "x86_64\n", stderr: "" }; + }, + }, + }); + + const hostResolution = resolveProjectBackend({ + registry, + workspaceRoot: "C:\\Users\\Jay\\project", + }); + const wslResolution = resolveProjectBackend({ + registry, + workspaceRoot: "\\\\wsl$\\Ubuntu\\home\\jay\\project", + }); + const overrideResolution = resolveProjectBackend({ + registry, + workspaceRoot: "C:\\Users\\Jay\\project", + overrideBackendId: makeBackendId("wsl-ubuntu"), + }); + const removedResolution = resolveProjectBackend({ + registry, + workspaceRoot: "\\\\wsl$\\Ghost\\home\\jay\\project", + }); + + expect(hostResolution.backend.id).toBe("host"); + expect(hostResolution.backendPath).toBe("C:\\Users\\Jay\\project"); + expect(wslResolution.backend.id).toBe("wsl-ubuntu"); + expect(wslResolution.backendPath).toBe("/home/jay/project"); + expect(overrideResolution.backend.id).toBe("wsl-ubuntu"); + expect(overrideResolution.backendPath).toBe("/mnt/c/Users/Jay/project"); + expect(removedResolution.backend.state.kind).toBe("removed"); + expect(removedResolution.backendPath).toBe("/home/jay/project"); + }); + + it("translates host and WSL path edge cases as pure data", async () => { + const registry = await discoverBackends({ + host, + wsl: { + enabled: true, + run: async (args) => { + if (args[0] === "--list") return { ok: true, stdout: "Ubuntu\n", stderr: "" }; + return { ok: true, stdout: "aarch64\n", stderr: "" }; + }, + }, + }); + const ubuntu = registry.backends.find((backend) => backend.id === "wsl-ubuntu"); + + expect(ubuntu).toBeDefined(); + if (ubuntu === undefined) return; + + expect(resolveBackendPath({ backend: ubuntu, path: "D:\\Work Dir\\repo" })).toEqual({ + hostPath: "D:\\Work Dir\\repo", + backendPath: "/mnt/d/Work Dir/repo", + }); + expect( + resolveBackendPath({ backend: ubuntu, path: "\\\\wsl$\\Ubuntu\\home\\jay\\repo" }), + ).toEqual({ + hostPath: "\\\\wsl$\\Ubuntu\\home\\jay\\repo", + backendPath: "/home/jay/repo", + }); + expect(resolveBackendPath({ backend: registry.host, path: "/mnt/c/Users/Jay/repo" })).toEqual({ + hostPath: "C:\\Users\\Jay\\repo", + backendPath: "/mnt/c/Users/Jay/repo", + }); + }); +}); diff --git a/apps/server/src/backend/backendRegistry.ts b/apps/server/src/backend/backendRegistry.ts new file mode 100644 index 00000000..18196198 --- /dev/null +++ b/apps/server/src/backend/backendRegistry.ts @@ -0,0 +1,235 @@ +import { + type Backend, + type BackendId, + type BackendRegistry, + type ExecutionEnvironmentDescriptor, + makeBackendEnvironmentId, + makeBackendId, + makeWslDistroName, +} from "@jcode/contracts"; + +import { + parseWslUncPath, + resolveBackendPath, + type ResolveBackendPathInput, + type ResolvedBackendPath, +} from "./backendPathResolver"; + +export { resolveBackendPath } from "./backendPathResolver"; +export type { ResolveBackendPathInput, ResolvedBackendPath } from "./backendPathResolver"; + +export type WslCommandResult = + | { readonly ok: true; readonly stdout: string; readonly stderr: string } + | { readonly ok: false; readonly stdout: string; readonly stderr: string }; + +export interface WslCommandRunner { + (args: readonly string[]): Promise; +} + +export type BackendDiscoveryHost = { + readonly environmentId: string; + readonly label: string; + readonly platform: ExecutionEnvironmentDescriptor["platform"]; + readonly serverVersion: string; +}; + +export type BackendDiscoveryInput = { + readonly host: BackendDiscoveryHost; + readonly wsl: + | { readonly enabled: false } + | { readonly enabled: true; readonly run: WslCommandRunner }; +}; + +export type ResolveProjectBackendInput = { + readonly registry: BackendRegistry; + readonly workspaceRoot: string; + readonly overrideBackendId?: BackendId | undefined; +}; + +function makeHostBackend(host: BackendDiscoveryHost): Backend { + return { + id: makeBackendId("host"), + kind: "local", + connection: { kind: "local" }, + descriptor: { + environmentId: makeBackendEnvironmentId(host.environmentId), + label: host.label, + platform: host.platform, + serverVersion: host.serverVersion, + capabilities: { repositoryIdentity: true }, + }, + state: { kind: "healthy" }, + }; +} + +function backendIdForWslDistro(distro: string): BackendId { + const normalized = distro + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return makeBackendId(`wsl-${normalized || "distro"}`); +} + +function descriptorForWslDistro(input: { + readonly distro: string; + readonly serverVersion: string; + readonly arch: ExecutionEnvironmentDescriptor["platform"]["arch"]; +}): ExecutionEnvironmentDescriptor { + const id = backendIdForWslDistro(input.distro); + return { + environmentId: makeBackendEnvironmentId(`${id}-env`), + label: `WSL ${input.distro}`, + platform: { os: "linux", arch: input.arch }, + serverVersion: input.serverVersion, + capabilities: { repositoryIdentity: true }, + }; +} + +function makeWslBackend(input: { + readonly distro: string; + readonly serverVersion: string; + readonly arch: ExecutionEnvironmentDescriptor["platform"]["arch"]; + readonly state: Backend["state"]; +}): Backend { + return { + id: backendIdForWslDistro(input.distro), + kind: "wsl", + connection: { kind: "wsl-exe", distro: makeWslDistroName(input.distro) }, + descriptor: descriptorForWslDistro({ + distro: input.distro, + serverVersion: input.serverVersion, + arch: input.arch, + }), + state: input.state, + }; +} + +function makeRemovedWslBackend(input: { + readonly distro: string; + readonly serverVersion: string; +}): Backend { + return makeWslBackend({ + distro: input.distro, + serverVersion: input.serverVersion, + arch: "other", + state: { kind: "removed", reason: `WSL distro ${input.distro} is not registered.` }, + }); +} + +function parseWslListOutput(stdout: string): readonly string[] { + const seen = new Set(); + const distros: string[] = []; + for (const rawLine of stdout + .replace(/\u0000/g, "") + .replace(/^\uFEFF/, "") + .split(/\r?\n/)) { + const distro = rawLine.trim(); + const lower = distro.toLowerCase(); + if (distro.length === 0) continue; + if (/[\u0000-\u001f\u007f]/.test(distro)) continue; + if (lower.startsWith("windows subsystem")) continue; + if (lower.includes("error")) continue; + if (seen.has(distro)) continue; + seen.add(distro); + distros.push(distro); + } + return distros; +} + +function platformArchFromUname(stdout: string): ExecutionEnvironmentDescriptor["platform"]["arch"] { + const arch = stdout.trim().toLowerCase(); + if (arch === "x86_64" || arch === "amd64") return "x64"; + if (arch === "aarch64" || arch === "arm64") return "arm64"; + return "other"; +} + +async function discoverWslBackend(input: { + readonly distro: string; + readonly serverVersion: string; + readonly run: WslCommandRunner; +}): Promise { + const probe = await input.run(["-d", input.distro, "--", "uname", "-m"]); + if (!probe.ok) { + return makeWslBackend({ + distro: input.distro, + serverVersion: input.serverVersion, + arch: "other", + state: { + kind: "degraded", + reason: probe.stderr.trim() || `WSL distro ${input.distro} did not respond.`, + }, + }); + } + + return makeWslBackend({ + distro: input.distro, + serverVersion: input.serverVersion, + arch: platformArchFromUname(probe.stdout), + state: { kind: "healthy" }, + }); +} + +export async function discoverBackends(input: BackendDiscoveryInput): Promise { + const host = makeHostBackend(input.host); + if (!input.wsl.enabled) return { host, backends: [host] }; + + const list = await input.wsl.run(["--list", "--quiet"]); + if (!list.ok) return { host, backends: [host] }; + + const backends: Backend[] = [host]; + for (const distro of parseWslListOutput(list.stdout)) { + backends.push( + await discoverWslBackend({ + distro, + serverVersion: input.host.serverVersion, + run: input.wsl.run, + }), + ); + } + return { host, backends }; +} + +function findBackendById(registry: BackendRegistry, id: BackendId): Backend | null { + return registry.backends.find((backend) => backend.id === id) ?? null; +} + +function findWslBackendByDistro(registry: BackendRegistry, distro: string): Backend | null { + const normalized = distro.toLowerCase(); + return ( + registry.backends.find( + (backend) => + backend.connection.kind === "wsl-exe" && + String(backend.connection.distro).toLowerCase() === normalized, + ) ?? null + ); +} + +export function resolveProjectBackend(input: ResolveProjectBackendInput) { + if (input.overrideBackendId !== undefined) { + const overrideBackend = findBackendById(input.registry, input.overrideBackendId); + const backend = overrideBackend ?? input.registry.host; + const paths = resolveBackendPath({ backend, path: input.workspaceRoot }); + return { backend, workspaceRoot: input.workspaceRoot, ...paths, source: "override" }; + } + + const wslUnc = parseWslUncPath(input.workspaceRoot); + if (wslUnc !== null) { + const backend = + findWslBackendByDistro(input.registry, wslUnc.distro) ?? + makeRemovedWslBackend({ + distro: wslUnc.distro, + serverVersion: input.registry.host.descriptor.serverVersion, + }); + const paths = resolveBackendPath({ backend, path: input.workspaceRoot }); + return { backend, workspaceRoot: input.workspaceRoot, ...paths, source: "path" }; + } + + const paths = resolveBackendPath({ backend: input.registry.host, path: input.workspaceRoot }); + return { + backend: input.registry.host, + workspaceRoot: input.workspaceRoot, + ...paths, + source: "default", + }; +} diff --git a/apps/web/src/components/ChatView.structure.test.ts b/apps/web/src/components/ChatView.structure.test.ts new file mode 100644 index 00000000..16d7d1a9 --- /dev/null +++ b/apps/web/src/components/ChatView.structure.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const chatViewSource = readFileSync(new URL("./ChatView.tsx", import.meta.url), "utf8"); + +function extractRuntimeUsageControlsPropsSource(): string { + const match = chatViewSource.match( + /const runtimeUsageControlsProps = \{(?[\s\S]*?)\n \};\n const branchToolbarProps/, + ); + return match?.groups?.body ?? ""; +} + +describe("ChatView runtime usage controls structure", () => { + it("passes provider usage summary through every active runtime controls surface", () => { + const propsSource = extractRuntimeUsageControlsPropsSource(); + + expect(propsSource).toContain("provider: activeProvider"); + expect(propsSource).toContain("providerRateLimits: activeProviderUsageSummary.rateLimits"); + expect(propsSource).toContain("providerUsageLines: activeProviderUsageSummary.usageLines"); + expect(propsSource).toContain("providerUsageIsLoading: activeProviderUsageSummary.isLoading"); + expect(propsSource).toContain( + "providerUsageLearnMoreHref: activeProviderUsageSummary.learnMoreHref", + ); + }); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1ddb0e5f..5b42f0ae 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -194,6 +194,7 @@ import { useThreadWorkspaceHandoff } from "../hooks/useThreadWorkspaceHandoff"; import { useComposerCommandMenuItems } from "../hooks/useComposerCommandMenuItems"; import { useThreadHandoff } from "../hooks/useThreadHandoff"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { useProviderUsageSummary } from "../hooks/useProviderUsageSummary"; import BranchToolbar, { RuntimeUsageControls } from "./BranchToolbar"; import { resolveRuntimeUsageControlsClassName } from "./BranchToolbar.logic"; import { ThreadWorktreeHandoffDialog } from "./ThreadWorktreeHandoffDialog"; @@ -324,7 +325,7 @@ import { getComposerProviderState, renderProviderTraitsPicker, } from "./chat/composerProviderRegistry"; -import { getComposerTraitSelection } from "./chat/composerTraits"; +import { deriveComposerPromptTraits, getComposerTraitSelection } from "./chat/composerTraits"; import { resolveRuntimeModelDescriptor } from "./chat/runtimeModelCapabilities"; import { ProjectPicker } from "./chat/ProjectPicker"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; @@ -850,6 +851,7 @@ export default function ChatView({ const syncServerShellSnapshot = useStore((store) => store.syncServerShellSnapshot); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadWorkspace = useStore((store) => store.setThreadWorkspace); + const allThreads = useStore((store) => store.threads); const { settings } = useAppSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, @@ -1036,6 +1038,7 @@ export default function ChatView({ ); const [isModelPickerOpen, setIsModelPickerOpen] = useState(false); const [isTraitsPickerOpen, setIsTraitsPickerOpen] = useState(false); + const [tailFollowEnabled, setTailFollowEnabled] = useState(true); const legendListRef = useRef(null); const tailFollowEnabledRef = useRef(true); const isAtEndRef = useRef(true); @@ -1450,6 +1453,13 @@ export default function ChatView({ : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? settings.defaultProvider; + const activeProvider = + activeThread?.session?.provider ?? activeThread?.modelSelection.provider ?? selectedProvider; + const activeProviderUsageSummary = useProviderUsageSummary({ + provider: activeProvider, + threads: allThreads, + codexHomePath: settings.codexHomePath || null, + }); const voiceTranscriptionRequestIdRef = useRef(0); const voiceThreadIdRef = useRef(threadId); const voiceProviderRef = useRef(selectedProvider); @@ -1734,16 +1744,23 @@ export default function ChatView({ }), [runtimeModelsByProvider, selectedModel, selectedProvider], ); + const composerPromptTraits = useMemo(() => deriveComposerPromptTraits(prompt), [prompt]); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, runtimeModel: selectedRuntimeModel, - prompt, + promptTraits: composerPromptTraits, modelOptions: composerModelOptions, }), - [composerModelOptions, prompt, selectedModel, selectedProvider, selectedRuntimeModel], + [ + composerModelOptions, + composerPromptTraits, + selectedModel, + selectedProvider, + selectedRuntimeModel, + ], ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; @@ -3956,6 +3973,7 @@ export default function ChatView({ const programmaticScrollUntilRef = useRef(0); const setTailFollowIntent = useCallback((enabled: boolean) => { tailFollowEnabledRef.current = enabled; + setTailFollowEnabled((current) => (current === enabled ? current : enabled)); }, []); const rememberCurrentMessagesScrollTop = useCallback(() => { const scrollContainer = legendListRef.current?.getScrollableNode?.(); @@ -7680,8 +7698,13 @@ export default function ChatView({ }; const runtimeUsageControlsProps = { + provider: activeProvider, runtimeMode, onRuntimeModeChange: handleRuntimeModeChange, + providerRateLimits: activeProviderUsageSummary.rateLimits, + providerUsageLines: activeProviderUsageSummary.usageLines, + providerUsageIsLoading: activeProviderUsageSummary.isLoading, + providerUsageLearnMoreHref: activeProviderUsageSummary.learnMoreHref, contextWindow: runtimeUsageContextWindow, cumulativeCostUsd: activeCumulativeCostUsd, activeContextWindowLabel: contextWindowSelectionStatus.activeLabel, @@ -8255,7 +8278,7 @@ export default function ChatView({ activeThreadId={activeThread.id} activeThreadTitle={activeThreadDisplayTitle} activeThreadEntryPoint={terminalState.entryPoint} - activeProvider={activeThread.session?.provider ?? activeThread.modelSelection.provider} + activeProvider={activeProvider} activeProjectName={activeProjectDisplayName} threadBreadcrumbs={threadBreadcrumbs} isSidechat={Boolean(activeThread.sidechatSourceThreadId)} @@ -8434,7 +8457,7 @@ export default function ChatView({ onEditUserMessage={onEditUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} onExpandTimelineImage={onExpandTimelineImage} - followLiveOutput={hasStreamingAssistantText} + followLiveOutput={tailFollowEnabled} onIsAtEndChange={onIsAtEndChange} markdownCwd={threadWorkspaceCwd ?? undefined} resolvedTheme={resolvedTheme} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index bb75ea2e..e55ec951 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -810,21 +810,37 @@ label:has(> select#reasoning-effort) select { } .chat-markdown .chat-markdown-codeblock { - --chat-markdown-codeblock-copy-button-space: 1.5rem; + --chat-markdown-codeblock-action-space: 3.25rem; position: relative; margin: 0.65rem 0; } .chat-markdown .chat-markdown-codeblock pre { margin: 0; - padding-right: calc(0.6rem + var(--chat-markdown-codeblock-copy-button-space)); + padding-right: calc(0.6rem + var(--chat-markdown-codeblock-action-space)); } -.chat-markdown .chat-markdown-copy-button { +.chat-markdown .chat-markdown-codeblock--wrapped pre, +.chat-markdown .chat-markdown-codeblock--wrapped pre code { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.chat-markdown .chat-markdown-codeblock-actions { position: absolute; top: 0.5rem; right: 0.5rem; z-index: 1; + display: inline-flex; + gap: 0.25rem; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; +} + +.chat-markdown .chat-markdown-codeblock-action, +.chat-markdown .chat-markdown-table-wrap-button { display: inline-flex; align-items: center; justify-content: center; @@ -835,21 +851,19 @@ label:has(> select#reasoning-effort) select { background: var(--app-chat-code-copy-bg, color-mix(in srgb, var(--background) 82%, transparent)); color: var(--app-chat-code-copy-fg, var(--muted-foreground)); cursor: pointer; - opacity: 0; - pointer-events: none; transition: - opacity 120ms ease, color 120ms ease, border-color 120ms ease; } -.chat-markdown .chat-markdown-codeblock:hover .chat-markdown-copy-button, -.chat-markdown .chat-markdown-codeblock:focus-within .chat-markdown-copy-button { +.chat-markdown .chat-markdown-codeblock:hover .chat-markdown-codeblock-actions, +.chat-markdown .chat-markdown-codeblock:focus-within .chat-markdown-codeblock-actions { opacity: 1; pointer-events: auto; } -.chat-markdown .chat-markdown-copy-button:hover { +.chat-markdown .chat-markdown-codeblock-action:hover, +.chat-markdown .chat-markdown-table-wrap-button:hover { color: var(--app-chat-link, var(--foreground)); border-color: color-mix( in srgb, @@ -1102,15 +1116,45 @@ label:has(> select#reasoning-effort) select { } .chat-markdown-table-scroll { + position: relative; max-width: 100%; overflow-x: auto; overscroll-behavior-x: contain; } +.chat-markdown-table-scroll--wrapped { + overflow-x: visible; +} + +.chat-markdown-table-scroll--wrapped > table { + table-layout: fixed; +} + .chat-markdown-table-scroll > table { min-width: 100%; } +.chat-markdown-table-scroll--wrapped th, +.chat-markdown-table-scroll--wrapped td { + overflow-wrap: anywhere; + word-break: break-word; +} + +.chat-markdown .chat-markdown-table-wrap-button { + position: absolute; + top: 0.35rem; + right: 0.35rem; + z-index: 1; + opacity: 0; + pointer-events: none; +} + +.chat-markdown-table-scroll:hover .chat-markdown-table-wrap-button, +.chat-markdown-table-scroll:focus-within .chat-markdown-table-wrap-button { + opacity: 1; + pointer-events: auto; +} + .chat-markdown th, .chat-markdown td { border: 1px solid var(--border); From efa1f9a4c4b472978bab47ece13a049507abe0f6 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 23:28:38 -0400 Subject: [PATCH 21/27] fix(server): address worktree review feedback --- apps/server/src/git/Layers/GitCore.test.ts | 48 ++++++++++++++++++++++ apps/server/src/git/Layers/GitCore.ts | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index dcd03668..79cdf287 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -1474,6 +1474,54 @@ it.layer(TestLayer)("git integration", (it) => { expect(listed).toEqual([]); }), ); + + it.effect("marks porcelain worktrees prunable when git includes a reason suffix", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const currentBranchEntry = (yield* (yield* GitCore).listBranches({ + cwd: tmp, + })).branches.find((branch) => branch.current); + expect(currentBranchEntry).toBeDefined(); + if (!currentBranchEntry) return; + const result = yield* (yield* GitCore).createWorktree({ + cwd: tmp, + branch: currentBranchEntry.name, + newBranch: "wt-prunable-candidate", + path: null, + }); + const stalePath = result.worktree.path; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(stalePath, { recursive: true }); + const realGitCore = yield* GitCore; + const core = yield* makeIsolatedGitCore((input) => { + if (input.args[0] === "worktree" && input.args[1] === "list") { + return Effect.succeed({ + code: 0, + stdout: [ + `worktree ${tmp}`, + "HEAD abc123", + "branch refs/heads/main", + "", + `worktree ${stalePath}`, + "HEAD def456", + "branch refs/heads/wt-stale", + "prunable gitdir file points to non-existent location", + "", + ].join("\n"), + stderr: "", + }); + } + return realGitCore.execute(input); + }); + + const listed = yield* core.listManagedWorktrees(tmp); + const candidate = listed.find((worktree) => worktree.path === stalePath); + + expect(candidate?.cleanupStatus).toBe("stale_missing"); + expect(candidate?.cleanupExplanation).toContain("prunable"); + }), + ); }); // ── Full flow: local branch checkout ── diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index a820a52e..d8608e8f 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -455,7 +455,7 @@ function parseWorktreeListPorcelain(stdout: string): WorktreePorcelainEntry[] { currentBranch = branch.length > 0 ? branch : null; continue; } - if (line === "prunable") { + if (line === "prunable" || line.startsWith("prunable ")) { currentPrunable = true; } } From 24349dddcef101869c95d0e7e3029e2f3643d78b Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 23:28:38 -0400 Subject: [PATCH 22/27] fix(server): address provider health review feedback --- .../provider/Layers/OpenCodeAdapter.test.ts | 14 +++- .../src/provider/Layers/ProviderHealth.ts | 67 +++++++++---------- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 50e91890..ce82b722 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1488,6 +1488,10 @@ describe("OpenCodeAdapter runtime lifecycle", () => { Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + // Given: a persisted JCode resume cursor names a live OpenCode session. const session = yield* adapter.startSession({ provider: "opencode", @@ -1508,7 +1512,8 @@ describe("OpenCodeAdapter runtime lifecycle", () => { }); // Then: both the session and turn keep the same upstream session cursor. - return { session, turn }; + const events = Array.from(yield* Fiber.join(eventsFiber)); + return { events, session, turn }; }).pipe( Effect.provide( makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( @@ -1524,10 +1529,17 @@ describe("OpenCodeAdapter runtime lifecycle", () => { expect(runtime.sessionGetCalls).toEqual([{ sessionID: "ses_valid" }]); expect(runtime.sessionUpdateCalls).toHaveLength(1); expect(runtime.sessionUpdateCalls[0]?.sessionID).toBe("ses_valid"); + expect(runtime.sessionUpdateCalls[0]?.permission).toEqual([ + { permission: "*", pattern: "*", action: "allow" }, + ]); expect(runtime.createCalls).toEqual([]); expect(runtime.promptCalls[0]).toMatchObject({ sessionID: "ses_valid" }); expect(result.session.resumeCursor).toEqual({ openCodeSessionId: "ses_valid" }); expect(result.turn.resumeCursor).toEqual({ openCodeSessionId: "ses_valid" }); + expect(result.events[0]).toMatchObject({ + type: "session.started", + payload: { message: "OpenCode session resumed" }, + }); }); it("surfaces transient resume probe failures instead of hiding context loss", async () => { diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 6dfef06e..85975f11 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -2042,9 +2042,9 @@ export const ProviderHealthLive = Layer.effect( }); const enrichStatuses = Effect.fn("enrichProviderStatuses")(function* ( + settings: ServerSettings, statuses: ReadonlyArray, ) { - const settings = yield* serverSettings.getSettings; const enriched = yield* Effect.forEach( statuses, (status) => @@ -2076,42 +2076,37 @@ export const ProviderHealthLive = Layer.effect( }); }); - const loadProviderStatuses = serverSettings.getSettings - .pipe( - Effect.flatMap((settings) => - Effect.all( - [ - makeCheckCodexProviderStatus(settings.providers.codex.binaryPath), - makeCheckClaudeProviderStatus( - resolveClaudeSubscription, - settings.providers.claudeAgent.binaryPath, - ), - makeCheckCursorProviderStatus(settings.providers.cursor.binaryPath), - makeCheckGeminiProviderStatus(settings.providers.gemini.binaryPath), - makeCheckKiloProviderStatus(settings.providers.kilo.binaryPath), - makeCheckOpenCodeProviderStatus(settings.providers.opencode.binaryPath), - checkOpenClawProviderStatus(settings.providers.openclaw).pipe( - Effect.provideService(ServerSecretStore, serverSecretStore), - ), - checkPiProviderStatus( - settings.providers.pi.agentDir, - settings.providers.pi.binaryPath, - ), - makeCheckDevinProviderStatus(settings.providers.devin.binaryPath), - ], - { - concurrency: "unbounded", - }, - ), + const loadProviderStatuses = serverSettings.getSettings.pipe( + Effect.flatMap((settings) => + Effect.all( + [ + makeCheckCodexProviderStatus(settings.providers.codex.binaryPath), + makeCheckClaudeProviderStatus( + resolveClaudeSubscription, + settings.providers.claudeAgent.binaryPath, + ), + makeCheckCursorProviderStatus(settings.providers.cursor.binaryPath), + makeCheckGeminiProviderStatus(settings.providers.gemini.binaryPath), + makeCheckKiloProviderStatus(settings.providers.kilo.binaryPath), + makeCheckOpenCodeProviderStatus(settings.providers.opencode.binaryPath), + checkOpenClawProviderStatus(settings.providers.openclaw).pipe( + Effect.provideService(ServerSecretStore, serverSecretStore), + ), + checkPiProviderStatus(settings.providers.pi.agentDir, settings.providers.pi.binaryPath), + makeCheckDevinProviderStatus(settings.providers.devin.binaryPath), + ], + { + concurrency: "unbounded", + }, + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.map(orderProviderStatuses), + Effect.flatMap((statuses) => enrichStatuses(settings, statuses)), ), - ) - .pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.map(orderProviderStatuses), - Effect.flatMap(enrichStatuses), - ); + ), + ); const persistStatuses = (statuses: ProviderStatuses) => Effect.forEach( From d1e847c77b74072851c93e3c62cbf06a6fedfd00 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 23:28:38 -0400 Subject: [PATCH 23/27] fix(web): preserve multi-file diff actions --- .../components/chat/MessagesTimeline.test.tsx | 79 ++++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 32 +++++--- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 16f3ab54..d9129766 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1628,7 +1628,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-file-change-row="true"'); expect(markup).toContain('aria-expanded="false"'); - expect(markup).toContain('aria-label="Expand File Change'); + expect(markup).toContain("Show details"); expect(markup).toContain("MessagesTimeline.tsx"); expect(markup).not.toContain("old timeline row"); expect(markup).not.toContain("new timeline row"); @@ -2158,6 +2158,83 @@ describe("MessagesTimeline", () => { expect(markup).toContain("+2"); }); + it("keeps multi-file diff buttons separate from shared activity details expansion", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const assistantMessageId = MessageId.makeUnsafe("message-assistant-inline-multi-edit-details"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="dark" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain('aria-label="Open diff for File Change one.ts"'); + expect(markup).toContain('aria-label="Open diff for File Change two.ts"'); + expect(markup).toContain("Show details"); + expect(markup).not.toContain('aria-label="Expand File Change one.ts"'); + }); + it("renders inline edited rows from the turn summary when the file-change tool call has no filenames", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const assistantMessageId = MessageId.makeUnsafe("message-assistant-inline-summary-fallback"); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7648063e..8a8e9501 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1894,6 +1894,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const subagentMeta = subagentCardMeta(workEntry); const [expanded, setExpanded] = useState(false); const canExpand = hasExpandableActivityDetails(workEntry); + const toggleDetailsExpanded = () => setExpanded((current) => !current); // Use the text font size (matching the UI settings) for tool call rows const rowFontSizePx = textFontSizePx; @@ -1906,6 +1907,11 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const changedFileStat = fileDiffStatByPath?.get(changedFilePath); const canOpenEditedDiff = Boolean(turnId && onOpenTurnDiff); const changedFileLabel = `${toolWorkEntryHeading(workEntry)} ${basename(changedFilePath)}`; + const changedFileAriaLabel = canOpenEditedDiff + ? `Open diff for ${changedFileLabel}` + : canExpand + ? `${expanded ? "Collapse" : "Expand"} details for ${changedFileLabel}` + : undefined; return ( + ) : null} {expanded && canExpand ? ( ) : null} From 457b7ef13d81327cc81b4e89170e6351a9230a0c Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 23:28:38 -0400 Subject: [PATCH 24/27] refactor(web): extract activity detail logic --- ...sagesTimelineActivityDetails.logic.test.ts | 53 +++++++++++ .../MessagesTimelineActivityDetails.logic.ts | 77 +++++++++++++++ .../chat/MessagesTimelineActivityDetails.tsx | 93 +++++-------------- apps/web/src/session-logic.ts | 25 ++++- 4 files changed, 172 insertions(+), 76 deletions(-) create mode 100644 apps/web/src/components/chat/MessagesTimelineActivityDetails.logic.test.ts create mode 100644 apps/web/src/components/chat/MessagesTimelineActivityDetails.logic.ts diff --git a/apps/web/src/components/chat/MessagesTimelineActivityDetails.logic.test.ts b/apps/web/src/components/chat/MessagesTimelineActivityDetails.logic.test.ts new file mode 100644 index 00000000..6a4eb208 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimelineActivityDetails.logic.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { + COMMAND_OUTPUT_TAIL_LINES, + formatWorkspaceRelativePath, + getVisibleCommandOutputLines, + hasCommandActivityDetails, + hasExpandableActivityDetails, +} from "./MessagesTimelineActivityDetails.logic"; + +describe("MessagesTimelineActivityDetails logic", () => { + it("uses the shared command classification for expandable command rows", () => { + expect( + hasCommandActivityDetails({ + id: "work-command", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Ran command", + tone: "tool", + requestKind: "command", + exitCode: 0, + }), + ).toBe(true); + }); + + it("keeps file-change patches expandable after normalizing workspace paths", () => { + const entry = { + id: "work-file-change", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Edited", + tone: "tool" as const, + requestKind: "file-change" as const, + patch: "diff --git a/app.ts b/app.ts", + }; + + expect(hasExpandableActivityDetails(entry)).toBe(true); + expect(formatWorkspaceRelativePath("/repo/apps/web/src/app.ts", "/repo")).toBe( + "apps/web/src/app.ts", + ); + }); + + it("reports hidden command output lines when tailing long output", () => { + const value = Array.from( + { length: COMMAND_OUTPUT_TAIL_LINES + 2 }, + (_, index) => `line ${index}`, + ).join("\n"); + + const output = getVisibleCommandOutputLines(value); + + expect(output.lines).toHaveLength(COMMAND_OUTPUT_TAIL_LINES); + expect(output.lines[0]).toBe("line 2"); + expect(output.hiddenLineCount).toBe(2); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimelineActivityDetails.logic.ts b/apps/web/src/components/chat/MessagesTimelineActivityDetails.logic.ts new file mode 100644 index 00000000..02c3e5f6 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimelineActivityDetails.logic.ts @@ -0,0 +1,77 @@ +import type { WorkLogEntry } from "../../session-logic"; +import { isCommandWorkLogEntry } from "../../session-logic"; + +export const COMMAND_OUTPUT_TAIL_LINES = 40; + +export function hasExpandableActivityDetails(workEntry: WorkLogEntry): boolean { + return hasCommandActivityDetails(workEntry) || hasFileChangeActivityDetails(workEntry); +} + +export function hasCommandActivityDetails(workEntry: WorkLogEntry): boolean { + if (!isCommandWorkLogEntry(workEntry)) { + return false; + } + return Boolean( + workEntry.command || + workEntry.rawCommand || + workEntry.output || + workEntry.stdout || + workEntry.stderr || + workEntry.exitCode !== undefined || + workEntry.durationMs !== undefined, + ); +} + +export function hasFileChangeActivityDetails(workEntry: WorkLogEntry): boolean { + return isFileChangeActivity(workEntry) && Boolean(workEntry.patch?.trim()); +} + +export function isFileChangeActivity(workEntry: WorkLogEntry): boolean { + return workEntry.itemType === "file_change" || workEntry.requestKind === "file-change"; +} + +export function formatWorkspaceRelativePath( + filePath: string, + workspaceRoot: string | undefined, +): string { + const normalizedPath = filePath.replace(/\\/gu, "/"); + const normalizedRoot = workspaceRoot?.replace(/\\/gu, "/").replace(/\/+$/u, ""); + if (normalizedRoot && normalizedPath.startsWith(`${normalizedRoot}/`)) { + return normalizedPath.slice(normalizedRoot.length + 1); + } + return normalizedPath; +} + +export function getRenderableCommandOutputLines(value: string | undefined): string[] { + if (typeof value !== "string" || value.length === 0) { + return []; + } + const lines = value.split(/\r?\n/u); + let startIndex = 0; + let endIndex = lines.length; + while (startIndex < endIndex && (lines[startIndex]?.trim().length ?? 0) === 0) { + startIndex += 1; + } + while (endIndex > startIndex && (lines[endIndex - 1]?.trim().length ?? 0) === 0) { + endIndex -= 1; + } + return lines.slice(startIndex, endIndex); +} + +export function hasRenderableCommandOutput(value: string | undefined): value is string { + return getRenderableCommandOutputLines(value).length > 0; +} + +export function getVisibleCommandOutputLines(value: string): { + readonly lines: readonly string[]; + readonly hiddenLineCount: number; +} { + const lines = getRenderableCommandOutputLines(value); + if (lines.length <= COMMAND_OUTPUT_TAIL_LINES) { + return { lines, hiddenLineCount: 0 }; + } + return { + lines: lines.slice(-COMMAND_OUTPUT_TAIL_LINES), + hiddenLineCount: lines.length - COMMAND_OUTPUT_TAIL_LINES, + }; +} diff --git a/apps/web/src/components/chat/MessagesTimelineActivityDetails.tsx b/apps/web/src/components/chat/MessagesTimelineActivityDetails.tsx index 9e02edce..81ce04b2 100644 --- a/apps/web/src/components/chat/MessagesTimelineActivityDetails.tsx +++ b/apps/web/src/components/chat/MessagesTimelineActivityDetails.tsx @@ -1,13 +1,17 @@ -import type { WorkLogEntry } from "../../session-logic"; import { formatDuration } from "../../session-logic"; import { getRenderablePatch, serializeRenderablePatchText } from "../../lib/diffRendering"; import { cn } from "~/lib/utils"; +import type { WorkLogEntry } from "../../session-logic"; +import { + formatWorkspaceRelativePath, + getVisibleCommandOutputLines, + hasCommandActivityDetails, + hasExpandableActivityDetails, + hasFileChangeActivityDetails, + hasRenderableCommandOutput, +} from "./MessagesTimelineActivityDetails.logic"; -const COMMAND_OUTPUT_TAIL_LINES = 40; - -export function hasExpandableActivityDetails(workEntry: WorkLogEntry): boolean { - return hasCommandActivityDetails(workEntry) || hasFileChangeActivityDetails(workEntry); -} +export { hasExpandableActivityDetails } from "./MessagesTimelineActivityDetails.logic"; export function ActivityEntryDetails(props: { workEntry: WorkLogEntry; @@ -22,37 +26,6 @@ export function ActivityEntryDetails(props: { return null; } -function hasCommandActivityDetails(workEntry: WorkLogEntry): boolean { - if (!isCommandActivity(workEntry)) { - return false; - } - return Boolean( - workEntry.command || - workEntry.rawCommand || - workEntry.output || - workEntry.stdout || - workEntry.stderr || - workEntry.exitCode !== undefined || - workEntry.durationMs !== undefined, - ); -} - -function isCommandActivity(workEntry: WorkLogEntry): boolean { - return ( - workEntry.itemType === "command_execution" || - workEntry.requestKind === "command" || - Boolean(workEntry.command ?? workEntry.rawCommand) - ); -} - -function hasFileChangeActivityDetails(workEntry: WorkLogEntry): boolean { - return isFileChangeActivity(workEntry) && Boolean(workEntry.patch?.trim()); -} - -function isFileChangeActivity(workEntry: WorkLogEntry): boolean { - return workEntry.itemType === "file_change" || workEntry.requestKind === "file-change"; -} - function CommandActivityDetails(props: { workEntry: WorkLogEntry }) { const command = props.workEntry.command ?? props.workEntry.rawCommand; const rawCommand = @@ -137,15 +110,6 @@ function FileChangeActivityDetails(props: { ); } -function formatWorkspaceRelativePath(filePath: string, workspaceRoot: string | undefined): string { - const normalizedPath = filePath.replace(/\\/gu, "/"); - const normalizedRoot = workspaceRoot?.replace(/\\/gu, "/").replace(/\/+$/u, ""); - if (normalizedRoot && normalizedPath.startsWith(`${normalizedRoot}/`)) { - return normalizedPath.slice(normalizedRoot.length + 1); - } - return normalizedPath; -} - function ActivityDetailBlock(props: { title: string; children: string; @@ -173,32 +137,17 @@ function ActivityDetailBlock(props: { } function CommandOutputBlock(props: { title: string; value: string; tone?: "default" | "error" }) { - const lines = getRenderableCommandOutputLines(props.value); - const visibleLines = - lines.length > COMMAND_OUTPUT_TAIL_LINES ? lines.slice(-COMMAND_OUTPUT_TAIL_LINES) : lines; + const output = getVisibleCommandOutputLines(props.value); return ( - - {visibleLines.join("\n")} - +
+ {output.hiddenLineCount > 0 ? ( +

+ Showing last {output.lines.length} lines; {output.hiddenLineCount} earlier lines hidden. +

+ ) : null} + + {output.lines.join("\n")} + +
); } - -function hasRenderableCommandOutput(value: string | undefined): value is string { - return getRenderableCommandOutputLines(value).length > 0; -} - -function getRenderableCommandOutputLines(value: string | undefined): string[] { - if (typeof value !== "string" || value.length === 0) { - return []; - } - const lines = value.split(/\r?\n/u); - let startIndex = 0; - let endIndex = lines.length; - while (startIndex < endIndex && (lines[startIndex]?.trim().length ?? 0) === 0) { - startIndex += 1; - } - while (endIndex > startIndex && (lines[endIndex - 1]?.trim().length ?? 0) === 0) { - endIndex -= 1; - } - return lines.slice(startIndex, endIndex); -} diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 47d6df25..ac68052e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -71,6 +71,21 @@ export interface WorkLogEntry { subagentAction?: WorkLogSubagentAction; } +type CommandWorkLogEntryInput = { + readonly itemType?: WorkLogEntry["itemType"] | undefined; + readonly requestKind?: WorkLogEntry["requestKind"] | undefined; + readonly command?: string | undefined; + readonly rawCommand?: string | undefined; +}; + +export function isCommandWorkLogEntry(workEntry: CommandWorkLogEntryInput): boolean { + return ( + workEntry.itemType === "command_execution" || + workEntry.requestKind === "command" || + Boolean(workEntry.command ?? workEntry.rawCommand) + ); +} + export const WORK_LOG_PRESENTATION_VERSION = 5; export interface WorkLogSubagent { @@ -771,10 +786,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (commandPreview.rawCommand) { entry.rawCommand = commandPreview.rawCommand; } - const isCommandEntry = - itemType === "command_execution" || - requestKind === "command" || - Boolean(commandPreview.command || commandPreview.rawCommand); + const isCommandEntry = isCommandWorkLogEntry({ + itemType, + requestKind, + command: commandPreview.command ?? undefined, + rawCommand: commandPreview.rawCommand ?? undefined, + }); if (isCommandEntry) { if (commandResult.output) { entry.output = commandResult.output; From 9a8ce2a14bde9bb3d224cfe607de35d30e458467 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 23:28:38 -0400 Subject: [PATCH 25/27] fix(web): address markdown control review feedback --- .../src/components/ChatView.structure.test.ts | 16 ++++++++++++---- .../components/chat/MessagesTimelineMinimap.tsx | 3 ++- apps/web/src/index.css | 5 ++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.structure.test.ts b/apps/web/src/components/ChatView.structure.test.ts index 16d7d1a9..c79015a5 100644 --- a/apps/web/src/components/ChatView.structure.test.ts +++ b/apps/web/src/components/ChatView.structure.test.ts @@ -4,10 +4,18 @@ import { describe, expect, it } from "vitest"; const chatViewSource = readFileSync(new URL("./ChatView.tsx", import.meta.url), "utf8"); function extractRuntimeUsageControlsPropsSource(): string { - const match = chatViewSource.match( - /const runtimeUsageControlsProps = \{(?[\s\S]*?)\n \};\n const branchToolbarProps/, - ); - return match?.groups?.body ?? ""; + const declaration = "const runtimeUsageControlsProps = {"; + const start = chatViewSource.indexOf(declaration); + if (start < 0) return ""; + const bodyStart = start + declaration.length; + let depth = 1; + for (let index = bodyStart; index < chatViewSource.length; index += 1) { + const char = chatViewSource[index]; + if (char === "{") depth += 1; + if (char === "}") depth -= 1; + if (depth === 0) return chatViewSource.slice(bodyStart, index); + } + return ""; } describe("ChatView runtime usage controls structure", () => { diff --git a/apps/web/src/components/chat/MessagesTimelineMinimap.tsx b/apps/web/src/components/chat/MessagesTimelineMinimap.tsx index 96d7a8e7..e732e0fc 100644 --- a/apps/web/src/components/chat/MessagesTimelineMinimap.tsx +++ b/apps/web/src/components/chat/MessagesTimelineMinimap.tsx @@ -1,5 +1,6 @@ import type { LegendListRef } from "@legendapp/list/react"; import type { RefObject } from "react"; +import { useMemo } from "react"; import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; @@ -69,7 +70,7 @@ export function TimelineMinimap({ readonly rows: ReadonlyArray; readonly onJump: (item: TimelineMinimapItem) => void; }) { - const items = deriveTimelineMinimapItems(rows); + const items = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); if (items.length < TIMELINE_MINIMAP_MIN_ITEMS) { return null; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index e55ec951..8d39026b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -824,7 +824,6 @@ label:has(> select#reasoning-effort) select { .chat-markdown .chat-markdown-codeblock--wrapped pre code { white-space: pre-wrap; overflow-wrap: anywhere; - word-break: break-word; } .chat-markdown .chat-markdown-codeblock-actions { @@ -1137,11 +1136,11 @@ label:has(> select#reasoning-effort) select { .chat-markdown-table-scroll--wrapped th, .chat-markdown-table-scroll--wrapped td { overflow-wrap: anywhere; - word-break: break-word; } .chat-markdown .chat-markdown-table-wrap-button { - position: absolute; + position: sticky; + float: right; top: 0.35rem; right: 0.35rem; z-index: 1; From ed2a2e527963a0fb3e1c4c269ee2b86620008b87 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 23:28:38 -0400 Subject: [PATCH 26/27] fix(contracts): constrain backend connection variants --- apps/server/src/backend/backendPathResolver.ts | 2 +- apps/server/src/backend/backendRegistry.test.ts | 2 +- packages/contracts/src/backend.test.ts | 10 ++++++++++ packages/contracts/src/backend.ts | 17 ++++++++++++++--- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/server/src/backend/backendPathResolver.ts b/apps/server/src/backend/backendPathResolver.ts index baf8bb40..facafe55 100644 --- a/apps/server/src/backend/backendPathResolver.ts +++ b/apps/server/src/backend/backendPathResolver.ts @@ -65,7 +65,7 @@ function hostPathForBackend(path: string): string { } export function resolveBackendPath(input: ResolveBackendPathInput): ResolvedBackendPath { - if (input.backend.kind === "wsl") { + if (input.backend.connection.kind === "wsl-exe") { return { hostPath: input.path, backendPath: backendPathForWsl(input.path) }; } return { hostPath: hostPathForBackend(input.path), backendPath: input.path }; diff --git a/apps/server/src/backend/backendRegistry.test.ts b/apps/server/src/backend/backendRegistry.test.ts index 78273d82..2a33cf83 100644 --- a/apps/server/src/backend/backendRegistry.test.ts +++ b/apps/server/src/backend/backendRegistry.test.ts @@ -32,7 +32,7 @@ describe("backend registry discovery", () => { }); calls.concat(mutableCalls); - expect(registry.host.kind).toBe("local"); + expect(registry.host.connection.kind).toBe("local"); expect(registry.backends.map((backend) => backend.id)).toEqual([ "host", "wsl-ubuntu", diff --git a/packages/contracts/src/backend.test.ts b/packages/contracts/src/backend.test.ts index 767bf105..60faa99c 100644 --- a/packages/contracts/src/backend.test.ts +++ b/packages/contracts/src/backend.test.ts @@ -69,4 +69,14 @@ describe("Backend contracts", () => { expect(resolution.backend.state.kind).toBe("removed"); expect(resolution.backendPath).toBe("/home/jay/project"); }); + + it("rejects backend contracts with mismatched kind and connection", () => { + expect(() => + Schema.decodeUnknownSync(Backend)({ + ...hostBackend, + kind: "local", + connection: { kind: "wsl-exe", distro: "Ubuntu" }, + }), + ).toThrow(); + }); }); diff --git a/packages/contracts/src/backend.ts b/packages/contracts/src/backend.ts index cc72d27b..021903e9 100644 --- a/packages/contracts/src/backend.ts +++ b/packages/contracts/src/backend.ts @@ -27,13 +27,24 @@ export const BackendConnection = Schema.Union([ ]); export type BackendConnection = typeof BackendConnection.Type; -export const Backend = Schema.Struct({ +const BackendBase = Schema.Struct({ id: BackendId, - kind: BackendKind, - connection: BackendConnection, descriptor: ExecutionEnvironmentDescriptor, state: BackendLifecycleState, }); + +export const Backend = Schema.Union([ + Schema.Struct({ + ...BackendBase.fields, + kind: Schema.Literal("local"), + connection: Schema.Struct({ kind: Schema.Literal("local") }), + }), + Schema.Struct({ + ...BackendBase.fields, + kind: Schema.Literal("wsl"), + connection: Schema.Struct({ kind: Schema.Literal("wsl-exe"), distro: WslDistroName }), + }), +]); export type Backend = typeof Backend.Type; export const BackendRegistry = Schema.Struct({ From 57eec4ac065f8c2d80fc7fb6c3127ff06b4fe45b Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 30 Jun 2026 23:28:38 -0400 Subject: [PATCH 27/27] docs: refresh adr index review date --- docs/adr/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index d9f1880f..405d9b68 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,7 +8,7 @@ | Audience | Engineers, reviewers, maintainers, and automation agents | | Scope | Durable architecture decisions for JCode boundaries, runtime posture, release strategy, and provider integration | | Canonical path | `docs/adr/README.md` | -| Last reviewed | 2026-06-05 | +| Last reviewed | 2026-06-30 | | Review cadence | Event-driven; add or update ADRs when a decision changes how future work should be done | | Source of truth | ADR files, linked architecture docs, and runtime source | | Verification | Cross-check ADR claims against current source and tests before relying on them |