From 80991402dcdc488838fb4d8b21171bd38d9ab0aa Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:32:25 -0700 Subject: [PATCH 001/113] fix(server): terminal subprocess polling no longer floods the PID space (#6377) --- apps/server/src/terminal/Manager.test.ts | 120 +++++++ apps/server/src/terminal/Manager.ts | 396 ++++++++++------------- 2 files changed, 299 insertions(+), 217 deletions(-) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index ed25a0880b4..47d91e4516e 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -24,6 +24,7 @@ import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; @@ -953,6 +954,125 @@ it.layer( }), ); + it.effect("derives subprocess activity for every terminal from one shared process snapshot", () => + Effect.gen(function* () { + const runCalls: Array<{ command: string; args: ReadonlyArray }> = []; + // FakePtyAdapter assigns pids starting at 9000, so the two terminals + // opened below run as pids 9000 and 9001. + const psStdout = [" 100 9000 vim", " 101 100 git", " 200 9001 /usr/bin/python3"].join( + "\n", + ); + const processRunner: ProcessRunner.ProcessRunner["Service"] = { + run: (input) => + Effect.sync(() => { + runCalls.push({ command: input.command, args: input.args }); + return { + stdout: psStdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }; + + const { manager, getEvents } = yield* createManager(5, { + subprocessPollIntervalMs: 20, + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.provide(withHostPlatform("linux")), + ); + + yield* manager.open(openInput()); + yield* manager.open(openInput({ threadId: "thread-2" })); + + yield* waitFor( + Effect.map( + getEvents, + (events) => + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.label === "vim", + ) && + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.label === "python3", + ), + ), + "1200 millis", + ); + yield* waitFor( + Effect.sync(() => runCalls.length >= 3), + "1200 millis", + ); + + // Every spawn is the shared table snapshot — no per-terminal `pgrep` + // or per-child `ps -p` invocations. + expect(runCalls.every((call) => call.args.join(" ") === "-eo pid=,ppid=,comm=")).toBe(true); + }), + ); + + it.effect("keeps last known subprocess state when the process snapshot fails", () => + Effect.gen(function* () { + let failSnapshots = false; + let failedCalls = 0; + const processRunner: ProcessRunner.ProcessRunner["Service"] = { + run: () => + Effect.sync(() => { + if (failSnapshots) failedCalls += 1; + return { + stdout: failSnapshots ? "" : " 100 9000 vim", + stderr: "", + code: ChildProcessSpawner.ExitCode(failSnapshots ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }; + + const { manager, getEvents } = yield* createManager(5, { + subprocessPollIntervalMs: 20, + }).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.provide(withHostPlatform("linux")), + ); + + yield* manager.open(openInput()); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.label === "vim", + ), + ), + "1200 millis", + ); + + failSnapshots = true; + yield* waitFor( + Effect.sync(() => failedCalls >= 3), + "1200 millis", + ); + + // A failed snapshot is not authoritative: no terminal flips to idle. + const activityEvents = (yield* getEvents).filter((event) => event.type === "activity"); + expect(activityEvents.length).toBeGreaterThan(0); + expect(activityEvents.every((event) => event.hasRunningSubprocess === true)).toBe(true); + }), + ); + it.effect("caps persisted history to configured line limit", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(3); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 6dc9e1892b6..64c2dbb913f 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -89,12 +89,21 @@ class TerminalSubprocessCheckError extends Schema.TaggedErrorClass detail !== null) + .join(", "); + return `Failed to inspect terminal subprocesses with ${this.command}${details.length > 0 ? ` (${details})` : ""}`; } } @@ -610,125 +619,102 @@ function isRetryableShellSpawnError(error: PtyAdapter.PtySpawnError): boolean { ); } -function parseFirstChildPidFromPgrep(stdout: string): number | null { +interface TerminalProcessTableSnapshot { + readonly childrenByParent: ReadonlyMap>; + readonly commandById: ReadonlyMap; +} + +function parsePosixProcessTable(stdout: string): TerminalProcessTableSnapshot { + const childrenByParent = new Map(); + const commandById = new Map(); for (const line of stdout.split(/\r?\n/g)) { - const n = Number.parseInt(line.trim(), 10); - if (Number.isInteger(n) && n > 0) { - return n; - } + // `comm=` is the final column and may itself contain spaces, so only the + // first two tokens are structural. + const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line); + if (!match) continue; + const pid = Number(match[1]); + const ppid = Number(match[2]); + if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; + commandById.set(pid, (match[3] ?? "").trim()); + const children = childrenByParent.get(ppid) ?? []; + children.push(pid); + childrenByParent.set(ppid, children); } - return null; + return { childrenByParent, commandById }; } -function windowsInspectSubprocess( - terminalPid: number, - platform: NodeJS.Platform, -): Effect.Effect< - TerminalSubprocessInspectResult, - TerminalSubprocessCheckError, - ProcessRunner.ProcessRunner -> { - const command = - 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; - return Effect.gen(function* () { - const processRunner = yield* ProcessRunner.ProcessRunner; - return yield* processRunner.run({ - // powershell.exe is a real executable — never spawn it through cmd.exe - // shell mode, which would re-tokenize the `-Command` payload (pipes, - // semicolons) before PowerShell ever sees it. - command: "powershell.exe", - args: ["-NoProfile", "-NonInteractive", "-Command", command], - timeout: "1500 millis", - maxOutputBytes: 32_768, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - }).pipe( - Effect.map((result) => { - if (result.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; - } - const processNameById = new Map(); - const childrenByParent = new Map(); - for (const line of result.stdout.split(/\r?\n/g)) { - const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); - const pid = Number(pidRaw); - const parentPid = Number(parentPidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; - processNameById.set(pid, nameRaw?.trim() ?? ""); - const children = childrenByParent.get(parentPid) ?? []; - children.push(pid); - childrenByParent.set(parentPid, children); - } - const directChildren = childrenByParent.get(terminalPid) ?? []; - const childPid = directChildren[0]; - if (childPid === undefined) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; - } - const processIds = new Set([terminalPid]); - const pending = [terminalPid]; - while (pending.length > 0) { - const parentPid = pending.pop(); - if (parentPid === undefined) continue; - for (const pid of childrenByParent.get(parentPid) ?? []) { - if (processIds.has(pid)) continue; - processIds.add(pid); - pending.push(pid); - } - } - const normalized = normalizeChildCommandName(processNameById.get(childPid) ?? "", platform); - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - processIds: [...processIds], - } as const; - }), - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - cause, - terminalPid, - command: "powershell", - }), - ), - ); +function parseWindowsProcessTable(stdout: string): TerminalProcessTableSnapshot { + const childrenByParent = new Map(); + const commandById = new Map(); + for (const line of stdout.split(/\r?\n/g)) { + const [pidRaw, parentPidRaw, nameRaw] = line.trim().split("|", 3); + const pid = Number(pidRaw); + const parentPid = Number(parentPidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(parentPid)) continue; + commandById.set(pid, nameRaw?.trim() ?? ""); + const children = childrenByParent.get(parentPid) ?? []; + children.push(pid); + childrenByParent.set(parentPid, children); + } + return { childrenByParent, commandById }; } -const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( +function deriveSubprocessInspectResult( + snapshot: TerminalProcessTableSnapshot, terminalPid: number, platform: NodeJS.Platform, +): TerminalSubprocessInspectResult { + const childPid = (snapshot.childrenByParent.get(terminalPid) ?? [])[0]; + if (childPid === undefined) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; + } + const processIds = new Set([terminalPid]); + const pending = [terminalPid]; + while (pending.length > 0) { + const parentPid = pending.pop(); + if (parentPid === undefined) continue; + for (const pid of snapshot.childrenByParent.get(parentPid) ?? []) { + if (processIds.has(pid)) continue; + processIds.add(pid); + pending.push(pid); + } + } + const normalized = normalizeChildCommandName(snapshot.commandById.get(childPid) ?? "", platform); + return { + hasRunningSubprocess: true, + childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + processIds: [...processIds], + }; +} + +const POSIX_PS_ABSOLUTE_PATHS = ["/bin/ps", "/usr/bin/ps"] as const; + +// Resolve `ps` to an absolute path once at startup. Spawning by bare name +// walks every PATH entry per spawn (one failed posix_spawn per directory +// until the hit), which is measurable at a 1s poll cadence on long PATHs. +const resolvePosixPsCommand = Effect.fn("terminal.resolvePosixPsCommand")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + for (const candidate of POSIX_PS_ABSOLUTE_PATHS) { + const exists = yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (exists) return candidate; + } + return "ps"; +}); + +const posixProcessTableSnapshot = Effect.fn("terminal.posixProcessTableSnapshot")(function* ( + psCommand: string, ): Effect.fn.Return< - TerminalSubprocessInspectResult, + TerminalProcessTableSnapshot, TerminalSubprocessCheckError, ProcessRunner.ProcessRunner > { const processRunner = yield* ProcessRunner.ProcessRunner; - const runPgrep = processRunner - .run({ - command: "pgrep", - args: ["-P", String(terminalPid)], - timeout: "1 second", - maxOutputBytes: 32_768, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }) - .pipe( - Effect.mapError( - (cause) => - new TerminalSubprocessCheckError({ - cause, - terminalPid, - command: "pgrep", - }), - ), - ); - - const runPs = processRunner + const result = yield* processRunner .run({ - command: "ps", - args: ["-eo", "pid=,ppid="], + command: psCommand, + args: ["-eo", "pid=,ppid=,comm="], timeout: "1 second", - maxOutputBytes: 262_144, + maxOutputBytes: 524_288, outputMode: "truncate", timeoutBehavior: "timedOutResult", }) @@ -737,120 +723,66 @@ const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(func (cause) => new TerminalSubprocessCheckError({ cause, - terminalPid, command: "ps", }), ), ); - - let childPid: number | null = null; - - const pgrepResult = yield* Effect.exit(runPgrep); - if (pgrepResult._tag === "Success") { - if (pgrepResult.value.code === 0) { - childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); - } else if (pgrepResult.value.code === 1) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - } - - if (childPid === null) { - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Failure" || psResult.value.code !== 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - for (const line of psResult.value.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - if (ppid === terminalPid) { - childPid = pid; - break; - } - } - } - - if (childPid === null) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - - const runComm = processRunner.run({ - command: "ps", - args: ["-p", String(childPid), "-o", "comm="], - timeout: "1 second", - maxOutputBytes: 8_192, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", - }); - - const commResult = yield* Effect.exit(runComm); - let rawComm: string | null = null; - if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { - rawComm = commResult.value.stdout.trim(); - } - - if (!rawComm || rawComm.length === 0) { - const runArgs = processRunner.run({ + if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { + // Not authoritative: an empty or partial table would mark every terminal + // idle and clear its registered process ids. Failing skips the tick. + return yield* new TerminalSubprocessCheckError({ command: "ps", - args: ["-p", String(childPid), "-o", "args="], - timeout: "1 second", - maxOutputBytes: 16_384, - outputMode: "truncate", - timeoutBehavior: "timedOutResult", + exitCode: result.code, + timedOut: result.timedOut, + stdoutTruncated: result.stdoutTruncated, }); - const argsResult = yield* Effect.exit(runArgs); - if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { - const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; - rawComm = first.length > 0 ? first : null; - } } - - const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; - const processIds = new Set([terminalPid]); - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Success" && psResult.value.code === 0) { - const childrenByParent = new Map(); - for (const line of psResult.value.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - const children = childrenByParent.get(ppid) ?? []; - children.push(pid); - childrenByParent.set(ppid, children); - } - const pending = [terminalPid]; - while (pending.length > 0) { - const parentPid = pending.pop(); - if (parentPid === undefined) continue; - for (const child of childrenByParent.get(parentPid) ?? []) { - if (processIds.has(child)) continue; - processIds.add(child); - pending.push(child); - } - } - } else { - processIds.add(childPid); - } - return { - hasRunningSubprocess: true, - childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, - processIds: [...processIds], - }; + return parsePosixProcessTable(result.stdout); }); -function defaultSubprocessInspectorForPlatform(platform: NodeJS.Platform) { - return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { - if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; - } - if (platform === "win32") { - return yield* windowsInspectSubprocess(terminalPid, platform); +const windowsProcessTableSnapshot = Effect.fn("terminal.windowsProcessTableSnapshot")( + function* (): Effect.fn.Return< + TerminalProcessTableSnapshot, + TerminalSubprocessCheckError, + ProcessRunner.ProcessRunner + > { + const command = + 'Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object { Write-Output "$($_.ProcessId)|$($_.ParentProcessId)|$($_.Name)" }'; + const processRunner = yield* ProcessRunner.ProcessRunner; + const result = yield* processRunner + .run({ + // powershell.exe is a real executable — never spawn it through cmd.exe + // shell mode, which would re-tokenize the `-Command` payload (pipes, + // semicolons) before PowerShell ever sees it. + command: "powershell.exe", + args: ["-NoProfile", "-NonInteractive", "-Command", command], + timeout: "1500 millis", + maxOutputBytes: 262_144, + outputMode: "truncate", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.mapError( + (cause) => + new TerminalSubprocessCheckError({ + cause, + command: "powershell", + }), + ), + ); + if (result.code !== 0 || result.timedOut || result.stdoutTruncated) { + // Not authoritative: an empty or partial table would mark every terminal + // idle and clear its registered process ids. Failing skips the tick. + return yield* new TerminalSubprocessCheckError({ + command: "powershell", + exitCode: result.code, + timedOut: result.timedOut, + stdoutTruncated: result.stdoutTruncated, + }); } - return yield* posixInspectSubprocess(terminalPid, platform); - }); -} + return parseWindowsProcessTable(result.stdout); + }, +); function capHistory(history: string, maxLines: number): string { if (history.length === 0) return history; @@ -1227,12 +1159,27 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; - const subprocessInspector = - options.subprocessInspector ?? - ((terminalPid) => - defaultSubprocessInspectorForPlatform(platform)(terminalPid).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - )); + // One process-table snapshot per poll tick, shared across every terminal. + // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and + // can exhaust the PID space on hosts with many sessions (#6332). + const fetchProcessTableSnapshot = ( + platform === "win32" + ? windowsProcessTableSnapshot() + : posixProcessTableSnapshot(yield* resolvePosixPsCommand()) + ).pipe(Effect.provideService(ProcessRunner.ProcessRunner, processRunner)); + const customSubprocessInspector = options.subprocessInspector; + const acquireSubprocessInspector: Effect.Effect< + TerminalSubprocessInspector, + TerminalSubprocessCheckError + > = + customSubprocessInspector !== undefined + ? Effect.succeed(customSubprocessInspector) + : Effect.map( + fetchProcessTableSnapshot, + (snapshot): TerminalSubprocessInspector => + (terminalPid) => + Effect.succeed(deriveSubprocessInspectResult(snapshot, terminalPid, platform)), + ); const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; @@ -2064,6 +2011,21 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return; } + const inspectorOption = yield* acquireSubprocessInspector.pipe( + Effect.map(Option.some), + Effect.catch((reason) => + Effect.logWarning("failed to snapshot processes for terminal subprocess polling", { + reason, + }).pipe(Effect.as(Option.none())), + ), + ); + + if (Option.isNone(inspectorOption)) { + return; + } + + const subprocessInspector = inspectorOption.value; + const checkSubprocessActivity = Effect.fn("terminal.checkSubprocessActivity")(function* ( session: TerminalSessionState & { pid: number }, ) { From 1add47b322ab1dfb5010bb363613650176b88088 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:22:57 +0530 Subject: [PATCH 002/113] fix(web): add copying terminal selection with ctrl+c in the web app (#5638) --- .../src/components/ThreadTerminalDrawer.tsx | 18 ++--- .../settings/SettingsFontPreviews.tsx | 1 - apps/web/src/contextMenuFallback.test.ts | 36 +++++++++- apps/web/src/contextMenuFallback.ts | 27 +++++++ apps/web/src/localApi.test.ts | 10 +++ apps/web/src/localApi.ts | 10 ++- apps/web/src/terminal/ghostty/surface.test.ts | 5 +- apps/web/src/terminal/ghostty/surface.ts | 72 +++++++++++++++++-- packages/contracts/src/ipc.ts | 1 + 9 files changed, 159 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index c59f682c415..87f0ed4ae70 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -440,7 +440,6 @@ export function TerminalViewport({ onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), - onCopy: (text) => handleCopy(text), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), }; @@ -668,17 +667,6 @@ export function TerminalViewport({ })(); } - function handleCopy(text: string): void { - void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) return; - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); - }); - } - function handleData(data: string): void { void (async () => { const result = await writeTerminal(data); @@ -696,6 +684,12 @@ export function TerminalViewport({ return; } clearSelectionAction(); + // A copy shortcut that clears the selection (Ctrl+C) must also close + // the context menu that appears with the selection, but a clear that + // never opened a menu must not dismiss an unrelated one. + if (selectionActionMenuOpenRef.current) { + void localApi?.contextMenu.close(); + } } const handleMouseUp = (event: MouseEvent) => { diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 05ea2c9f04e..a678c2ad554 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -238,7 +238,6 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu onData: echo, onResize: noop, onSelectionChange: noop, - onCopy: (text) => void navigator.clipboard?.writeText(text).catch(noop), // Tab keeps walking the settings page instead of feeding the echo loop. beforeKey: (event) => event.key !== "Tab", onLinkActivate: noop, diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index 29596e72a9f..d36f1a1d11b 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { showContextMenuFallback } from "./contextMenuFallback"; +import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; type FakeListener = (event: FakeDomEvent) => void; @@ -236,3 +236,37 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename:project-b"); }); }); + +describe("dismissContextMenu", () => { + it("resolves an open menu with null", async () => { + const selectionPromise = showContextMenuFallback([ + { id: "rename", label: "Rename" }, + { id: "delete", label: "Delete" }, + ]); + expect(findButton("Rename")).toBeTruthy(); + + dismissContextMenu(); + + await expect(selectionPromise).resolves.toBeNull(); + expect(findButton("Rename")).toBeUndefined(); + }); + + it("is a no-op when no menu is open", async () => { + dismissContextMenu(); + expect(findButton("Rename")).toBeUndefined(); + }); + + it("dismisses the prior menu when a new one opens", async () => { + const firstPromise = showContextMenuFallback([{ id: "first", label: "First" }]); + expect(findButton("First")).toBeTruthy(); + + const secondPromise = showContextMenuFallback([{ id: "second", label: "Second" }]); + + await expect(firstPromise).resolves.toBeNull(); + expect(findButton("First")).toBeUndefined(); + expect(findButton("Second")).toBeTruthy(); + + dismissContextMenu(); + await expect(secondPromise).resolves.toBeNull(); + }); +}); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 50f4340e22d..769826e3999 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -101,6 +101,21 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H return false; } +// Only one fallback menu exists at a time in the renderer; the active one is +// tracked so a state change (for example a terminal selection clearing) can +// dismiss it with the same result as an outside click or Escape. +let activeContextMenuDismiss: (() => void) | null = null; + +/** + * Closes the currently open fallback context menu, resolving its show() with + * null (the same result as dismissing by outside click or Escape). No-op when + * no fallback menu is open. + */ +export function dismissContextMenu(): void { + activeContextMenuDismiss?.(); + activeContextMenuDismiss = null; +} + /** * Imperative DOM-based context menu for non-Electron environments. * Supports nested submenus and resolves with the clicked leaf item id. @@ -114,11 +129,16 @@ export function showContextMenuFallback( let isDisposed = false; let canDismissFromPointer = false; + const dismiss = () => cleanup(null); + const cleanup = (result: T | null) => { if (isDisposed) { return; } isDisposed = true; + if (activeContextMenuDismiss === dismiss) { + activeContextMenuDismiss = null; + } document.removeEventListener("keydown", onKeyDown); document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("contextmenu", onContextMenu, true); @@ -299,6 +319,13 @@ export function showContextMenuFallback( document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("contextmenu", onContextMenu, true); openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); + // Only one fallback menu can be open at a time: a new show must dismiss + // any prior one, or its DOM and listeners leak and close() can only ever + // reach the newest menu. + if (activeContextMenuDismiss) { + activeContextMenuDismiss(); + } + activeContextMenuDismiss = dismiss; requestAnimationFrame(() => { canDismissFromPointer = true; diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 064b927031d..9220252cb20 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -13,12 +13,14 @@ const showContextMenuFallbackMock = position?: { x: number; y: number }, ) => Promise >(); +const dismissContextMenuMock = vi.fn<() => void>(); const requestConfirmDialogMock = vi.fn<(message: string, options?: ConfirmDialogOptions) => Promise | undefined>(); vi.mock("./contextMenuFallback", () => ({ showContextMenuFallback: showContextMenuFallbackMock, + dismissContextMenu: dismissContextMenuMock, })); vi.mock("./confirmDialog", () => ({ @@ -85,6 +87,14 @@ describe("LocalApi", () => { expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); }); + it("dismisses an open browser context menu without a desktop bridge", async () => { + const { createLocalApi } = await import("./localApi"); + + await createLocalApi().contextMenu.close(); + + expect(dismissContextMenuMock).toHaveBeenCalledOnce(); + }); + it("uses the themed confirmation host when it is available", async () => { requestConfirmDialogMock.mockResolvedValue(true); const { createLocalApi } = await import("./localApi"); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 5c8f4ec9da8..863388106a3 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -1,7 +1,7 @@ import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/contracts"; import { requestConfirmDialog } from "./confirmDialog"; -import { showContextMenuFallback } from "./contextMenuFallback"; +import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; @@ -41,6 +41,14 @@ function createBrowserLocalApi(): LocalApi { } return showContextMenuFallback(items, position); }, + // A native desktop menu blocks keyboard input and closes on outside + // interaction, so nothing to do there; the DOM fallback needs an explicit + // dismiss when the state behind it goes away. + close: async () => { + if (!window.desktopBridge) { + dismissContextMenu(); + } + }, }, persistence: { getClientSettings: async () => { diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 31bc47bdff7..18cf9590120 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -219,11 +219,12 @@ describe("isTerminalCopyShortcut", () => { expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); }); - it("uses the conventional Ctrl+Shift+C shortcut elsewhere", () => { - expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false); + it("copies with Ctrl+C and Ctrl+Shift+C elsewhere", () => { + expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(true); expect(isTerminalCopyShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe( true, ); + expect(isTerminalCopyShortcut(event({}), "Linux x86_64")).toBe(false); }); it("uses the produced character instead of the physical key position", () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index fc7a89c6d31..8a9c796b948 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -333,7 +333,7 @@ export function isTerminalCopyShortcut( platform = navigator.platform, ) { if (event.key.toLowerCase() !== "c") return false; - return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey; } export function isTerminalPasteShortcut( @@ -463,7 +463,6 @@ export interface GhosttyTerminalSurfaceOptions { readonly onData: (data: string) => void; readonly onResize: (cols: number, rows: number) => void; readonly onSelectionChange: () => void; - readonly onCopy: (text: string) => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; } @@ -531,6 +530,8 @@ export class GhosttyTerminalSurface { private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set(); private pasteShortcutToken = 0; + private copyShortcutToken = 0; + private clearSelectionAfterCopy = false; private wheelRemainder = 0; private dprMedia: MediaQueryList | null = null; // Read live on every blink decision, and watched so that dropping the @@ -901,9 +902,58 @@ export class GhosttyTerminalSurface { return; } if (isTerminalCopyShortcut(event) && this.hasSelection()) { - event.preventDefault(); + // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in + // onCopyEvent; not preventing the default keeps that path alive. WebKit + // omits the keyboard copy event without a DOM selection, so race the + // clipboard write against it the same way paste races its read. The + // Shift variant has no native event (Chrome binds Ctrl+Shift+C to + // inspect), so synthesize one with execCommand("copy"). + if (event.shiftKey) { + event.preventDefault(); + document.execCommand("copy"); + } else { + // A plain Ctrl+C is also SIGINT on non-mac: clear the selection once + // it copies so the next Ctrl+C reaches the shell. The Shift chord and + // Cmd+C are copy-only, so they keep the selection; resetting the flag + // up front also drops any clear owed by an earlier gesture that never + // completed. + this.clearSelectionAfterCopy = !event.shiftKey && !isMacPlatform(navigator.platform); + const clipboard = navigator.clipboard; + if (typeof clipboard?.writeText === "function") { + // Defer the write past the default action: the native copy event + // (dispatched synchronously with the default action) claims the + // token first when it fires, and the write covers browsers whose + // shortcut produces no copy event. Skipping a write the native + // event already handled stops a stale resolution from clobbering a + // clipboard the user filled after this copy. + const token = ++this.copyShortcutToken; + const selection = this.getSelection(); + void Promise.resolve().then(() => { + if (this.disposed || this.copyShortcutToken !== token) return; + void clipboard.writeText(selection).then( + () => { + // The write may have been superseded while in flight; only + // touch the selection if this gesture still owns the token. + if (this.disposed || this.copyShortcutToken !== token) return; + if (this.clearSelectionAfterCopy) { + this.clearSelectionAfterCopy = false; + this.clearSelection(); + } + }, + () => { + // The write failed and the native event has already had its + // chance, so nothing copied and no clear is owed by this + // gesture; a newer one may have just set the flag, so only + // drop it if this gesture still owns the token. + if (this.copyShortcutToken === token) { + this.clearSelectionAfterCopy = false; + } + }, + ); + }); + } + } this.suppressedKeyCodes.add(event.code); - this.options.onCopy(this.getSelection()); return; } if (isTerminalPasteShortcut(event)) { @@ -989,6 +1039,18 @@ export class GhosttyTerminalSurface { this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange); } + private readonly onCopyEvent = (event: ClipboardEvent) => { + if (!this.hasSelection()) return; + event.preventDefault(); + event.clipboardData?.setData("text/plain", this.getSelection()); + // The native event beat any deferred write; drop the in-flight fallback. + this.copyShortcutToken += 1; + if (this.clearSelectionAfterCopy) { + this.clearSelectionAfterCopy = false; + this.clearSelection(); + } + }; + private readonly onPaste = (event: ClipboardEvent) => { // Always suppress the browser's default insertion: content the textarea // would receive (for example an html-only clipboard converted to text) @@ -1384,6 +1446,7 @@ export class GhosttyTerminalSurface { this.input.addEventListener("blur", this.onBlur); this.input.addEventListener("input", this.onInput); this.input.addEventListener("paste", this.onPaste); + this.input.addEventListener("copy", this.onCopyEvent); this.input.addEventListener("compositionstart", this.onCompositionStart); this.input.addEventListener("compositionend", this.onCompositionEnd); this.canvas.addEventListener("pointerdown", this.onPointerDown); @@ -1408,6 +1471,7 @@ export class GhosttyTerminalSurface { this.input.removeEventListener("blur", this.onBlur); this.input.removeEventListener("input", this.onInput); this.input.removeEventListener("paste", this.onPaste); + this.input.removeEventListener("copy", this.onCopyEvent); this.input.removeEventListener("compositionstart", this.onCompositionStart); this.input.removeEventListener("compositionend", this.onCompositionEnd); this.canvas.removeEventListener("pointerdown", this.onPointerDown); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4e4d4baa13d..f99d4d34b4d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1189,6 +1189,7 @@ export interface LocalApi { items: readonly ContextMenuItem[], position?: { x: number; y: number }, ) => Promise; + close: () => Promise; }; persistence: { getClientSettings: () => Promise; From c9063f03ea1c16e0239e1996a9b6ef611679995d Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:12:39 +0200 Subject: [PATCH 003/113] perf(desktop): speed up Windows update installation (#6169) --- .../src/app/DesktopEnvironment.test.ts | 19 + apps/desktop/src/app/DesktopEnvironment.ts | 14 +- .../DesktopBackendConfiguration.test.ts | 104 ++- .../backend/DesktopBackendConfiguration.ts | 52 +- apps/desktop/src/main.ts | 2 + .../src/wsl/DesktopWslServerTree.test.ts | 323 ++++++++ apps/desktop/src/wsl/DesktopWslServerTree.ts | 226 ++++++ apps/server/package.json | 1 + docs/operations/release.md | 31 + patches/@ff-labs__fff-node@0.9.4.patch | 10 +- pnpm-lock.yaml | 14 +- scripts/build-desktop-artifact.test.ts | 407 +++++++++- scripts/build-desktop-artifact.ts | 707 +++++++++++++++--- scripts/lib/cli-external-packages.test.ts | 53 +- scripts/lib/cli-external-packages.ts | 46 +- scripts/package.json | 1 + 16 files changed, 1822 insertions(+), 188 deletions(-) create mode 100644 apps/desktop/src/wsl/DesktopWslServerTree.test.ts create mode 100644 apps/desktop/src/wsl/DesktopWslServerTree.ts diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 15d23f8e152..218e2c3e4ba 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -65,6 +65,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.rootDir, "/repo"); assert.equal(environment.appRoot, "/repo"); + assert.equal(environment.serverRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); assert.equal(environment.backendCwd, "/repo"); assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); @@ -98,6 +99,24 @@ describe("DesktopEnvironment", () => { }), ); + it.effect("uses the packaged Windows server sidecar as the backend root", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment({ + platform: "win32", + isPackaged: true, + appPath: "/install/resources/app.asar", + resourcesPath: "/install/resources", + }); + + assert.equal(environment.appRoot, "/install/resources/app.asar"); + assert.equal(environment.serverRoot, "/install/resources/server.asar"); + assert.equal( + environment.backendEntryPath, + "/install/resources/server.asar/apps/server/dist/bin.mjs", + ); + }), + ); + it.effect("keeps implicit development state separate from production state", () => Effect.gen(function* () { const development = yield* makeEnvironment( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 1806289a08d..eaf39018712 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -52,6 +52,13 @@ export class DesktopEnvironment extends Context.Service< readonly browserArtifactsDir: string; readonly rootDir: string; readonly appRoot: string; + // Root of the tree containing apps/server/dist and node_modules for the + // backend. Equals appRoot everywhere except packaged Windows, where the + // server tree ships as the resources/server.asar sidecar (see + // scripts/build-desktop-artifact.ts) that the asar-aware + // ELECTRON_RUN_AS_NODE primary reads in place and the WSL backend + // extracts on demand (see DesktopWslServerTree). + readonly serverRoot: string; readonly backendEntryPath: string; readonly backendCwd: string; readonly preloadPath: string; @@ -157,6 +164,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( }); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; + const serverRoot = + input.isPackaged && input.platform === "win32" + ? path.join(input.resourcesPath, "server.asar") + : appRoot; const branding = resolveDesktopAppBranding({ isDevelopment, appVersion: input.appVersion, @@ -198,7 +209,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( browserArtifactsDir: path.join(stateDir, "browser-artifacts"), rootDir, appRoot, - backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), + serverRoot, + backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"), backendCwd: input.isPackaged ? homeDirectory : appRoot, preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 309dbb21d4a..2bbde73abaa 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -17,6 +17,7 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; +import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; const PersistedServerObservabilitySettingsDocument = Schema.Struct({ observability: Schema.Struct({ @@ -115,6 +116,7 @@ const withHarness = ( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(makeEnvironmentLayer(baseDir)), ), ), @@ -153,6 +155,47 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const resourcesPath = `${baseDir}/resources`; + + const config = yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + return yield* configuration.resolvePrimary; + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge( + Layer.succeed( + DesktopWslServerTree.DesktopWslServerTree, + DesktopWslServerTree.DesktopWslServerTree.of({ + ensure: Effect.die("Windows primary must not extract the WSL server tree"), + }), + ), + ), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + appPath: `${resourcesPath}/app.asar`, + platform: "win32", + resourcesPath, + }), + ), + ), + ), + ); + + assert.equal(config.entryPath, `${resourcesPath}/server.asar/apps/server/dist/bin.mjs`); + assert.equal(config.env.ELECTRON_RUN_AS_NODE, "1"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolveWsl reuses the primary's bootstrap token", () => withHarness( Effect.gen(function* () { @@ -173,7 +216,7 @@ describe("DesktopBackendConfiguration", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + const entryPath = path.join(baseDir, "apps/server/dist/bin.mjs"); yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); yield* fileSystem.writeFileString(entryPath, ""); @@ -186,6 +229,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -234,7 +278,7 @@ describe("DesktopBackendConfiguration", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + const entryPath = path.join(baseDir, "apps/server/dist/bin.mjs"); yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); yield* fileSystem.writeFileString(entryPath, ""); @@ -250,6 +294,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -386,6 +431,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge(makeEnvironmentLayer(baseDir)), Layer.provideMerge(failingFileSystemLayer), @@ -427,6 +473,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { @@ -486,6 +533,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -536,6 +584,7 @@ describe("DesktopBackendConfiguration", () => { wslOnly: true, }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), ), @@ -573,6 +622,7 @@ describe("DesktopBackendConfiguration", () => { wslDistro: "Removed-Distro", }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -606,6 +656,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -640,6 +691,49 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolveWsl surfaces sidecar extraction failures through typed preflight", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isFalse(failure.fatal); + assert.equal(failure.retryLimit, 12); + assert.include(failure.reason, "could not be extracted"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge( + DesktopWslServerTree.layerTest({ + result: { + ok: false, + reason: "WSL server files could not be extracted", + fatal: false, + }, + }), + ), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -672,6 +766,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -708,6 +803,7 @@ describe("DesktopBackendConfiguration", () => { wslDistro: "Ubuntu", }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), ), @@ -748,6 +844,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { @@ -793,6 +890,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { @@ -843,6 +941,7 @@ describe("DesktopBackendConfiguration", () => { wslDistro: "Ubuntu", }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), ), @@ -864,6 +963,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layer), // isAvailable on win32 only touches the filesystem, never the spawner, // so a die-stub is enough to satisfy the layer's deps. diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index bfb9d6900e5..bcce731a595 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -19,6 +19,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; +import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass()( "DesktopBackendObservabilitySettingsReadError", @@ -424,10 +425,12 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl never, | DesktopEnvironment.DesktopEnvironment | DesktopWslEnvironment.DesktopWslEnvironment + | DesktopWslServerTree.DesktopWslServerTree | FileSystem.FileSystem > { const environment = yield* DesktopEnvironment.DesktopEnvironment; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; // Bind to 0.0.0.0 inside WSL so the backend is reachable both via // WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1 @@ -464,31 +467,31 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...buildObservabilityFragment(input.observabilitySettings), }; - // In packaged builds environment.appRoot is .../resources/app.asar — an - // archive FILE. The Windows primary reads its entry through - // ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain - // `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks - // the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts) - // to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is - // already a real directory, so this is a no-op. - const wslAppRoot = environment.isPackaged - ? environment.path.join(environment.resourcesPath, "app.asar.unpacked") - : environment.appRoot; + // In packaged builds the server tree ships inside resources/server.asar — + // an archive FILE the Windows primary reads through ELECTRON_RUN_AS_NODE + // (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which + // can't read an asar, so materialize (or reuse) the extracted copy of the + // sidecar before preflighting. In dev the server tree is the real checkout + // directory and ensure returns it unchanged. + const serverTree = yield* wslServerTree.ensure; + const wslAppRoot = serverTree.ok ? serverTree.root : environment.serverRoot; const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs"); - const preflight = yield* runWslPreflight({ - distro: input.distro, - windowsEntryPath: wslEntryPath, - windowsRepoRoot: wslAppRoot, - // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and - // attached to the Windows artifact — see build-desktop-artifact.ts), so the - // WSL backend never needs a compiler, node-gyp, or network on first launch. - // Compiling from source is a dev-only convenience: a checkout has no shipped - // prebuilt, and developers have the toolchain. In packaged builds we instead - // surface a clear diagnostic if the prebuilt can't load (unsupported - // arch/distro), rather than silently dropping into a fragile runtime build. - allowBuild: !environment.isPackaged, - }); + const preflight = serverTree.ok + ? yield* runWslPreflight({ + distro: input.distro, + windowsEntryPath: wslEntryPath, + windowsRepoRoot: wslAppRoot, + // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and + // attached to the Windows artifact — see build-desktop-artifact.ts), so the + // WSL backend never needs a compiler, node-gyp, or network on first launch. + // Compiling from source is a dev-only convenience: a checkout has no shipped + // prebuilt, and developers have the toolchain. In packaged builds we instead + // surface a clear diagnostic if the prebuilt can't load (unsupported + // arch/distro), rather than silently dropping into a fragile runtime build. + allowBuild: !environment.isPackaged, + }) + : ({ _tag: "Failed", reason: serverTree.reason, fatal: serverTree.fatal } as const); // Every operation after preflight uses the same concrete distro. In // default-tracking mode this closes the race where the system default @@ -610,6 +613,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const settings = yield* DesktopAppSettings.DesktopAppSettings; const crypto = yield* Crypto.Crypto; // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. @@ -665,6 +669,7 @@ export const make = Effect.gen(function* () { }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), + Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), Effect.provideService(FileSystem.FileSystem, fileSystem), ); }); @@ -727,6 +732,7 @@ export const make = Effect.gen(function* () { return yield* resolveWslStartConfig({ ...shared, ...input }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), + Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), Effect.provideService(FileSystem.FileSystem, fileSystem), ); }).pipe( diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0616184ec74..14caeed8a9a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -62,6 +62,7 @@ import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; +import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { @@ -165,6 +166,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), Layer.provideMerge(DesktopWslEnvironment.layer), + Layer.provideMerge(DesktopWslServerTree.layer), Layer.provideMerge(DesktopTelemetryPublisher.layer), Layer.provideMerge(desktopWindowLayer), ); diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts new file mode 100644 index 00000000000..8c1a5b020b1 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts @@ -0,0 +1,323 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopWslServerTree from "./DesktopWslServerTree.ts"; + +// The service reads packaged Windows roots through the (asar-aware, in +// Electron) fs, so a plain directory named server.asar exercises the full +// extraction path under plain Node. + +const environmentLayer = (input: { + readonly baseDir: string; + readonly resourcesPath: string; + readonly appVersion?: string; + readonly isPackaged?: boolean; +}) => + DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: input.baseDir, + platform: "win32", + processArch: "x64", + appVersion: input.appVersion ?? "1.2.3", + appPath: "/repo", + isPackaged: input.isPackaged ?? true, + resourcesPath: input.resourcesPath, + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: input.baseDir, + T3CODE_MODE: "desktop", + }), + ), + ), + ); + +const withTempDir = ( + run: (tempDir: string) => Effect.Effect, +): Effect.Effect< + A, + E | PlatformError.PlatformError, + FileSystem.FileSystem | Exclude +> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-wsl-server-tree-test-", + }); + return yield* run(tempDir); + }).pipe(Effect.scoped); + +const ensureWith = (input: { + readonly baseDir: string; + readonly resourcesPath: string; + readonly appVersion?: string; + readonly isPackaged?: boolean; +}) => + Effect.gen(function* () { + const tree = yield* DesktopWslServerTree.DesktopWslServerTree; + return yield* tree.ensure; + }).pipe( + Effect.provide(DesktopWslServerTree.layer.pipe(Layer.provideMerge(environmentLayer(input)))), + ); + +describe("DesktopWslServerTree", () => { + it.effect("bounds entry work across an eight-way nested tree", () => + Effect.gen(function* () { + const active = yield* Ref.make(0); + const maxActive = yield* Ref.make(0); + const visited = yield* Ref.make(0); + + yield* DesktopWslServerTree.forEachBoundedTree([{ depth: 0, id: "root" }], (node) => + Effect.acquireUseRelease( + Effect.gen(function* () { + const current = yield* Ref.updateAndGet(active, (count) => count + 1); + yield* Ref.update(maxActive, (maximum) => Math.max(maximum, current)); + yield* Ref.update(visited, (count) => count + 1); + }), + () => + Effect.gen(function* () { + // Give every task in the current batch a chance to overlap. + yield* Effect.yieldNow; + if (node.depth === 4) return []; + return Array.from({ length: 8 }, (_, index) => ({ + depth: node.depth + 1, + id: `${node.id}.${String(index)}`, + })); + }), + () => Ref.update(active, (count) => count - 1), + ), + ); + + assert.equal(yield* Ref.get(active), 0); + assert.equal(yield* Ref.get(maxActive), 8); + assert.equal(yield* Ref.get(visited), 4_681); + }), + ); + + it.effect("returns the server root unchanged when it is a plain directory (dev)", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const result = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: tempDir, + isPackaged: false, + }); + assert.isTrue(result.ok); + assert.isFalse(result.ok && result.root.endsWith(".asar")); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("extracts an archive root into a version-keyed state directory", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(serverRoot, "apps/server/dist/bin.mjs"), + "server-entry", + ); + yield* fileSystem.makeDirectory(path.join(serverRoot, "node_modules/effect"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(serverRoot, "node_modules/effect/package.json"), + "{}", + ); + + const result = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + + assert.isTrue(result.ok); + const root = result.ok ? result.root : ""; + assert.include(root, path.join("wsl-server-tree", "1.2.3")); + const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs")); + assert.equal(entry, "server-entry"); + const dep = yield* fileSystem.exists(path.join(root, "node_modules/effect/package.json")); + assert.isTrue(dep); + const marker = yield* fileSystem.readFileString( + path.join(root, "t3code-wsl-server-tree.json"), + ); + assert.include(marker, '"version":"1.2.3"'); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("serializes concurrent extraction callers and publishes one complete tree", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resourcesPath = path.join(tempDir, "resources"); + const serverRoot = path.join(resourcesPath, "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(serverRoot, "apps/server/dist/bin.mjs"), + "server-entry", + ); + + const results = yield* Effect.gen(function* () { + const tree = yield* DesktopWslServerTree.DesktopWslServerTree; + return yield* Effect.all([tree.ensure, tree.ensure], { concurrency: "unbounded" }); + }).pipe( + Effect.provide( + DesktopWslServerTree.layer.pipe( + Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })), + ), + ), + ); + + assert.isTrue(results.every((result) => result.ok)); + const roots = results.flatMap((result) => (result.ok ? [result.root] : [])); + assert.lengthOf(new Set(roots), 1); + assert.equal( + yield* fileSystem.readFileString(path.join(roots[0] ?? "", "apps/server/dist/bin.mjs")), + "server-entry", + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reuses a completed extraction instead of copying again", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "v1"); + + const first = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isTrue(first.ok); + + // Mutate the source; a reused tree must keep the first copy. + yield* fileSystem.writeFileString( + path.join(serverRoot, "apps/server/dist/bin.mjs"), + "v2-should-not-appear", + ); + const second = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isTrue(second.ok); + const root = second.ok ? second.root : ""; + const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs")); + assert.equal(entry, "v1"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("sweeps stale version directories and leftover partials after extraction", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "x"); + + // T3CODE_HOME is set to tempDir, so the desktop state dir resolves to + // /userdata (no .t3 segment). + const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree"); + yield* fileSystem.makeDirectory(path.join(treeRoot, "1.0.0"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(treeRoot, "1.2.3.partial"), { recursive: true }); + + const result = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isTrue(result.ok); + assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.0.0"))); + assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.2.3.partial"))); + assert.isTrue(yield* fileSystem.exists(path.join(treeRoot, "1.2.3"))); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("re-extracts when the app version changes", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "old"); + + const first = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + appVersion: "1.2.3", + }); + assert.isTrue(first.ok); + + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "new"); + const second = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + appVersion: "1.2.4", + }); + assert.isTrue(second.ok); + const root = second.ok ? second.root : ""; + assert.include(root, "1.2.4"); + const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs")); + assert.equal(entry, "new"); + // The previous version's tree is gone. + const treeRoot = path.dirname(root); + assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.2.3"))); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports a retryable failure when the archive cannot be read", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* ensureWith({ + baseDir: tempDir, + // resources dir exists but server.asar does not + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isFalse(result.ok); + if (!result.ok) { + assert.include(result.reason, "could not be extracted"); + assert.isFalse(result.fatal); + } + const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree"); + const leftovers = yield* fileSystem + .readDirectory(treeRoot) + .pipe(Effect.orElseSucceed(() => [])); + assert.deepStrictEqual(leftovers, []); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.ts b/apps/desktop/src/wsl/DesktopWslServerTree.ts new file mode 100644 index 00000000000..0b87f7bf1fe --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslServerTree.ts @@ -0,0 +1,226 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +// Packaged Windows builds ship the server tree inside resources/server.asar +// (see scripts/build-desktop-artifact.ts). The Windows primary reads it in +// place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL +// backend launches plain `wsl.exe -- node`, which cannot read an asar +// archive. This service materializes the archive into a real, version-keyed +// directory the first time the WSL backend starts, and reuses it afterwards — +// so only users who enable WSL ever pay for a loose copy of the server tree. +// +// Reading through Electron's patched fs also transparently returns the +// contents of files that electron-builder/asar left in the server.asar.unpacked +// sibling (native binaries), so a single walk of the archive yields the +// complete tree. + +export type WslServerTreeResult = + | { readonly ok: true; readonly root: string } + | { readonly ok: false; readonly reason: string; readonly fatal: boolean }; + +const MARKER_FILE_NAME = "t3code-wsl-server-tree.json"; +const COPY_CONCURRENCY = 8; + +const Marker = Schema.Struct({ version: Schema.String }); +const decodeMarker = Schema.decodeUnknownEffect(Schema.fromJsonString(Marker)); +const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker)); + +export class DesktopWslServerTreeExtractError extends Schema.TaggedErrorClass()( + "DesktopWslServerTreeExtractError", + { + targetDir: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to extract the WSL server tree to ${this.targetDir}.`; + } +} + +export class DesktopWslServerTree extends Context.Service< + DesktopWslServerTree, + { + // Resolves the directory the WSL backend should treat as the app root + // (the directory containing apps/server/dist and node_modules). In dev + // the checkout already is that directory; packaged Windows builds extract + // server.asar on first use. + readonly ensure: Effect.Effect; + } +>()("@t3tools/desktop/wsl/DesktopWslServerTree") {} + +// Child scheduling stays here instead of inside `visit`, so nested directories +// cannot create independent concurrency pools. The LIFO work list also keeps +// traversal memory proportional to the remaining frontier rather than the +// number of active fibers. +export const forEachBoundedTree = ( + roots: ReadonlyArray, + visit: (node: Node) => Effect.Effect, E, R>, +): Effect.Effect => + Effect.gen(function* () { + const pending = [...roots]; + while (pending.length > 0) { + const batch = pending.splice(-COPY_CONCURRENCY); + const children = yield* Effect.forEach(batch, visit, { + concurrency: COPY_CONCURRENCY, + }); + for (const entries of children) { + pending.push(...entries); + } + } + }); + +interface CopyTreeEntry { + readonly sourcePath: string; + readonly targetPath: string; +} + +// Copy using only operations supported by Electron's asar-patched fs. Symlinks +// are not expected because the sidecar is installed with a hoisted, physical +// layout; anything that is neither a file nor a directory is skipped. +const copyTree = ( + fs: FileSystem.FileSystem, + join: (first: string, ...rest: string[]) => string, + from: string, + to: string, +): Effect.Effect => + forEachBoundedTree( + [{ sourcePath: from, targetPath: to }], + ({ sourcePath, targetPath }) => + Effect.gen(function* () { + const info = yield* fs.stat(sourcePath); + if (info.type === "Directory") { + yield* fs.makeDirectory(targetPath, { recursive: true }); + const entries = yield* fs.readDirectory(sourcePath); + return entries.map((entry) => ({ + sourcePath: join(sourcePath, entry), + targetPath: join(targetPath, entry), + })); + } + if (info.type === "File") { + // Read and write stay in the same bounded task, so at most eight file + // buffers can be retained while their writes complete. + const bytes = yield* fs.readFile(sourcePath); + yield* fs.writeFile(targetPath, bytes); + } + return []; + }), + ); + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fs = yield* FileSystem.FileSystem; + const join = environment.path.join; + + const serverRoot = environment.serverRoot; + const needsExtraction = environment.isPackaged && environment.platform === "win32"; + const treeRoot = join(environment.stateDir, "wsl-server-tree"); + const version = environment.appVersion; + const versionDir = join(treeRoot, version); + + // Remove sibling trees left behind by previous app versions (and aborted + // extractions). Best-effort: a locked file must not block the backend. + const sweepStale = Effect.gen(function* () { + const entries = yield* fs.readDirectory(treeRoot).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + entries.filter((entry) => entry !== version), + (entry) => fs.remove(join(treeRoot, entry), { recursive: true }).pipe(Effect.ignore), + { discard: true }, + ); + }); + + const markerMatches = Effect.gen(function* () { + const raw = yield* fs.readFileString(join(versionDir, MARKER_FILE_NAME)); + const marker = yield* decodeMarker(raw); + return marker.version === version; + }).pipe(Effect.orElseSucceed(() => false)); + + const extract = Effect.gen(function* () { + yield* Effect.log(`[wsl-server-tree] Extracting ${serverRoot} to ${versionDir}...`); + yield* fs.makeDirectory(treeRoot, { recursive: true }); + // Keep the temporary tree beside the target so rename is atomic. Cleanup + // is owned explicitly because a scoped temp-directory finalizer treats the + // successful rename (and therefore missing original path) as an error. + const partialDir = yield* fs.makeTempDirectory({ + directory: treeRoot, + prefix: `.${version}.extract-`, + }); + yield* Effect.gen(function* () { + yield* copyTree(fs, join, serverRoot, partialDir); + const markerJson = yield* encodeMarker({ version }); + yield* fs.writeFileString(join(partialDir, MARKER_FILE_NAME), `${markerJson}\n`); + // The marker is written before the rename, so a directory named after + // the version is complete by construction. + yield* fs.remove(versionDir, { recursive: true }).pipe(Effect.ignore); + yield* fs.rename(partialDir, versionDir); + }).pipe( + Effect.ensuring(fs.remove(partialDir, { recursive: true, force: true }).pipe(Effect.ignore)), + ); + yield* Effect.log(`[wsl-server-tree] Extraction complete at ${versionDir}.`); + }).pipe( + Effect.mapError( + (cause) => new DesktopWslServerTreeExtractError({ targetDir: versionDir, cause }), + ), + ); + + // Serialize concurrent ensure calls (backend restarts can overlap): the + // first caller extracts, later callers see the marker and reuse the tree. + const gate = yield* Semaphore.make(1); + + const ensure: Effect.Effect = gate + .withPermits(1)( + Effect.gen(function* () { + if (!needsExtraction) { + return { ok: true, root: serverRoot } as const; + } + if (yield* markerMatches) { + yield* sweepStale; + return { ok: true, root: versionDir } as const; + } + const result = yield* extract.pipe( + Effect.map(() => ({ ok: true, root: versionDir }) as const), + // Retryable: transient antivirus locks and slow disks are the common + // causes, and the backend manager already bounds preflight retries. + Effect.catch((error) => + Effect.succeed({ + ok: false, + reason: `WSL server files could not be extracted to ${versionDir}: ${ + error.cause instanceof Error ? error.cause.message : String(error.cause) + }`, + fatal: false, + } as const), + ), + ); + if (result.ok) { + yield* sweepStale; + } + return result; + }), + ) + .pipe(Effect.withSpan("desktop.wslServerTree.ensure")); + + return DesktopWslServerTree.of({ ensure }); +}); + +export const layer = Layer.effect(DesktopWslServerTree, make); + +export interface DesktopWslServerTreeTestStub { + readonly result?: WslServerTreeResult; +} + +export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) => + Layer.effect( + DesktopWslServerTree, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return DesktopWslServerTree.of({ + ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }), + }); + }), + ); diff --git a/apps/server/package.json b/apps/server/package.json index 7a508a38eff..eb4dc7dd35e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -31,6 +31,7 @@ "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", + "msgpackr-extract": "3.0.4", "node-pty": "^1.1.0", "yaml": "catalog:" }, diff --git a/docs/operations/release.md b/docs/operations/release.md index 1d8768f59d0..1cec84054cb 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -214,6 +214,37 @@ desktop-managed guidance when those environments are available. - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon. - The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release. +### Windows payload topology and update validation + +Windows packages the bundled server and only its runtime-external/native +dependency closure in `resources/server.asar`. Native modules and helper +executables declared as unpacked by that archive must be present at the matching +paths below `resources/server.asar.unpacked`. The Windows-native backend reads +the archive in place through Electron. WSL cannot read ASAR files, so enabling +the WSL backend extracts the server tree once into the desktop state directory +under `wsl-server-tree/` and reuses the completed version until the app +is updated. + +The artifact builder rejects a Windows package when any of these invariants +break: + +- `resources/server.asar` is absent or does not contain the server entry. +- Any file marked unpacked in the ASAR header is absent from + `resources/server.asar.unpacked`. +- On same-architecture Windows builds, the packaged primary cannot load the fff + native library from inside `server.asar` through its `.unpacked` sibling. +- The isolated, extracted sidecar cannot load the server entry with plain Node. +- The external Windows resource monitor is absent. +- The unpacked Windows application contains more than 80 files. + +Cross-architecture Windows builds retain every structural and extracted-sidecar +check, but skip executing the target Electron binary. A same-architecture build +for each release target must exercise the primary native-load probe. + +NSIS differential packaging remains enabled. A sidecar layout transition can +produce a larger one-time download; subsequent small releases retain their +blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update. + ## 0) npm OIDC trusted publishing setup (CLI) The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That diff --git a/patches/@ff-labs__fff-node@0.9.4.patch b/patches/@ff-labs__fff-node@0.9.4.patch index 2d0c16133eb..74c132926d9 100644 --- a/patches/@ff-labs__fff-node@0.9.4.patch +++ b/patches/@ff-labs__fff-node@0.9.4.patch @@ -11,16 +11,18 @@ index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef1 import { fileURLToPath } from "node:url"; import { getLibFilename, getNpmPackageName } from "./platform.js"; /** -@@ -46,6 +46,14 @@ function getPackageDir() { +@@ -46,6 +46,16 @@ function getPackageDir() { // Fallback: assume we're one level deep in src/ return dirname(currentDir); } +function resolveUnpackedAsarPath(binaryPath) { -+ const asarSegment = `${sep}app.asar${sep}`; -+ if (!binaryPath.includes(asarSegment)) { ++ const pathSegments = binaryPath.split(sep); ++ const asarIndex = pathSegments.findLastIndex((segment) => segment.endsWith(".asar")); ++ if (asarIndex === -1) { + return binaryPath; + } -+ const unpackedPath = binaryPath.replace(asarSegment, `${sep}app.asar.unpacked${sep}`); ++ pathSegments[asarIndex] = `${pathSegments[asarIndex]}.unpacked`; ++ const unpackedPath = pathSegments.join(sep); + return existsSync(unpackedPath) ? unpackedPath : binaryPath; +} /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7eab1715c13..2c79aea36a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,7 +75,7 @@ patchedDependencies: '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 - '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 + '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 @@ -463,7 +463,7 @@ importers: version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@ff-labs/fff-node': specifier: 0.9.4 - version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) + version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368) '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -473,6 +473,9 @@ importers: effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + msgpackr-extract: + specifier: 3.0.4 + version: 3.0.4 node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -913,6 +916,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@electron/asar': + specifier: ^3.4.1 + version: 3.4.1 '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -12789,7 +12795,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.9.4': optional: true - '@ff-labs/fff-node@0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8)': + '@ff-labs/fff-node@0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)': dependencies: ffi-rs: 1.3.2 optionalDependencies: @@ -19143,7 +19149,6 @@ snapshots: '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 - optional: true msgpackr@2.0.4: optionalDependencies: @@ -19237,7 +19242,6 @@ snapshots: node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 - optional: true node-gyp-build@4.8.4: optional: true diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 6b04d658708..2b9fd3e0258 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -2,15 +2,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { + BundleNotSelfContainedError, BuildCommandFailedError, createStageWorkspaceConfig, createStagePatchedDependencies, @@ -26,6 +27,7 @@ import { LinuxIconResizeError, MacPasskeySigningConfigurationResolutionError, MissingMacPasskeyProvisioningProfileError, + packWindowsServerAsar, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, resolveMacPasskeySigningConfiguration, @@ -44,9 +46,17 @@ import { resolvePackageManagerUserAgent, stageLinuxIconSize, STAGE_INSTALL_ARGS, - WINDOWS_ASAR_UNPACK, ancestorNodeModulesPaths, copyDirectoryPreservingSymlinks, + validateWindowsPackagedPayload, + WindowsPrimaryNativeProbeError, + WindowsPackagedPayloadValidationError, + WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT, + WINDOWS_SERVER_ASAR_IGNORE_GLOBS, + WINDOWS_SERVER_EXTRA_RESOURCES, + WINDOWS_SERVER_ASAR_RESOURCE, + WINDOWS_SERVER_ASAR_UNPACK_GLOB, + WINDOWS_SERVER_RESOURCE_SOURCE_DIR, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -88,6 +98,54 @@ function iconResizeSpawnerLayer( ); } +const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: { + readonly copyUnpackedNatives: boolean; + readonly serverEntrySource?: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-windows-payload-test-", + }); + const sourceDir = path.join(tempDir, "server-source"); + const serverEntryPath = path.join(sourceDir, "apps/server/dist/bin.mjs"); + const nativePath = path.join(sourceDir, "node_modules/native/addon.node"); + yield* fs.makeDirectory(path.dirname(serverEntryPath), { recursive: true }); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString(serverEntryPath, input.serverEntrySource ?? "console.log('server');\n"); + yield* fs.writeFileString(nativePath, "native-binary"); + + const generatedAsarPath = path.join(tempDir, WINDOWS_SERVER_ASAR_RESOURCE); + yield* packWindowsServerAsar({ sourceDir, asarPath: generatedAsarPath }); + + const stageDistDir = path.join(tempDir, "dist"); + const packagedAppDir = path.join(stageDistDir, "win-unpacked"); + const resourcesDir = path.join(packagedAppDir, "resources"); + yield* fs.makeDirectory(path.join(resourcesDir, "resource-monitor"), { recursive: true }); + yield* fs.copyFile(generatedAsarPath, path.join(resourcesDir, WINDOWS_SERVER_ASAR_RESOURCE)); + if (input.copyUnpackedNatives) { + yield* fs.copy( + `${generatedAsarPath}.unpacked`, + path.join(resourcesDir, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked`), + ); + } + yield* fs.writeFileString( + path.join(resourcesDir, "resource-monitor/t3-resource-monitor.exe"), + "monitor", + ); + const appExecutableName = "t3code.exe"; + yield* fs.writeFileString(path.join(packagedAppDir, appExecutableName), "electron"); + yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad"); + + return { + stageDistDir, + packagedAppDir, + sourceDir, + generatedAsarPath, + appExecutableName, + } as const; +}); + it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("resolves the dedicated nightly updater channel from nightly versions", () => { assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); @@ -232,22 +290,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { libc: ["glibc"], }, }); - // Windows artifacts also bundle the same-architecture WSL (Linux, glibc) backend, so the - // staged install must fetch its native optional deps (e.g. ffi-rs) too. + // The Windows app stage only serves the desktop main process; the server + // sidecar stage is the one that needs Linux natives (below). assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), { supportedArchitectures: { - os: ["win32", "linux"], + os: ["win32"], cpu: ["x64"], - libc: ["glibc"], }, }); - assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), { - supportedArchitectures: { - os: ["win32", "linux"], - cpu: ["arm64"], - libc: ["glibc"], + // The server sidecar stage bundles the same-architecture WSL (Linux, + // glibc) backend, so its install must fetch Linux native optional deps + // (e.g. ffi-rs) too — and must be hoisted so the tree survives asar + // packing and runtime extraction without symlinks. + assert.deepStrictEqual( + createStageWorkspaceConfig({ platform: "win", arch: "x64", linuxServerBackend: true }), + { + supportedArchitectures: { + os: ["win32", "linux"], + cpu: ["x64"], + libc: ["glibc"], + }, + nodeLinker: "hoisted", }, - }); + ); + assert.deepStrictEqual( + createStageWorkspaceConfig({ platform: "win", arch: "arm64", linuxServerBackend: true }), + { + supportedArchitectures: { + os: ["win32", "linux"], + cpu: ["arm64"], + libc: ["glibc"], + }, + nodeLinker: "hoisted", + }, + ); assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), { supportedArchitectures: { os: ["darwin"], @@ -317,6 +393,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]); assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [ "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", + "!apps/desktop/prod-resources/windows-server", + "!apps/desktop/prod-resources/windows-server/**/*", + ]); + assert.equal(WINDOWS_SERVER_RESOURCE_SOURCE_DIR, "apps/desktop/prod-resources/windows-server"); + assert.deepStrictEqual(WINDOWS_SERVER_EXTRA_RESOURCES, [ + { + from: "apps/desktop/prod-resources/windows-server", + to: ".", + filter: ["server.asar", "server.asar.unpacked/**/*"], + }, ]); }); @@ -350,9 +436,33 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { undefined, ); + // All platforms keep app.asar fully packed; Windows ships the server + // tree as the hand-packed server.asar sidecar in extraResources instead + // of unpacking thousands of loose files at install time. assert.notProperty(mac, "asarUnpack"); assert.notProperty(linux, "asarUnpack"); - assert.deepStrictEqual(win.asarUnpack, WINDOWS_ASAR_UNPACK); + assert.notProperty(win, "asarUnpack"); + assert.deepStrictEqual(win.extraResources, [ + { + from: "apps/desktop/prod-resources/resource-monitor", + to: "resource-monitor", + }, + ...WINDOWS_SERVER_EXTRA_RESOURCES, + ]); + assert.deepStrictEqual(win.nsis, { differentialPackage: true }); + // Native binaries and helper executables cannot load from inside an + // asar; everything else stays packed. The Claude SDK platform packages + // and .bin shims never ship. + assert.equal( + WINDOWS_SERVER_ASAR_UNPACK_GLOB, + "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}", + ); + assert.deepStrictEqual(WINDOWS_SERVER_ASAR_IGNORE_GLOBS, [ + "**/node_modules/@anthropic-ai/claude-agent-sdk-*", + "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**", + "**/node_modules/.bin", + "**/node_modules/.bin/**", + ]); // Linux must register the renderer schemes so the generated .desktop // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links. assert.deepStrictEqual((linux.linux as Record).protocols, [ @@ -365,6 +475,275 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); + it.effect("validates every ASAR-unpacked native in the packaged Windows payload", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const result = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }); + + const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar"); + yield* packWindowsServerAsar({ + sourceDir: fixture.sourceDir, + asarPath: secondAsarPath, + }); + const [firstAsar, secondAsar] = yield* Effect.all([ + fs.readFile(fixture.generatedAsarPath), + fs.readFile(secondAsarPath), + ]); + + assert.equal(result.packagedAppDir, fixture.packagedAppDir); + assert.deepStrictEqual(result.unpackedFiles, ["node_modules/native/addon.node"]); + assert.isBelow(result.fileCount, WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT); + assert.deepStrictEqual(secondAsar, firstAsar); + }), + ), + ); + + it.effect("probes fff through the packaged Windows primary instead of helper executables", () => { + const commands: Array<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly options: { + readonly cwd?: string; + readonly env?: Readonly>; + }; + }> = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + commands.push(command as unknown as (typeof commands)[number]); + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }); + + const primaryProbe = commands.find( + (command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1", + ); + if (primaryProbe === undefined) return assert.fail("Windows primary probe was not spawned"); + + assert.equal( + primaryProbe.command, + path.join(fixture.packagedAppDir, fixture.appExecutableName), + ); + assert.deepStrictEqual(primaryProbe.args.slice(0, 3), [ + "--no-global-search-paths", + "--input-type=module", + "--eval", + ]); + assert.include(primaryProbe.args[3], "FileFinder.create"); + assert.equal( + primaryProbe.args[4], + path.join( + fixture.packagedAppDir, + "resources/server.asar/node_modules/@ff-labs/fff-node/dist/src/index.js", + ), + ); + assert.equal(primaryProbe.options.cwd, fixture.packagedAppDir); + assert.equal(primaryProbe.options.env?.NODE_PATH, ""); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + spawnerLayer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), + ); + }); + + it.effect("skips the primary native probe for cross-architecture Windows payloads", () => { + const commands: Array<{ + readonly command: string; + readonly options: { + readonly env?: Readonly>; + }; + }> = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + commands.push(command as unknown as (typeof commands)[number]); + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "arm64", + }); + + assert.isFalse( + commands.some((command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1"), + ); + assert.isTrue( + commands.some( + (command) => + command.command === process.execPath && command.options.env?.NODE_PATH === "", + ), + ); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + spawnerLayer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), + ); + }); + + it.effect("rejects a cross-architecture Windows payload without its primary executable", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const executablePath = path.join(fixture.packagedAppDir, fixture.appExecutableName); + yield* fs.remove(executablePath); + + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "arm64", + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPrimaryNativeProbeError); + assert.equal(error.executablePath, executablePath); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), + ), + ); + + it.effect("rejects a packaged sidecar whose ASAR-unpacked native is missing", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: false }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "unpacked-native-missing"); + assert.deepStrictEqual(error.missingFiles, [ + "server.asar.unpacked/node_modules/native/addon.node", + ]); + }), + ), + ); + + it.effect("rejects directories in place of packaged executable files", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const nativePath = path.join( + fixture.packagedAppDir, + "resources/server.asar.unpacked/node_modules/native/addon.node", + ); + yield* fs.remove(nativePath); + yield* fs.makeDirectory(nativePath); + + const nativeError = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError); + assert.equal(nativeError.reason, "unpacked-native-missing"); + assert.deepStrictEqual(nativeError.missingFiles, [ + "server.asar.unpacked/node_modules/native/addon.node", + ]); + + yield* fs.remove(nativePath, { recursive: true }); + yield* fs.writeFileString(nativePath, "native-binary"); + const resourceMonitorPath = path.join( + fixture.packagedAppDir, + "resources/resource-monitor/t3-resource-monitor.exe", + ); + yield* fs.remove(resourceMonitorPath); + yield* fs.makeDirectory(resourceMonitorPath); + + const resourceMonitorError = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError); + assert.equal(resourceMonitorError.reason, "resource-monitor-missing"); + assert.deepStrictEqual(resourceMonitorError.missingFiles, [ + "resource-monitor/t3-resource-monitor.exe", + ]); + }), + ), + ); + + it.effect("rejects a Windows payload that regresses above the file-count budget", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + fileLimit: 2, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "file-limit-exceeded"); + assert.isAbove(error.fileCount ?? 0, 2); + }), + ), + ); + + it.effect("rejects a sidecar whose extracted server bundle cannot resolve", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + serverEntrySource: 'import "t3code-deliberately-missing-package";\n', + }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + + assert.instanceOf(error, BundleNotSelfContainedError); + assert.include(error.output, "t3code-deliberately-missing-package"); + }), + ), + ); + it.effect("preserves both Linux icon resize failures with structural context", () => { const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; @@ -773,7 +1152,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); // The self-containment check runs the packaged tree in a scratch directory. Its -// own node_modules holds the unpacked externals and must be ignored, but any +// own node_modules holds the sidecar externals and must be ignored, but any // node_modules *above* it would let Node's parent walk satisfy an import that is // missing from the package, so the probe refuses to run in that case. it("lists ancestor node_modules, nearest first, excluding the start directory", () => { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index c86f0c38cb5..0cda9766f37 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -4,8 +4,16 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeModule from "node:module"; +import { + createPackageWithOptions, + extractAll, + getRawHeader, + statFile, + type DirectoryRecord, +} from "@electron/asar"; + import { fromYaml } from "@t3tools/shared/schemaYaml"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import rootPackageJson from "../package.json" with { type: "json" }; @@ -20,8 +28,8 @@ import { } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { - CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, findInlinedExternalPackages, + selectCliRuntimeExternalDependencies, } from "./lib/cli-external-packages.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; @@ -69,6 +77,7 @@ const StageWorkspaceConfig = Schema.Struct({ allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), + nodeLinker: Schema.optional(Schema.Literals(["hoisted"])), }); type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type; @@ -386,18 +395,36 @@ const desktopBuildInputArtifactNames = { /** * Imported by every server module, so it is inlined in any correctly bundled * build. Its absence means the bundle went back to externalizing its - * dependencies, which the unpack globs do not cover. + * dependencies, which the sidecar's selected runtime closure does not cover. */ const BUNDLE_SELF_CONTAINED_SENTINEL = "effect"; const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120); +const WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT = Duration.seconds(30); + +const WINDOWS_PRIMARY_FFF_PROBE_SOURCE = ` +const { join } = await import("node:path"); +const { pathToFileURL } = await import("node:url"); +const { FileFinder } = await import(pathToFileURL(process.argv[1]).href); +const probeRoot = process.argv[2]; +const result = FileFinder.create({ + basePath: probeRoot, + frecencyDbPath: join(probeRoot, "frecency.mdb"), + historyDbPath: join(probeRoot, "history.mdb"), + disableWatch: true, + disableMmapCache: true, + disableContentIndexing: true, +}); +if (!result.ok) throw new Error(result.error); +result.value.destroy(); +`; export class ExternalizedBundleError extends Schema.TaggedErrorClass()( "ExternalizedBundleError", { sentinel: Schema.String, inlinedPackageCount: Schema.Number }, ) { override get message(): string { - return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the native externals; if its dependencies are external again they will not be unpacked, and the WSL backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`; + return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the runtime externals; if its dependencies are external again they will be absent from the sidecar, and the backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`; } } @@ -406,7 +433,7 @@ export class BundleNotSelfContainedError extends Schema.TaggedErrorClass()( + "WindowsServerSidecarPackError", + { + asarPath: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Failed to pack the Windows server sidecar at ${this.asarPath}.`; + } +} + +export class WindowsPrimaryNativeProbeError extends Schema.TaggedErrorClass()( + "WindowsPrimaryNativeProbeError", + { + executablePath: Schema.String, + exitCode: Schema.Number, + output: Schema.String, + }, +) { + override get message(): string { + return `The packaged Windows primary could not load fff from server.asar (exit ${this.exitCode}). Output:\n${this.output}`; + } +} + +const WindowsPackagedPayloadValidationReason = Schema.Literals([ + "packaged-app-missing", + "sidecar-missing", + "sidecar-invalid", + "unpacked-native-missing", + "resource-monitor-missing", + "file-limit-exceeded", +]); + +export class WindowsPackagedPayloadValidationError extends Schema.TaggedErrorClass()( + "WindowsPackagedPayloadValidationError", + { + reason: WindowsPackagedPayloadValidationReason, + packagedAppDir: Schema.String, + missingFiles: Schema.optionalKey(Schema.Array(Schema.String)), + fileCount: Schema.optionalKey(Schema.Int), + fileLimit: Schema.optionalKey(Schema.Int), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + if (this.reason === "file-limit-exceeded") { + return `Windows packaged payload contains ${String(this.fileCount)} files; expected at most ${String(this.fileLimit)}.`; + } + if (this.reason === "unpacked-native-missing") { + return `Windows server sidecar is missing ${String(this.missingFiles?.length ?? 0)} unpacked native files.`; + } + if (this.reason === "resource-monitor-missing") { + return "Windows packaged payload is missing the resource monitor executable."; + } + if (this.reason === "sidecar-invalid") { + return "Windows packaged payload contains an invalid server.asar sidecar."; + } + if (this.reason === "sidecar-missing") { + return "Windows packaged payload is missing resources/server.asar."; + } + return `Windows packaged application directory was not found at ${this.packagedAppDir}.`; + } +} + export class WslNodePtyManifestReadError extends Schema.TaggedErrorClass()( "WslNodePtyManifestReadError", { @@ -686,21 +778,47 @@ export const DESKTOP_FILE_EXCLUSIONS = [ // so the SDK's optional platform packages (each a ~200MB bundled executable) // are dead weight. The trailing dash keeps the SDK's own JS package. "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", + // Windows stages the server sidecar below prod-resources so electron-builder + // can copy it using project-relative extraResources matchers. Keep those + // staging inputs out of app.asar; they are emitted once at resources/. + "!apps/desktop/prod-resources/windows-server", + "!apps/desktop/prod-resources/windows-server/**/*", ] as const; -// The WSL backend launches the server with plain `wsl.exe -- node`, which cannot -// read inside an asar archive, so everything it loads must be on the real -// filesystem. This used to unpack `**\/node_modules\/**` wholesale, because the -// server bundle externalized its runtime deps and the Linux Node would fail with -// ERR_MODULE_NOT_FOUND ("Cannot find package 'effect'") before it even reached -// node-pty. -// -// The CLI bundle now inlines its JS dependencies, so the only things that still -// have to be loose are the server bundle itself and the packages the bundle -// leaves external — derived from the same list the bundler uses, so the two -// cannot drift apart. -export const WINDOWS_ASAR_UNPACK = [ - "apps/server/dist/**", - ...CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, +// Windows ships the server tree (bundle + node_modules) as a separate +// resources/server.asar sidecar instead of loose files: the NSIS installer +// then extracts a handful of large archives instead of thousands of small +// files, which dominates install (and update) time. The Windows primary runs +// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE +// runtime; the WSL backend cannot read asar archives, so enabling WSL lazily +// extracts the sidecar to a version-keyed directory (see DesktopWslServerTree). +export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; +// dlopen/spawn need real files, so native modules, shared libraries, and +// helper executables live in the server.asar.unpacked sibling (the standard +// asar redirect convention). Everything else stays packed. +export const WINDOWS_SERVER_ASAR_UNPACK_GLOB = + "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}"; +// Mirrors DESKTOP_FILE_EXCLUSIONS for the hand-packed sidecar: the Claude SDK +// platform packages are dead weight (see above), and node_modules/.bin shims +// are never spawned at runtime (and are symlinks on POSIX build hosts, which +// the asar extraction path deliberately does not support). +export const WINDOWS_SERVER_ASAR_IGNORE_GLOBS = [ + "**/node_modules/@anthropic-ai/claude-agent-sdk-*", + "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**", + "**/node_modules/.bin", + "**/node_modules/.bin/**", +] as const; +export const WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT = 80; +export const WINDOWS_SERVER_RESOURCE_SOURCE_DIR = "apps/desktop/prod-resources/windows-server"; +export const WINDOWS_SERVER_EXTRA_RESOURCES = [ + { + // Copy the archive and its .unpacked sibling from one parent directory. + // Mapping the .unpacked directory as an independent FileSet silently + // omitted it from Windows packages even though electron-builder copied + // the adjacent archive. + from: WINDOWS_SERVER_RESOURCE_SOURCE_DIR, + to: ".", + filter: [WINDOWS_SERVER_ASAR_RESOURCE, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/**/*`], + }, ] as const; export const DESKTOP_EXTRA_RESOURCES = [ { @@ -1019,14 +1137,20 @@ export function createStageWorkspaceConfig(input: { readonly allowBuilds?: Record; readonly patchedDependencies?: Record; readonly overrides?: Record; + // The Windows server sidecar stage runs both the Windows primary and the + // WSL Linux backend from one dependency tree, so it needs win32 + linux + // natives (e.g. @yuuang/ffi-rs-linux-x64-gnu) — and a hoisted (physical, + // symlink-free) node_modules: the tree gets packed into server.asar and + // later extracted for WSL, and neither step can rely on pnpm's + // symlink/junction layout surviving the trip. + readonly linuxServerBackend?: boolean; }): StageWorkspaceConfig { - const { platform, arch, allowBuilds, patchedDependencies, overrides } = input; + const { platform, arch, allowBuilds, patchedDependencies, overrides, linuxServerBackend } = input; const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch]; - // Linux AppImages and Windows WSL backends both execute a Linux/glibc Node - // process that loads Linux-native optional deps at runtime (e.g. - // @yuuang/ffi-rs-linux-x64-gnu). Keep libc explicit so pnpm includes those - // optional packages in the staged production install. + // Linux AppImages execute a Linux/glibc Node process that loads + // Linux-native optional deps at runtime. Keep libc explicit so pnpm + // includes those optional packages in the staged production install. const supportedArchitectures = platform === "linux" ? { @@ -1034,7 +1158,7 @@ export function createStageWorkspaceConfig(input: { cpu: hostCpu, libc: ["glibc"], } - : platform === "win" + : linuxServerBackend ? { os: Array.from(new Set([hostOs, "linux"])), cpu: hostCpu, @@ -1052,6 +1176,7 @@ export function createStageWorkspaceConfig(input: { ? { patchedDependencies } : {}), ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}), + ...(linuxServerBackend ? { nodeLinker: "hoisted" as const } : {}), }; } @@ -1411,36 +1536,27 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin ); const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")( - function* (input: { readonly stageDistDir: string; readonly verbose: boolean }) { + function* (input: { readonly asarPath: string; readonly verbose: boolean }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // electron-builder names this win-unpacked, win-arm64-unpacked, and so on. - const distEntries = yield* fs - .readDirectory(input.stageDistDir) - .pipe(Effect.orElseSucceed(() => [] as Array)); - let unpackedRoot: string | null = null; - for (const entry of distEntries) { - const candidate = path.join(input.stageDistDir, entry, "resources/app.asar.unpacked"); - if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { - unpackedRoot = candidate; - break; - } - } - // Nothing to verify rather than silently passing: a packaging layout change - // should surface here instead of turning the check into a no-op. - if (unpackedRoot === null) { - return yield* new BundleNotSelfContainedError({ - exitCode: -1, - output: `No */resources/app.asar.unpacked directory under ${input.stageDistDir}; the bundle self-containment check found nothing to verify.`, - }); - } - const probeRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-bundle-selfcheck-", }); + const extractedApp = path.join(probeRoot, "extracted"); const probeApp = path.join(probeRoot, "app"); - yield* copyDirectoryPreservingSymlinks(unpackedRoot, probeApp); + yield* Effect.try({ + try: () => extractAll(input.asarPath, extractedApp), + catch: (cause) => + new BundleNotSelfContainedError({ + exitCode: -1, + output: `Could not extract ${input.asarPath} for the bundle self-containment check: ${String(cause)}`, + }), + }); + // Keep the existing symlink isolation guard even though the sidecar stage + // is hoisted and should be physical. A future package-manager layout change + // must not let the probe resolve through the build tree. + yield* copyDirectoryPreservingSymlinks(extractedApp, probeApp); // Guard the guard: if anything above the probe provides a node_modules, a // missing dependency would resolve there and the check would pass while the @@ -1466,8 +1582,8 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // missing dependency shows up, without starting a server or touching disk // state. It does not cover lazily imported externals: node-pty is checked // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node - // and the bun adapters are only covered by the unpack globs and the - // inlined-native check below. + // and the bun adapters are covered by the shared runtime-external closure + // and emitted-bundle checks. yield* runCommand( ChildProcess.make( process.execPath, @@ -1486,7 +1602,10 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel env: { ...process.env, NODE_PATH: "" }, }, ), - { label: "bundle self-containment check (node bin.mjs --version)", verbose: input.verbose }, + { + label: "server sidecar self-containment check (node bin.mjs --version)", + verbose: input.verbose, + }, ).pipe( // Printing a version should be immediate. A regression that blocks (on // stdin, a port, a lock) would otherwise hang release CI until the job @@ -1878,11 +1997,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( directories: { buildResources: "apps/desktop/resources", }, - // Only the Windows WSL backend needs files outside the asar (see - // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack - // extracts native libraries, which fff-node finds in app.asar.unpacked. - ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}), - extraResources: DESKTOP_EXTRA_RESOURCES, + // All platforms keep app.asar fully packed; electron-builder's default + // smart unpack extracts native libraries, which loaders find in + // app.asar.unpacked. Windows additionally ships the server tree as the + // hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE). + extraResources: [ + ...DESKTOP_EXTRA_RESOURCES, + ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []), + ], }; const updateChannel = resolveDesktopUpdateChannel(version); const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); @@ -1942,6 +2064,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( if (platform === "win") { buildConfig.npmRebuild = false; + // Keep blockmap-based differential downloads enabled while changing the + // installed file topology. The optimization is in the payload shape, not + // in trading update bandwidth for install speed. + buildConfig.nsis = { differentialPackage: true }; const winConfig: Record = { target: [target], icon: "icon.ico", @@ -2050,6 +2176,381 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* ( ); }); +// Stage and pack the Windows server sidecar: the bundled server plus a hoisted +// install of only its runtime-external/native dependency closure for win32 and +// WSL Linux. The Windows primary runs from the archive through the asar-aware +// ELECTRON_RUN_AS_NODE runtime; enabling WSL extracts it to a real directory. +// Shipping one packed archive instead of thousands of loose files is what +// makes the NSIS install/update fast. +export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: { + readonly sourceDir: string; + readonly asarPath: string; +}) { + const fs = yield* FileSystem.FileSystem; + yield* Effect.tryPromise({ + try: () => + createPackageWithOptions(input.sourceDir, input.asarPath, { + dot: true, + unpack: WINDOWS_SERVER_ASAR_UNPACK_GLOB, + globOptions: { ignore: [...WINDOWS_SERVER_ASAR_IGNORE_GLOBS] }, + }), + catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }), + }); + const unpackedDirPath = `${input.asarPath}.unpacked`; + if (!(yield* fs.exists(unpackedDirPath))) { + return yield* new WindowsServerSidecarPackError({ + asarPath: input.asarPath, + cause: new Error(`expected native binaries at ${unpackedDirPath}, but none were unpacked`), + }); + } +}); + +export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(function* (input: { + readonly stageRoot: string; + readonly repoRoot: string; + readonly serverDistDir: string; + readonly arch: typeof BuildArch.Type; + readonly appVersion: string; + readonly runtimeExternalDependencies: Record; + readonly fffNodeVersion: string; + readonly allowBuilds: Record; + readonly patchedDependencies: Record; + readonly overrides: Record; + readonly wslPrebuildPath: string | undefined; + readonly asarPath: string; + readonly verbose: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const serverStageDir = path.join(input.stageRoot, "server"); + yield* fs.makeDirectory(path.join(serverStageDir, "apps/server"), { recursive: true }); + yield* fs.copy(input.serverDistDir, path.join(serverStageDir, "apps/server/dist")); + + const sidecarDependencies = { + ...input.runtimeExternalDependencies, + // The sidecar serves two processes: the Windows primary loads win32 + // natives, and the WSL backend loads the matching Linux natives (fff via + // ffi-rs) from the extracted copy of this same tree. + ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion), + ...resolveFffNativeDependencies("linux", input.arch, input.fffNodeVersion), + }; + const sidecarPatchedDependencies = createStagePatchedDependencies( + input.patchedDependencies, + sidecarDependencies, + ); + const sidecarPackageJson = { + name: "t3code-server", + version: input.appVersion, + private: true, + packageManager: rootPackageJson.packageManager, + dependencies: sidecarDependencies, + }; + const sidecarPackageJsonString = yield* encodeJsonString(sidecarPackageJson); + yield* fs.writeFileString( + path.join(serverStageDir, "package.json"), + `${sidecarPackageJsonString}\n`, + ); + const sidecarWorkspaceConfig = createStageWorkspaceConfig({ + platform: "win", + arch: input.arch, + allowBuilds: input.allowBuilds, + patchedDependencies: sidecarPatchedDependencies, + overrides: input.overrides, + linuxServerBackend: true, + }); + const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig); + yield* fs.writeFileString( + path.join(serverStageDir, "pnpm-workspace.yaml"), + sidecarWorkspaceConfigString, + ); + if (Object.keys(sidecarPatchedDependencies).length > 0) { + yield* fs.copy(path.join(input.repoRoot, "patches"), path.join(serverStageDir, "patches")); + } + + yield* Effect.log("[desktop-artifact] Installing server sidecar runtime externals..."); + const installCommand = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]); + yield* runCommand( + ChildProcess.make(installCommand.command, installCommand.args, { + cwd: serverStageDir, + shell: installCommand.shell, + }), + { label: "vp install --prod (server sidecar)", verbose: input.verbose }, + ); + + yield* stageWslNodePtyPrebuild({ + stageAppDir: serverStageDir, + arch: input.arch, + prebuildPath: input.wslPrebuildPath, + }); + + yield* Effect.log("[desktop-artifact] Packing server.asar..."); + yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true }); + yield* packWindowsServerAsar({ sourceDir: serverStageDir, asarPath: input.asarPath }); + const packedStat = yield* fs.stat(input.asarPath); + yield* Effect.log( + `[desktop-artifact] Packed server.asar (${String(packedStat.size)} bytes) + unpacked natives.`, + ); +}); + +function collectUnpackedAsarFiles( + directory: DirectoryRecord, + parentPath = "", + output: string[] = [], +): readonly string[] { + for (const [name, entry] of Object.entries(directory.files)) { + const entryPath = parentPath.length === 0 ? name : `${parentPath}/${name}`; + if ("files" in entry) { + collectUnpackedAsarFiles(entry, entryPath, output); + } else if (entry.unpacked) { + output.push(entryPath); + } + } + return output; +} + +const countPayloadFiles = Effect.fn("desktopArtifact.countPayloadFiles")(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pendingDirectories = [root]; + let count = 0; + + while (pendingDirectories.length > 0) { + const directory = pendingDirectories.pop(); + if (directory === undefined) break; + const entries = yield* fs.readDirectory(directory); + for (const entry of entries) { + const entryPath = path.join(directory, entry); + const stat = yield* fs.stat(entryPath); + if (stat.type === "Directory") { + pendingDirectories.push(entryPath); + } else if (stat.type === "File") { + count += 1; + } + } + } + + return count; +}); + +export const verifyWindowsPrimaryFffNativeLoad = Effect.fn( + "desktopArtifact.verifyWindowsPrimaryFffNativeLoad", +)(function* (input: { + readonly packagedAppDir: string; + readonly asarPath: string; + readonly appExecutableName: string; + readonly targetArch: typeof BuildArch.Type; + readonly verbose: boolean; +}) { + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const executablePath = path.join(input.packagedAppDir, input.appExecutableName); + const executableStat = yield* fs.stat(executablePath).pipe(Effect.orElseSucceed(() => null)); + if (executableStat?.type !== "File") { + return yield* new WindowsPrimaryNativeProbeError({ + executablePath, + exitCode: -1, + output: "The unpacked application does not contain its expected primary executable.", + }); + } + if (hostPlatform !== "win32" || hostArchitecture !== input.targetArch) return; + + const probeRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-windows-primary-native-probe-", + }); + const fffEntryPath = path.join( + input.asarPath, + "node_modules/@ff-labs/fff-node/dist/src/index.js", + ); + const probeEnv = { ...process.env }; + delete probeEnv.ELECTRON_NO_ASAR; + delete probeEnv.NODE_OPTIONS; + + yield* runCommand( + ChildProcess.make( + executablePath, + [ + "--no-global-search-paths", + "--input-type=module", + "--eval", + WINDOWS_PRIMARY_FFF_PROBE_SOURCE, + fffEntryPath, + probeRoot, + ], + { + cwd: input.packagedAppDir, + stdout: "pipe", + stderr: "pipe", + env: { + ...probeEnv, + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: "", + }, + }, + ), + { + label: "Windows primary fff native-load probe", + verbose: input.verbose, + }, + ).pipe( + Effect.timeout(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT), + Effect.catchTags({ + TimeoutError: () => + Effect.fail( + new WindowsPrimaryNativeProbeError({ + executablePath, + exitCode: -1, + output: `The native-load probe did not finish within ${Duration.toSeconds(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT)}s.`, + }), + ), + BuildCommandFailedError: (error) => + Effect.fail( + new WindowsPrimaryNativeProbeError({ + executablePath, + exitCode: error.exitCode, + output: `${error.stderrTail ?? ""}${error.stdoutTail ?? ""}`.trim(), + }), + ), + }), + ); +}); + +export const validateWindowsPackagedPayload = Effect.fn( + "desktopArtifact.validateWindowsPackagedPayload", +)(function* (input: { + readonly stageDistDir: string; + readonly appExecutableName: string; + readonly targetArch: typeof BuildArch.Type; + readonly fileLimit?: number; + readonly verbose?: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fileLimit = input.fileLimit ?? WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT; + const isFile = (filePath: string) => + fs.stat(filePath).pipe( + Effect.map((stat) => stat.type === "File"), + Effect.orElseSucceed(() => false), + ); + const stageEntries = yield* fs.readDirectory(input.stageDistDir); + let packagedAppDir: string | undefined; + + for (const entry of stageEntries) { + if (!entry.endsWith("-unpacked")) continue; + const candidate = path.join(input.stageDistDir, entry); + const stat = yield* fs.stat(candidate).pipe(Effect.orElseSucceed(() => null)); + if (stat?.type === "Directory") { + packagedAppDir = candidate; + break; + } + } + + if (packagedAppDir === undefined) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "packaged-app-missing", + packagedAppDir: path.join(input.stageDistDir, "win-unpacked"), + }); + } + + const resourcesDir = path.join(packagedAppDir, "resources"); + const asarPath = path.join(resourcesDir, WINDOWS_SERVER_ASAR_RESOURCE); + if (!(yield* fs.exists(asarPath).pipe(Effect.orElseSucceed(() => false)))) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "sidecar-missing", + packagedAppDir, + missingFiles: [WINDOWS_SERVER_ASAR_RESOURCE], + }); + } + + const unpackedFiles = yield* Effect.try({ + try: () => { + // The entry lookup proves the archive contains the server executable, + // while the single header walk identifies every file ASAR redirects to + // the unpacked sibling at runtime. + // @electron/asar resolves entry names using the host path separator. + // POSIX separators work on Linux/macOS but fail on Windows even when the + // entry is present in the archive. + statFile(asarPath, path.join("apps", "server", "dist", "bin.mjs")); + return [...collectUnpackedAsarFiles(getRawHeader(asarPath).header)].sort(); + }, + catch: (cause) => + new WindowsPackagedPayloadValidationError({ + reason: "sidecar-invalid", + packagedAppDir, + cause, + }), + }); + if (unpackedFiles.length === 0) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "sidecar-invalid", + packagedAppDir, + cause: new Error("server.asar does not declare any unpacked native files"), + }); + } + + const missingFiles: string[] = []; + for (const unpackedFile of unpackedFiles) { + const unpackedPath = path.join( + resourcesDir, + `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked`, + ...unpackedFile.split("/"), + ); + if (!(yield* isFile(unpackedPath))) { + missingFiles.push(`${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/${unpackedFile}`); + } + } + if (missingFiles.length > 0) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "unpacked-native-missing", + packagedAppDir, + missingFiles, + }); + } + + const resourceMonitorPath = path.join( + resourcesDir, + "resource-monitor", + resourceMonitorExecutableName("win"), + ); + if (!(yield* isFile(resourceMonitorPath))) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "resource-monitor-missing", + packagedAppDir, + missingFiles: ["resource-monitor/t3-resource-monitor.exe"], + }); + } + + const fileCount = yield* countPayloadFiles(packagedAppDir); + if (fileCount > fileLimit) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "file-limit-exceeded", + packagedAppDir, + fileCount, + fileLimit, + }); + } + + yield* verifyWindowsPrimaryFffNativeLoad({ + packagedAppDir, + asarPath, + appExecutableName: input.appExecutableName, + targetArch: input.targetArch, + verbose: input.verbose ?? false, + }); + + yield* verifyPackagedBundleIsSelfContained({ + asarPath, + verbose: input.verbose ?? false, + }); + + yield* Effect.log( + `[desktop-artifact] Validated Windows payload (${String(fileCount)} files, ${String(unpackedFiles.length)} sidecar natives).`, + ); + return { packagedAppDir, fileCount, unpackedFiles } as const; +}); + const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options: ResolvedBuildOptions, ) { @@ -2098,6 +2599,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( cause, }), }); + const resolvedServerRuntimeExternalDependencies = selectCliRuntimeExternalDependencies( + resolvedServerDependencies, + ); const resolvedDesktopRuntimeDependencies = yield* Effect.try({ try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog), catch: (cause) => @@ -2188,7 +2692,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // inlined. A regression to externalizing everything would also pass it, // since source-file regions still exist -- and that is the failure this // whole change exists to prevent, because those packages are not in the - // unpack globs and the WSL backend would die on ERR_MODULE_NOT_FOUND. + // selected sidecar closure and both backends would die on ERR_MODULE_NOT_FOUND. // `effect` is imported by every server module, so it is inlined in any // correctly bundled build. // The list-based check above only sees packages someone already thought to @@ -2227,12 +2731,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* validateBundledClientAssets(path.dirname(bundledClientEntry)); yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true }); - yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true }); + if (options.platform !== "win") { + yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true }); + } yield* Effect.log("[desktop-artifact] Staging release app..."); yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); yield* fs.copy(distDirs.desktopResources, stageResourcesDir); - yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); + // On Windows the server tree ships in the server.asar sidecar instead of + // app.asar (see stageWindowsServerSidecar), so the app stage omits it. + if (options.platform !== "win") { + yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); + } yield* stageResourceMonitor({ repoRoot, stageResourcesDir, @@ -2253,7 +2763,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ); // electron-builder is filtering out stageResourcesDir directory in the AppImage for production - yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); + const stageProdResourcesDir = path.join(stageAppDir, "apps/desktop/prod-resources"); + yield* fs.copy(stageResourcesDir, stageProdResourcesDir); const configuredMacPasskeySigning = options.platform === "mac" && options.signed @@ -2283,30 +2794,31 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning)); } - const stageDependencies = { - ...resolvedServerDependencies, - ...resolvedDesktopRuntimeDependencies, - ...resolveFffNativeDependencies( - options.platform, - options.arch, - serverPackageJson.dependencies["@ff-labs/fff-node"], - ), - // Windows artifacts also bundle the same-architecture WSL Linux backend, which loads the - // fff native binary through ffi-rs. The platform fff binary above is the - // host's (win32), so promote the matching Linux fff binaries too; without - // them file-finding in WSL fails to load its Linux native package. - ...(options.platform === "win" - ? resolveFffNativeDependencies( - "linux", - options.arch, - serverPackageJson.dependencies["@ff-labs/fff-node"], - ) - : {}), - }; + // Windows splits dependencies per process: app.asar carries only the + // desktop main-process runtime deps, while the server bundle's deps live in + // the server.asar sidecar (see stageWindowsServerSidecar). macOS and Linux + // keep the single merged tree — their primary resolves everything from + // app.asar and there is no second consumer. + const stageDependencies = + options.platform === "win" + ? { ...resolvedDesktopRuntimeDependencies } + : { + ...resolvedServerDependencies, + ...resolvedDesktopRuntimeDependencies, + ...resolveFffNativeDependencies( + options.platform, + options.arch, + serverPackageJson.dependencies["@ff-labs/fff-node"], + ), + }; const stagePatchedDependencies = createStagePatchedDependencies( workspacePatchedDependencies, stageDependencies, ); + const windowsServerAsarPath = + options.platform === "win" + ? path.join(stageAppDir, WINDOWS_SERVER_RESOURCE_SOURCE_DIR, WINDOWS_SERVER_ASAR_RESOURCE) + : undefined; const stagePackageJson: StagePackageJson = { name: "t3code", version: appVersion, @@ -2367,13 +2879,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ); yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); - // WSL is Windows-only, so only the Windows artifact carries the Linux backend - // binary; other platforms ignore the prebuild input. - if (options.platform === "win") { - yield* stageWslNodePtyPrebuild({ - stageAppDir, + // WSL is Windows-only, so only the Windows artifact carries the server + // sidecar (which embeds the Linux node-pty prebuild); other platforms + // ignore the prebuild input. + if (options.platform === "win" && windowsServerAsarPath) { + yield* stageWindowsServerSidecar({ + stageRoot, + repoRoot, + serverDistDir: distDirs.serverDist, arch: options.arch, - prebuildPath: options.wslPrebuild, + appVersion, + runtimeExternalDependencies: resolvedServerRuntimeExternalDependencies, + fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"], + allowBuilds: workspaceAllowBuilds, + patchedDependencies: workspacePatchedDependencies, + overrides: resolvedOverrides, + wslPrebuildPath: options.wslPrebuild, + asarPath: windowsServerAsarPath, + verbose: options.verbose, }); } @@ -2462,9 +2985,15 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // resolver has no such ambiguity: it either finds every import or it does not. // // Only Windows unpacks anything; macOS and Linux keep the whole tree inside - // the asar, where this check has nothing to look at. + // the app asar. Windows validates and executes the separately packed server + // sidecar after electron-builder copies it into the final payload. if (options.platform === "win") { - yield* verifyPackagedBundleIsSelfContained({ stageDistDir, verbose: options.verbose }); + yield* validateWindowsPackagedPayload({ + stageDistDir, + appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`, + targetArch: options.arch, + verbose: options.verbose, + }); } const stageEntries = yield* fs.readDirectory(stageDistDir); diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 189634dfee6..754cd646f17 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -7,11 +7,12 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import serverPackageJson from "../../apps/server/package.json" with { type: "json" }; + import { - CLI_EXTERNAL_PACKAGE_PREFIXES, - CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, CLI_RUNTIME_EXTERNAL_PREFIXES, findInlinedExternalPackages, + selectCliRuntimeExternalDependencies, shouldBundleCliDependency, } from "./cli-external-packages.ts"; @@ -60,39 +61,41 @@ describe("shouldBundleCliDependency", () => { }); // The real package is `node-gyp-build-optional-packages`, reached by prefix. - // Matching it as external while failing to unpack it is invisible on the - // Windows primary (which reads app.asar) and breaks only under WSL. + // It is transitive to a selected dependency root, so the runtime closure test + // below ensures it follows that root into the sidecar. it("treats prefix-matched siblings as external", () => { assert.strictEqual(shouldBundleCliDependency("node-gyp-build-optional-packages"), false); }); }); -describe("CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS", () => { - it("unpacks every external prefix from both the top level and the pnpm store", () => { - for (const prefix of CLI_EXTERNAL_PACKAGE_PREFIXES) { - assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, `node_modules/${prefix}*/**/*`, prefix); - assert.include( - CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, - `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`, - prefix, - ); - } +describe("selectCliRuntimeExternalDependencies", () => { + it("keeps only runtime-external dependency roots for the Windows sidecar", () => { + assert.deepStrictEqual( + selectCliRuntimeExternalDependencies({ + "@effect/platform-bun": "1.0.0", + "@ff-labs/fff-node": "2.0.0", + effect: "3.0.0", + "node-pty": "4.0.0", + }), + { + "@ff-labs/fff-node": "2.0.0", + "node-pty": "4.0.0", + }, + ); }); - // Without the trailing `*` the globs stop covering prefix-matched siblings, - // which is exactly how a package ends up external but not unpacked. - it("keeps the trailing wildcard that matches prefix siblings", () => { - assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, "node_modules/node-gyp-build*/**/*"); + it("selects every external root declared by the server", () => { + assert.deepStrictEqual( + Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(), + ["@ff-labs/fff-node", "msgpackr-extract", "node-pty"], + ); }); }); -// The failure this guards is invisible on Windows and fatal under WSL. -// // An external package is loaded from the real filesystem, so its own `require` // also resolves from the real filesystem. If one of its dependencies was -// bundled away instead of left external, that dependency exists only inside -// app.asar — which the Windows primary reads transparently under -// ELECTRON_RUN_AS_NODE, and plain `node` under WSL cannot. +// bundled away instead of left external, that dependency does not follow the +// selected root into the sidecar. // // Found the hard way: node-gyp-build-optional-packages requires detect-libc, // which was bundled. Windows was fine; WSL got MODULE_NOT_FOUND. @@ -103,8 +106,8 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { // by name from this file at all, and an `exports` map can refuse the // `/package.json` subpath outright (@ff-labs/fff-node). Both surface as "not // installed", which would let this test skip everything and pass while - // checking nothing. The store is also what asarUnpack globs target, so this - // reads the same tree the build packages. + // checking nothing. The store contains the dependency graph the sidecar's + // minimal production install resolves. const readInstalledPackages = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index f50718af4fe..d7a89bc408a 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -4,14 +4,12 @@ * Two consumers derive from this list, and they must never disagree: * * - apps/server/vite.config.ts decides what stays external to the bundle. - * - scripts/build-desktop-artifact.ts decides what gets unpacked out of the asar. + * - scripts/build-desktop-artifact.ts selects the runtime dependency roots for + * the Windows server sidecar. * - * A package that is external but not unpacked still resolves on the Windows - * primary, which runs under ELECTRON_RUN_AS_NODE and reads app.asar - * transparently. It fails only under WSL, where the backend is launched as plain - * `wsl.exe -- node` and cannot read inside an archive. That asymmetry makes the - * drift invisible on the platform you are most likely to test on, which is why - * both consumers derive from one list instead of maintaining their own. + * A runtime package that is external but absent from the sidecar fails as soon + * as Node resolves it from the emitted bundle. Keeping both consumers on one + * list prevents packaging from drifting away from the bundle boundary. * * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover * a package's platform-specific siblings — `node-gyp-build` covers @@ -24,8 +22,8 @@ * critically — the ordinary JS packages those wrappers require. An external * package is loaded from the real filesystem, so its own `require` also * resolves from the real filesystem; a dependency that was bundled away exists - * only inside app.asar and is unreachable there. This closure is enforced by a - * test, not by inspection. + * only inside the emitted bundle and is unreachable there. This closure is + * enforced by a test, not by inspection. */ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "node-pty", @@ -70,6 +68,10 @@ export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES, ] as const; +export function isRuntimeExternalCliDependency(id: string): boolean { + return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix)); +} + /** * True when `id` must stay out of the bundle. * @@ -90,20 +92,14 @@ export function shouldBundleCliDependency(id: string): boolean { return !isExternalCliDependency(id); } -/** - * asar-unpack globs covering every external package. - * - * The trailing `*` is what keeps these aligned with the prefix matching above: - * without it, `node-gyp-build` would be left external by the bundler and then - * not unpacked, because the real package is `node-gyp-build-optional-packages`. - * - * pnpm stores real files under `.pnpm` and symlinks the top-level names, so both - * paths are unpacked for the link target to exist on disk. - */ -export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.flatMap( - (prefix) => - [`node_modules/${prefix}*/**/*`, `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`] as const, -); +/** Select direct dependency roots whose runtime closure belongs in the sidecar. */ +export function selectCliRuntimeExternalDependencies( + dependencies: Readonly>, +): Record { + return Object.fromEntries( + Object.entries(dependencies).filter(([name]) => isRuntimeExternalCliDependency(name)), + ); +} /** * Scan an emitted bundle chunk for runtime-external packages that were inlined. @@ -122,8 +118,8 @@ export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.f * check the opposite direction too. Verifying only that externals are absent * would still pass if the bundler reverted to leaving everything external: the * scan would see source-file regions, report nothing inlined, and the packaged - * WSL backend would then fail with ERR_MODULE_NOT_FOUND because those packages - * are not in the unpack globs either. + * backends would then fail with ERR_MODULE_NOT_FOUND because those packages + * are not in the selected sidecar closure either. */ export function findInlinedExternalPackages(source: string): { readonly regionCount: number; diff --git a/scripts/package.json b/scripts/package.json index 457a8f0d3a3..14c4ea98e9b 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@electron/asar": "^3.4.1", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", From 196c8ea0d642acd1db66bd57f5d98abe81d8da6e Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:43:33 +0000 Subject: [PATCH 004/113] fix(web): style sidebar action tooltips (#6371) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> --- apps/web/src/components/Sidebar.tsx | 82 ++++++++++++++++++----------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f35dd1fdba6..dc8be07dcad 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -366,19 +366,26 @@ function SnoozePopoverButton(props: { ); return ( - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" - /> - } - > - - + + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + /> + } + /> + } + > + + + Snooze thread + {presets.map((preset) => ( + + + } + > + + + Unpin thread + ) : ( ) : null} {props.settlementSupported ? ( - + + + } + > + + Settle + + Settle thread + ) : null} ) : null} From 9885a845c97325b1099b095011da8385485616f5 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:30:13 +0200 Subject: [PATCH 005/113] refactor(web): simplify global styling (#6381) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge --- .../check-run-agents/ui-consistency.md | 82 ++ apps/web/src/components/AgentsPanel.tsx | 18 +- .../BranchToolbarBranchSelector.tsx | 9 +- apps/web/src/components/ChatMarkdown.tsx | 23 +- apps/web/src/components/ChatView.tsx | 18 +- .../src/components/ComposerPromptEditor.tsx | 12 +- apps/web/src/components/DiffPanel.tsx | 9 +- apps/web/src/components/DiffPanelShell.tsx | 2 +- apps/web/src/components/LegacySidebar.tsx | 7 +- .../src/components/NoActiveThreadState.tsx | 4 +- apps/web/src/components/RightPanelTabs.tsx | 13 +- apps/web/src/components/Sidebar.tsx | 13 +- .../src/components/ThreadTerminalDrawer.tsx | 9 +- .../components/chat/ComposerCommandMenu.tsx | 2 +- .../ComposerPreviewAnnotationCards.test.tsx | 14 + .../chat/ComposerPreviewAnnotationCards.tsx | 10 +- .../components/chat/ComposerStashBadge.tsx | 2 +- .../src/components/chat/ComposerStashMenu.tsx | 2 +- .../components/chat/ContextWindowMeter.tsx | 15 +- .../components/chat/MessagesTimeline.test.tsx | 4 +- .../src/components/chat/MessagesTimeline.tsx | 2 +- .../components/chat/ModelPickerContent.tsx | 11 +- .../components/chat/ProviderStatusBanner.tsx | 10 +- apps/web/src/components/composerInlineChip.ts | 3 + .../diffs/StyledDiffCodeView.test.tsx | 4 +- .../components/diffs/StyledDiffCodeView.tsx | 4 +- .../src/components/files/FileBrowserPanel.tsx | 7 +- .../src/components/files/FilePreviewPanel.tsx | 5 +- .../components/preview/PreviewChromeRow.tsx | 8 +- .../pullRequest/PullRequestCodeTab.tsx | 24 +- .../pullRequest/PullRequestDetailPanel.tsx | 10 +- .../pullRequest/PullRequestListFilters.tsx | 26 +- .../pullRequest/PullRequestReviewerPicker.tsx | 5 +- .../search/ProjectContentSearchDialog.tsx | 15 +- .../settings/DiagnosticsSettings.tsx | 26 +- .../settings/KeybindingsSettings.tsx | 98 +- .../settings/ProjectSettingsPanel.tsx | 2 +- .../settings/ProviderInstanceCard.tsx | 9 +- .../settings/ProviderModelsSection.tsx | 33 +- .../settings/ProviderSettingsPanel.tsx | 10 +- .../settings/ResourceTelemetryDiagnostics.tsx | 14 +- .../settings/SettingsSidebarNav.tsx | 4 +- .../settings/SourceControlSettings.tsx | 45 +- .../components/settings/ThemeImportDialog.tsx | 8 +- .../settings/ThemeSearchSection.tsx | 30 +- .../components/settings/settingsLayout.tsx | 18 +- .../src/components/sidebar/SidebarChrome.tsx | 2 +- .../sidebar/SidebarProviderUpdatePill.tsx | 12 +- .../src/components/threadSidebarWidth.test.ts | 19 +- apps/web/src/components/ui/button.test.tsx | 15 + apps/web/src/components/ui/button.tsx | 8 + apps/web/src/components/ui/combobox.tsx | 2 +- apps/web/src/components/ui/input-group.tsx | 4 +- apps/web/src/components/ui/input.tsx | 6 +- apps/web/src/components/ui/menu.tsx | 10 +- apps/web/src/components/ui/popover.tsx | 2 + apps/web/src/components/ui/scroll-area.tsx | 19 +- apps/web/src/components/ui/select.tsx | 4 +- apps/web/src/components/ui/sidebar.tsx | 2 + apps/web/src/components/ui/skeleton.tsx | 2 +- apps/web/src/components/ui/toast.tsx | 27 +- apps/web/src/components/ui/toggle.tsx | 2 + apps/web/src/components/usage/UsagePage.tsx | 11 +- apps/web/src/index.css | 1101 ++++++----------- .../web/src/routes/-chatIndexTitlebar.test.ts | 7 +- apps/web/src/routes/_chat.index.tsx | 2 +- apps/web/src/routes/_chat.pull-requests.tsx | 7 +- apps/web/src/routes/settings.tsx | 2 +- apps/web/src/terminal/ghostty/surface.ts | 9 +- 69 files changed, 851 insertions(+), 1123 deletions(-) create mode 100644 .macroscope/check-run-agents/ui-consistency.md diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md new file mode 100644 index 00000000000..8ec72074275 --- /dev/null +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -0,0 +1,82 @@ +--- +title: UI Consistency +model: claude-opus-5 +effort: high +input: full_diff +tools: + - browse_code + - git_tools + - github_api_read_only + - modify_pr +include: + - "apps/web/src/**/*.ts" + - "apps/web/src/**/*.tsx" + - "apps/web/src/**/*.css" +conclusion: failure +showToolCalls: true +--- + +# UI consistency review + +Review changed web UI code and directly affected call sites for consistency with the shared component system, Tailwind ownership, and the behavioral constraints below. Apply these rules when a pull request creates, moves, or modifies controls or styling. Do not demand unrelated repository-wide cleanup. + +The goal is not to minimize CSS or class counts at any cost. The goal is to put each behavior in the smallest correct owner while preserving interaction, theming, accessibility, layout, and browser behavior. + +## Shared controls and variants + +- Prefer the core UI primitives in `apps/web/src/components/ui` over native controls or locally reconstructed primitives. In ordinary product UI, a raw ` +
{result._tag === "Success" ? ( @@ -417,14 +419,14 @@ function ExpandedWorkflowSection({ {settled}/{members.length} settled - +
{scriptOpen && canShowScript ? ( diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 05ed533acbc..b3c1c08eb73 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -51,6 +51,7 @@ import { } from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; import { Switch } from "./ui/switch"; +import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; import { Combobox, ComboboxEmpty, @@ -814,9 +815,11 @@ export function BranchToolbarBranchSelector({ maybeFetchNextBranchPage(); }} className={cn( - "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1 [--fade-size:1.5rem]", - showTopBranchScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", - showBottomBranchScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", + "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1", + getVirtualizedScrollFadeClassName({ + top: showTopBranchScrollFade, + bottom: showBottomBranchScrollFade, + }), )} style={{ maxHeight: "14rem" }} /> diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e9390ed0a8a..53b043f3a8f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -449,7 +449,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { {children} -
+
-
- +
+ (); const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( - + {failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( @@ -1044,7 +1047,7 @@ function MarkdownExternalLinkContent({ const leadingLength = leadingExternalLinkTextLength(plainText); return ( <> - + {plainText.slice(0, leadingLength)} @@ -1060,7 +1063,7 @@ function MarkdownExternalLinkContent({ const leadingLength = leadingExternalLinkTextLength(firstChild); return ( <> - + {firstChild.slice(0, leadingLength)} @@ -1072,7 +1075,7 @@ function MarkdownExternalLinkContent({ return ( <> - + {firstChild} @@ -1289,7 +1292,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ side="top" className="max-w-[min(40rem,calc(100vw-2rem))] font-mono text-[11px] leading-tight" > -
+
{displayPath}
@@ -1699,7 +1702,7 @@ function ChatMarkdown({ return (
{rightPanelOpen && !shouldUseRightPanelSheet ? ( @@ -6273,16 +6274,17 @@ function ChatViewContent(props: ChatViewProps) { className="pointer-events-none absolute left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5" style={{ bottom: composerOverlayHeight + 4 }} > - +
)}
@@ -6299,7 +6301,7 @@ function ChatViewContent(props: ChatViewProps) { >
{isDraftHeroState ? ( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 0489e8c79cd..f6dfef2489b 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -71,6 +71,7 @@ import { import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { + COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME, @@ -188,7 +189,7 @@ class ComposerMentionNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -326,7 +327,7 @@ class ComposerSkillNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -397,7 +398,7 @@ class ComposerTerminalContextNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -1747,13 +1748,12 @@ function ComposerPromptEditorInner({ return ( -
+
Appearance - // can drive it; keep everything else here. + // The wrapper owns the appearance preference; keep everything else here. "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", className, )} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 385d67b6b70..b929d05a719 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -825,7 +825,7 @@ export default function DiffPanel({
) : ( <> -
+
{isSelectedPatchTruncated && (

This diff was truncated because it exceeded the preview limit. The changes shown are @@ -907,10 +907,11 @@ export default function DiffPanel({ } diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 68a5855c1a2..82dddd8f41e 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -11,7 +11,9 @@ export function NoActiveThreadState() {

diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index df65aa60d52..b91e81bc7a0 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -26,6 +26,7 @@ import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; +import { Button } from "~/components/ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; @@ -603,7 +604,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { >
0 ? ( + } > diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dc8be07dcad..2f0c5a22140 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3298,9 +3298,9 @@ export default function Sidebar() { {isSearchingThreads ? ( + ); })} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 87f0ed4ae70..1266e5ed7e9 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -31,6 +31,7 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { Button } from "~/components/ui/button"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; @@ -1273,13 +1274,9 @@ export default function ThreadTerminalDrawer({ ) : null}

No terminal sessions for this thread yet.

- +
); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 3ed2a9432e4..4f32211c10c 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -141,7 +141,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { >
{props.items.length > 0 ? ( diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx index 5bb28054e7d..46af299073c 100644 --- a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx @@ -43,4 +43,18 @@ describe("ComposerPreviewAnnotationCards", () => { expect(markup).not.toContain("localhost:3000"); expect(markup).not.toContain("Preview annotation"); }); + + it("uses the shared button contract for removal", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Remove preview annotation"'); + expect(markup).toContain('data-slot="button"'); + }); }); diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx index 5e9e43dcf21..19f6a5ca308 100644 --- a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import type { ComposerImageAttachment } from "~/composerDraftStore"; import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext"; import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; interface ComposerPreviewAnnotationCardsProps { annotations: ReadonlyArray; @@ -128,14 +129,15 @@ export function ComposerPreviewAnnotationCards({
- + ); })} diff --git a/apps/web/src/components/chat/ComposerStashBadge.tsx b/apps/web/src/components/chat/ComposerStashBadge.tsx index 79ed301a5d5..a2599ebc9f9 100644 --- a/apps/web/src/components/chat/ComposerStashBadge.tsx +++ b/apps/web/src/components/chat/ComposerStashBadge.tsx @@ -46,7 +46,7 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: { className={cn( "rounded-full px-1.5 text-[10px] font-medium tabular-nums", props.pulsing - ? "prompt-stash-count-enter bg-primary text-primary-foreground" + ? "animate-[prompt-stash-count-enter_180ms_ease-out_both] bg-primary text-primary-foreground motion-reduce:animate-none" : "bg-muted text-muted-foreground", )} > diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 9e923851533..fc8be327da1 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -91,7 +91,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { return ( -
+
diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index 752ec401376..f377c893ae2 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,4 +1,4 @@ -import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -36,13 +36,10 @@ export function ContextWindowMeter(props: { delay={150} closeDelay={0} render={ - + } /> { ); expect(compactMarkup).toContain('class="h-3 sm:h-4"'); - expect(compactMarkup).not.toContain("chat-timeline-scroll-fade"); + expect(compactMarkup).not.toContain("topbar-scroll-fade"); expect(fadedMarkup).toContain('class="h-10 sm:h-12"'); - expect(fadedMarkup).toContain("chat-timeline-scroll-fade"); + expect(fadedMarkup).toContain("topbar-scroll-fade"); }); it("keeps assistant changed-files headers sticky below the thread header", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9cd1c78aa4d..e190f47569b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -591,7 +591,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onScroll={handleScroll} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", - topFadeEnabled && "chat-timeline-scroll-fade", + topFadeEnabled && "topbar-scroll-fade", )} ListHeaderComponent={ loadEarlier !== null ? ( diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 7c86ec63014..7ffb2bf077d 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -33,6 +33,7 @@ import { } from "../../keybindings"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; +import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { TooltipProvider } from "../ui/tooltip"; import { isProviderInstancePickerReady, @@ -598,7 +599,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} @@ -781,9 +782,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onLayout={updateModelListScrollFades} onScroll={updateModelListScrollFades} className={cn( - "model-picker-list scrollbar-gutter-stable h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", - showTopScrollFade && "model-picker-list-scroll-fade-top", - showBottomScrollFade && "model-picker-list-scroll-fade-bottom", + "scrollbar-gutter-stable h-full overflow-x-hidden overscroll-y-contain py-1.5 [&::-webkit-scrollbar-track]:my-2", + getVirtualizedScrollFadeClassName({ + top: showTopScrollFade, + bottom: showBottomScrollFade, + }), )} /> diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index f82c17b13dd..1c7571b962f 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -2,6 +2,7 @@ import { type ServerProvider } from "@t3tools/contracts"; import { memo } from "react"; import { InfoIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; import { formatProviderDriverKindLabel } from "../../providerModels"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -66,14 +67,15 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
- +
); diff --git a/apps/web/src/components/composerInlineChip.ts b/apps/web/src/components/composerInlineChip.ts index c17b3ddab3c..3f0e8ca1ac0 100644 --- a/apps/web/src/components/composerInlineChip.ts +++ b/apps/web/src/components/composerInlineChip.ts @@ -8,6 +8,9 @@ export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[0.86em] select-none`; +export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME = + "relative inline-flex align-[-0.125em] leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']"; + export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx index dbdd10d194f..f0cd49abc41 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx @@ -35,7 +35,9 @@ describe("StyledDiffCodeView", () => { />, ); - expect(testState.codeViewClassName).toBe("diff-render-surface outline-none min-h-0"); + expect(testState.codeViewClassName).toBe( + "diff-render-surface [--code-background:var(--background)] outline-none min-h-0", + ); expect(testState.codeViewOptions).toMatchObject({ theme: "pierre-dark", stickyHeaders: true, diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx index 7dbd5358a0f..14939de0982 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -292,8 +292,8 @@ export function StyledDiffCodeView({ // outside the panel clipping boundary; actual controls inside retain their own indicators. className={ className - ? `diff-render-surface outline-none ${className}` - : "diff-render-surface outline-none" + ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}` + : "diff-render-surface [--code-background:var(--background)] outline-none" } options={{ ...options, diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index ff658693a70..e3280c99caa 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -78,7 +78,7 @@ function FileSearchField(props: { value: string; }) { return ( - + -
+
{relativePath ? ( -
+
-
+
- + { event.stopPropagation(); toggleFile(item.id); @@ -685,7 +684,7 @@ export function PullRequestCodeTab({ ) : ( )} - + ); }, [toggleFile], @@ -899,7 +898,7 @@ export function PullRequestCodeTab({ review.verdicts.length === 0 ? null : (
{reviewOpen ? ( -
+
+ )}
); @@ -959,7 +959,7 @@ export function PullRequestCodeTab({ * diff API offers it. */ const toolbar = ( -
+
{/* A host that reports no commits has nothing to scope by, and a dropdown whose only entry is the scope already showing is a control that does nothing. */} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 7237b435748..2f4e84dc3fd 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1137,8 +1137,14 @@ export function PullRequestDetailPanel({ <> + } > diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index dd4a9d161cd..3066eafc38a 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -24,6 +24,7 @@ import type { ElementType } from "react"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; import { Menu, @@ -79,29 +80,18 @@ export function PullRequestSearchInput({ onChange: (value: string) => void; }) { return ( -
- {busy ? ( - - ) : ( - - )} - + + {busy ? : } + + onChange(event.currentTarget.value)} placeholder="Search pull requests, or label:bug" aria-label="Search pull requests" - // Tracks the shared input's height at both widths, so it stays level with the icon - // button beside it rather than towering over it on wide screens. - className="h-9 w-full rounded-lg border border-input bg-background pr-3 pl-9 text-sm outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24 sm:h-8" /> -
+ ); } diff --git a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx index 25c663794e9..8330c87a929 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx @@ -19,6 +19,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; @@ -131,13 +132,13 @@ export function PullRequestReviewerPicker({ />
- setQuery(event.currentTarget.value)} placeholder="Search people with access" aria-label="Search people with access" - className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring" + size="compact" />
diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 1890aab7fa7..d877d6537bd 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -11,6 +11,7 @@ import { useProjectContentSearch } from "~/state/queries"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; import { CommandPaletteContent } from "../CommandPaletteContent"; import { ScrollArea } from "../ui/scroll-area"; +import { Toggle } from "../ui/toggle"; import { HighlightedSearchLine } from "./HighlightedSearchLine"; interface ProjectContentSearchDialogProps { @@ -58,19 +59,17 @@ function SearchOptionButton(props: { readonly children: ReactNode; }) { return ( - + ); } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 5bd4fdc08c4..a472c6a8d3d 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -273,14 +273,14 @@ function TraceIdCell({ traceId }: { traceId: string }) { copyToClipboard(traceId)} > - + } /> {copied ? "Copied" : "Copy full trace ID"} @@ -322,14 +322,14 @@ function ProcessNameCell({ style={{ paddingLeft: `${Math.min(process.depth, 6) * 10}px` }} > {hasChildren ? ( - + ) : (
- - @@ -702,17 +681,11 @@ function WhenExpressionBuilder({ ) : (
- - @@ -864,8 +837,7 @@ function KeybindingTableRow({ )} {isDirty ? (
+ ) : ( )} @@ -975,9 +975,8 @@ export function ResourceTelemetryDiagnostics() { - } - /> - - {children} - - - ); -} - function optionLabel(value: Option.Option): string | null { return Option.getOrNull(value); } @@ -316,9 +301,8 @@ function DiscoveryItemRow({
{hasDetails ? ( + } /> @@ -216,11 +212,10 @@ export function SettingResetButton({ { event.stopPropagation(); onClick(); @@ -251,7 +246,10 @@ export function SettingsPageContainer({ return ( -
+
{children}
diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 175a1c3d062..f4a98dec86c 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -83,7 +83,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { startExit(displayedView.key, null, displayedView.key)} > - + } /> Dismiss until provider status changes diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts index 3beb2a8f513..e38d5c3749b 100644 --- a/apps/web/src/components/threadSidebarWidth.test.ts +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off - Regression coverage compares shipped CSS with the sidebar width contract. +// @effect-diagnostics nodeBuiltinImport:off - Regression coverage compares the sidebar component with its width contract. import * as NodeFS from "node:fs"; import { describe, expect, it } from "vite-plus/test"; @@ -36,20 +36,13 @@ describe("thread sidebar width", () => { }); it("shows the desktop wordmark across the sidebar's full legal width range", () => { - const sidebarStyles = NodeFS.readFileSync(new URL("../index.css", import.meta.url), "utf8"); - const desktopHeaderStyles = sidebarStyles.slice( - sidebarStyles.indexOf("@media (min-width: 48rem)"), - sidebarStyles.indexOf("/* Stage-channel sidebar art"), + const sidebarSource = NodeFS.readFileSync( + new URL("./sidebar/SidebarChrome.tsx", import.meta.url), + "utf8", ); - const stageLabelThreshold = desktopHeaderStyles.match( - /@container sidebar-header \(min-width: ([\d.]+)rem\) \{\s*\.sidebar-brand-stage \{\s*display: inline-flex;/, - )?.[1]; - expect(sidebarStyles).toMatch(/\.sidebar-brand \{\s*display: none;/); - expect(desktopHeaderStyles).toMatch( - /@media \(min-width: 48rem\) \{\s*\.sidebar-brand \{\s*display: flex;/, - ); + expect(sidebarSource).toContain("hidden h-7 w-fit min-w-0 shrink-0 items-center gap-1"); + expect(sidebarSource).toContain("md:flex"); expect(THREAD_SIDEBAR_MIN_WIDTH).toBe(13 * 16); - expect(Number(stageLabelThreshold) * 16).toBeGreaterThan(THREAD_SIDEBAR_MIN_WIDTH); }); }); diff --git a/apps/web/src/components/ui/button.test.tsx b/apps/web/src/components/ui/button.test.tsx index 341d85b42bb..e1bd89d94cd 100644 --- a/apps/web/src/components/ui/button.test.tsx +++ b/apps/web/src/components/ui/button.test.tsx @@ -28,4 +28,19 @@ describe("button geometry tokens", () => { expect(html).toContain("size-7"); expect(html).toContain("sm:size-6"); }); + + it("owns shared compact and micro control geometry", () => { + const compact = renderToStaticMarkup(); + const micro = renderToStaticMarkup( + , + ); + + expect(compact).toContain("h-7"); + expect(compact).toContain("rounded-md"); + expect(micro).toContain("size-5"); + expect(micro).toContain("rounded-sm"); + expect(micro).toContain("text-muted-foreground"); + }); }); diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 778574ba010..9f0b4d049ed 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -16,9 +16,13 @@ const buttonVariants = cva( }, variants: { size: { + compact: + "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", default: "h-9 px-[calc(--spacing(3)-1px)] sm:h-8", icon: "size-9 sm:size-8", "icon-lg": "size-10 sm:size-9", + "icon-micro": + "size-5 rounded-sm p-0 before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 sm:size-7", "icon-xl": "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", @@ -38,6 +42,10 @@ const buttonVariants = cva( "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4", ghost: "[--control-icon-color:var(--muted-foreground)] border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent", + "ghost-muted": + "[--control-icon-color:var(--muted-foreground)] border-transparent text-muted-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground", + glass: + "surface-glass [--control-icon-color:var(--muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border", link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", outline: "[--control-icon-color:var(--muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64", diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 324b67e64d9..cf3a46142ad 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -170,7 +170,7 @@ function ComboboxPopup({ > diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx index 2ac9ee1ed41..04e34e5611e 100644 --- a/apps/web/src/components/ui/input-group.tsx +++ b/apps/web/src/components/ui/input-group.tsx @@ -8,7 +8,7 @@ import { Input, type InputProps } from "~/components/ui/input"; import { Textarea, type TextareaProps } from "~/components/ui/textarea"; const inputGroupVariants = cva( - "relative inline-flex w-full min-w-0 items-center rounded-lg border text-base text-foreground ring-ring/24 transition-shadow has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--radius-md)-1px)]", + "relative inline-flex w-full min-w-0 items-center rounded-[var(--control-radius)] border text-base text-foreground ring-ring/24 transition-shadow has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--control-radius)-1px)]", { defaultVariants: { variant: "default", @@ -16,7 +16,7 @@ const inputGroupVariants = cva( variants: { variant: { default: - "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)]", + "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--control-radius)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)]", ghost: "border-transparent bg-transparent shadow-none hover:bg-muted/40 has-[input:focus-visible,textarea:focus-visible]:bg-background", }, diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 6edc8d4a62f..cae3dfe6285 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -6,7 +6,7 @@ import type * as React from "react"; import { cn } from "~/lib/utils"; type InputProps = Omit, "size"> & { - size?: "sm" | "default" | "lg" | number; + size?: "sm" | "compact" | "default" | "lg" | number; unstyled?: boolean; nativeInput?: boolean; }; @@ -20,6 +20,7 @@ function Input({ }: InputProps) { const inputClassName = cn( "h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none placeholder:text-placeholder sm:h-7.5 sm:leading-7.5 [transition:background-color_5000000s_ease-in-out_0s]", + size === "compact" && "h-7 px-[calc(--spacing(2.5)-1px)] text-xs leading-7 sm:h-7 sm:leading-7", size === "sm" && "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5", size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5", props.type === "search" && @@ -59,6 +60,9 @@ function Input({ cn( !unstyled && "relative inline-flex w-full rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-ring has-autofill:bg-foreground/4 has-disabled:opacity-64 has-[:disabled,:focus-visible,[aria-invalid]]:shadow-none has-focus-visible:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]", + !unstyled && + size === "compact" && + "rounded-md before:rounded-[calc(var(--radius-md)-1px)]", className, ) || undefined } diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 9f7cfc8c067..803d6c1987c 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -36,6 +36,13 @@ function MenuPopup({ side?: MenuPrimitive.Positioner.Props["side"]; anchor?: MenuPrimitive.Positioner.Props["anchor"]; }) { + const hasExplicitWidthClass = + typeof className === "string" && + className.split(/\s+/).some((classToken) => { + const utility = classToken.split(":").at(-1) ?? classToken; + return /^(?:min-|max-)?w-/.test(utility); + }); + return (
) { return (
copyToClipboard(text)} - type="button" /> } > @@ -381,24 +382,22 @@ function ToastBodyContent({ > {copyErrorText !== null ? : null} {additionalActions.map(({ id, props: { className, ...props } }) => ( - ))}
- +
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6169e1a643c..4e636eb4ff0 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @custom-variant dark (&:is(.dark, .dark *)); +@custom-variant light (&:not(.dark, .dark *)); /* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */ @custom-variant wco (&:is(.wco, .wco *)); @@ -102,20 +103,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --workspace-native-controls-inset: 0px; --workspace-titlebar-control-size: 1.75rem; --workspace-titlebar-control-gap: 0.75rem; -} - -.dark { - --app-scrollbar-thumb: rgb(255 255 255 / 8%); - --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%); - --glass-blur: 16px; - --glass-saturation: 1.08; -} -[data-slot="sidebar-wrapper"] { - --workspace-titlebar-content-left: calc( - var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + - var(--workspace-titlebar-control-gap) - ); + @variant dark { + --app-scrollbar-thumb: rgb(255 255 255 / 8%); + --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%); + --glass-blur: 16px; + --glass-saturation: 1.08; + } } .wco { @@ -264,6 +258,179 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +@utility surface-glass { + background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--background) !important; + } +} + +@utility alert-glass { + --alert-glass-tint: transparent; + background: + linear-gradient( + color-mix(in srgb, var(--alert-glass-tint) 4%, transparent), + color-mix(in srgb, var(--alert-glass-tint) 4%, transparent) + ), + color-mix(in srgb, var(--background) var(--glass-opacity), transparent) !important; + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + + &[data-variant="error"] { + --alert-glass-tint: var(--destructive); + } + + &[data-variant="info"] { + --alert-glass-tint: var(--info); + } + + &[data-variant="success"] { + --alert-glass-tint: var(--success); + } + + &[data-variant="warning"] { + --alert-glass-tint: var(--warning); + } + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--background) !important; + } +} + +@utility dialog-glass { + background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border-color: color-mix(in srgb, var(--foreground) 10%, transparent); + box-shadow: 0 24px 64px -24px rgb(0 0 0 / 65%); + + @variant dark { + border-color: color-mix(in srgb, var(--color-white) 8%, transparent); + box-shadow: + inset 0 1px rgb(255 255 255 / 4%), + 0 24px 72px -20px rgb(0 0 0 / 90%); + } + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--popover) !important; + } +} + +@utility dialog-backdrop { + background: color-mix(in srgb, var(--background) 60%, transparent); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + + @variant dark { + background: color-mix(in srgb, var(--background) 64%, transparent); + } +} + +@utility dropdown-glass { + background: color-mix( + in srgb, + var(--popover) 18%, + color-mix(in srgb, var(--popover) var(--glass-opacity), transparent) + ); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--popover) !important; + } +} + +@utility topbar-scroll-fade { + --topbar-scroll-fade-height: 2.5rem; + -webkit-mask-image: + linear-gradient( + to bottom, + transparent 0%, + rgb(0 0 0 / 10%) 10%, + rgb(0 0 0 / 30%) 24%, + rgb(0 0 0 / 58%) 42%, + rgb(0 0 0 / 82%) 62%, + rgb(0 0 0 / 96%) 82%, + black 100% + ), + linear-gradient(black, black), linear-gradient(black, black); + -webkit-mask-position: top, bottom, right; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: + 100% var(--topbar-scroll-fade-height), + 100% calc(100% - var(--topbar-scroll-fade-height)), + var(--app-scrollbar-width) 100%; + mask-image: + linear-gradient( + to bottom, + transparent 0%, + rgb(0 0 0 / 10%) 10%, + rgb(0 0 0 / 30%) 24%, + rgb(0 0 0 / 58%) 42%, + rgb(0 0 0 / 82%) 62%, + rgb(0 0 0 / 96%) 82%, + black 100% + ), + linear-gradient(black, black), linear-gradient(black, black); + mask-position: top, bottom, right; + mask-repeat: no-repeat; + mask-size: + 100% var(--topbar-scroll-fade-height), + 100% calc(100% - var(--topbar-scroll-fade-height)), + var(--app-scrollbar-width) 100%; + + @variant sm { + --topbar-scroll-fade-height: 3rem; + } +} + +/* Virtualizers own their native scroll element, so they cannot use ScrollArea's + viewport fade. Keep the scrollbar lane opaque while sharing the same fade + contract across those lists. */ +@utility virtualized-scroll-fade { + -webkit-mask-image: var(--virtualized-scroll-fade-mask), linear-gradient(black, black); + mask-image: var(--virtualized-scroll-fade-mask), linear-gradient(black, black); + -webkit-mask-position: left, right; + mask-position: left, right; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: + calc(100% - var(--app-scrollbar-width)) 100%, + var(--app-scrollbar-width) 100%; + mask-size: + calc(100% - var(--app-scrollbar-width)) 100%, + var(--app-scrollbar-width) 100%; +} + +/* Stage-channel art needs a mask and pseudo-element gradient, so keep the + behavior composable without tying it to the global components layer. */ +@utility sidebar-stage-backdrop { + --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background)); + mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); + -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); + + &::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + to bottom, + transparent 0%, + transparent 28%, + color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%, + color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%, + color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%, + color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%, + color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%, + var(--stage-fade) 93% + ); + } +} + @layer base { :root { /* Keep the original T3 Code artwork palettes as the defaults. Built-in @@ -292,12 +459,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-glow-highlight: oklch(0.553749 0.176543 271.958); --stage-night-glow-secondary: oklch(0.345571 0.117466 273.568); --stage-night-sparkle: oklch(0.880867 0.057747 269.011); - } - .dark { - --stage-art-top: oklch(0.581473 0.149124 256.9); - --stage-art-mid: oklch(0.456509 0.159377 261.945); - --stage-art-bottom: oklch(0.291327 0.136578 267.649); + @variant dark { + --stage-art-top: oklch(0.581473 0.149124 256.9); + --stage-art-mid: oklch(0.456509 0.159377 261.945); + --stage-art-bottom: oklch(0.291327 0.136578 267.649); + } } * { @@ -311,63 +478,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil ):focus-visible { @apply outline-none ring-0; } - html { + html, + body { background-color: var(--app-chrome-background); } body { @apply text-foreground relative; - background-color: var(--app-chrome-background); } } @layer components { - .sidebar-brand { - display: none; - } - - .sidebar-brand-stage { - display: none; - } - - @media (min-width: 48rem) { - .sidebar-brand { - display: flex; - } - - @container sidebar-header (min-width: 15.75rem) { - .sidebar-brand-stage { - display: inline-flex; - } - } - } - - /* Stage-channel sidebar art; ::after ramps to the sidebar bg color and the - mask lets the surface grain show through at the boundary. Panels whose - background differs from the app chrome (e.g. sidebar v2) override - --sidebar-stage-fade so the art fades into their own surface color. */ - .sidebar-stage-backdrop { - --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background)); - mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); - -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); - } - - .sidebar-stage-backdrop::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - to bottom, - transparent 0%, - transparent 28%, - color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%, - color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%, - color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%, - color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%, - color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%, - var(--stage-fade) 93% - ); - } - /* Each maintainer palette gives the same line art its own material: rose vellum, forest drafting paper, marine cyanotype, copper, and violet ink. These colors stay deliberately deep at the top edge so the white stage @@ -380,16 +500,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-art-secondary: oklch(0.763402 0.163836 352.525); --stage-art-tertiary: oklch(0.70819 0.180285 311.949); --stage-art-line: oklch(0.952158 0.034194 336.179); - } - html.dark[data-theme-id="t3-chat"] { - --stage-art-top: oklch(0.540689 0.143665 347.587); - --stage-art-mid: oklch(0.396586 0.126592 347.6); - --stage-art-bottom: oklch(0.249959 0.079694 340.523); - --stage-art-highlight: oklch(0.921297 0.051708 343.229); - --stage-art-secondary: oklch(0.667398 0.165674 352.549); - --stage-art-tertiary: oklch(0.609315 0.163722 306.315); - --stage-art-line: oklch(0.945349 0.036045 341.433); + @variant dark { + --stage-art-top: oklch(0.540689 0.143665 347.587); + --stage-art-mid: oklch(0.396586 0.126592 347.6); + --stage-art-bottom: oklch(0.249959 0.079694 340.523); + --stage-art-highlight: oklch(0.921297 0.051708 343.229); + --stage-art-secondary: oklch(0.667398 0.165674 352.549); + --stage-art-tertiary: oklch(0.609315 0.163722 306.315); + --stage-art-line: oklch(0.945349 0.036045 341.433); + } } html[data-theme-id="grove"] { @@ -407,23 +527,23 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-secondary: oklch(0.665652 0.109731 156.599); --stage-night-tertiary: oklch(0.698651 0.103024 89.828); --stage-night-line: oklch(0.945336 0.041923 157.222); - } - html.dark[data-theme-id="grove"] { - --stage-art-top: oklch(0.58719 0.09869 157.426); - --stage-art-mid: oklch(0.454979 0.079031 159.756); - --stage-art-bottom: oklch(0.297856 0.050355 161.167); - --stage-art-highlight: oklch(0.952407 0.053872 158.44); - --stage-art-secondary: oklch(0.732591 0.120606 155.853); - --stage-art-tertiary: oklch(0.716282 0.116547 80.563); - --stage-art-line: oklch(0.961577 0.035285 157.03); - --stage-night-top: oklch(0.398632 0.065534 158.601); - --stage-night-mid: oklch(0.290561 0.049694 160.456); - --stage-night-bottom: oklch(0.210147 0.03173 169.818); - --stage-night-highlight: oklch(0.866303 0.057526 156.796); - --stage-night-secondary: oklch(0.586553 0.093722 157.365); - --stage-night-tertiary: oklch(0.6364 0.101769 82.985); - --stage-night-line: oklch(0.913292 0.035718 156.976); + @variant dark { + --stage-art-top: oklch(0.58719 0.09869 157.426); + --stage-art-mid: oklch(0.454979 0.079031 159.756); + --stage-art-bottom: oklch(0.297856 0.050355 161.167); + --stage-art-highlight: oklch(0.952407 0.053872 158.44); + --stage-art-secondary: oklch(0.732591 0.120606 155.853); + --stage-art-tertiary: oklch(0.716282 0.116547 80.563); + --stage-art-line: oklch(0.961577 0.035285 157.03); + --stage-night-top: oklch(0.398632 0.065534 158.601); + --stage-night-mid: oklch(0.290561 0.049694 160.456); + --stage-night-bottom: oklch(0.210147 0.03173 169.818); + --stage-night-highlight: oklch(0.866303 0.057526 156.796); + --stage-night-secondary: oklch(0.586553 0.093722 157.365); + --stage-night-tertiary: oklch(0.6364 0.101769 82.985); + --stage-night-line: oklch(0.913292 0.035718 156.976); + } } html[data-theme-id="ocean"] { @@ -434,16 +554,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-art-secondary: oklch(0.788391 0.090856 215.684); --stage-art-tertiary: oklch(0.76441 0.099607 187.893); --stage-art-line: oklch(0.976025 0.019647 212.543); - } - html.dark[data-theme-id="ocean"] { - --stage-art-top: oklch(0.59663 0.089167 233.427); - --stage-art-mid: oklch(0.461094 0.084904 243.478); - --stage-art-bottom: oklch(0.294818 0.05947 250.526); - --stage-art-highlight: oklch(0.952907 0.032224 221.27); - --stage-art-secondary: oklch(0.732079 0.09296 224.414); - --stage-art-tertiary: oklch(0.720885 0.095495 190.903); - --stage-art-line: oklch(0.961039 0.027355 219.756); + @variant dark { + --stage-art-top: oklch(0.59663 0.089167 233.427); + --stage-art-mid: oklch(0.461094 0.084904 243.478); + --stage-art-bottom: oklch(0.294818 0.05947 250.526); + --stage-art-highlight: oklch(0.952907 0.032224 221.27); + --stage-art-secondary: oklch(0.732079 0.09296 224.414); + --stage-art-tertiary: oklch(0.720885 0.095495 190.903); + --stage-art-line: oklch(0.961039 0.027355 219.756); + } } html[data-theme-id="ember"] { @@ -461,23 +581,23 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-secondary: oklch(0.641705 0.126508 44.376); --stage-night-tertiary: oklch(0.538694 0.129931 25.865); --stage-night-line: oklch(0.926348 0.046029 58.73); - } - html.dark[data-theme-id="ember"] { - --stage-art-top: oklch(0.597533 0.120694 43.455); - --stage-art-mid: oklch(0.437763 0.101287 34.86); - --stage-art-bottom: oklch(0.264269 0.055858 26.548); - --stage-art-highlight: oklch(0.929214 0.042638 55.801); - --stage-art-secondary: oklch(0.705592 0.137369 43.176); - --stage-art-tertiary: oklch(0.629583 0.158322 24.088); - --stage-art-line: oklch(0.945058 0.033906 58.824); - --stage-night-top: oklch(0.392352 0.081287 36.444); - --stage-night-mid: oklch(0.271305 0.056352 31.135); - --stage-night-bottom: oklch(0.182126 0.028154 27.774); - --stage-night-highlight: oklch(0.851007 0.061294 53.805); - --stage-night-secondary: oklch(0.560789 0.10645 42.953); - --stage-night-tertiary: oklch(0.476228 0.106656 24.165); - --stage-night-line: oklch(0.884931 0.046607 56.556); + @variant dark { + --stage-art-top: oklch(0.597533 0.120694 43.455); + --stage-art-mid: oklch(0.437763 0.101287 34.86); + --stage-art-bottom: oklch(0.264269 0.055858 26.548); + --stage-art-highlight: oklch(0.929214 0.042638 55.801); + --stage-art-secondary: oklch(0.705592 0.137369 43.176); + --stage-art-tertiary: oklch(0.629583 0.158322 24.088); + --stage-art-line: oklch(0.945058 0.033906 58.824); + --stage-night-top: oklch(0.392352 0.081287 36.444); + --stage-night-mid: oklch(0.271305 0.056352 31.135); + --stage-night-bottom: oklch(0.182126 0.028154 27.774); + --stage-night-highlight: oklch(0.851007 0.061294 53.805); + --stage-night-secondary: oklch(0.560789 0.10645 42.953); + --stage-night-tertiary: oklch(0.476228 0.106656 24.165); + --stage-night-line: oklch(0.884931 0.046607 56.556); + } } html[data-theme-id="iris"] { @@ -488,16 +608,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-art-secondary: oklch(0.745085 0.125892 298.647); --stage-art-tertiary: oklch(0.73066 0.167815 340.964); --stage-art-line: oklch(0.960278 0.024064 306.969); - } - html.dark[data-theme-id="iris"] { - --stage-art-top: oklch(0.57297 0.145973 295.185); - --stage-art-mid: oklch(0.419499 0.13752 292.131); - --stage-art-bottom: oklch(0.274235 0.095798 286.608); - --stage-art-highlight: oklch(0.916698 0.047206 300.224); - --stage-art-secondary: oklch(0.670994 0.13095 296.689); - --stage-art-tertiary: oklch(0.679357 0.165376 340.439); - --stage-art-line: oklch(0.940582 0.032921 299.076); + @variant dark { + --stage-art-top: oklch(0.57297 0.145973 295.185); + --stage-art-mid: oklch(0.419499 0.13752 292.131); + --stage-art-bottom: oklch(0.274235 0.095798 286.608); + --stage-art-highlight: oklch(0.916698 0.047206 300.224); + --stage-art-secondary: oklch(0.670994 0.13095 296.689); + --stage-art-tertiary: oklch(0.679357 0.165376 340.439); + --stage-art-line: oklch(0.940582 0.032921 299.076); + } } :is(html[data-theme-id="t3-chat"], html[data-theme-id="ocean"], html[data-theme-id="iris"]) { @@ -538,65 +658,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-sparkle: var(--stage-night-line); } - .workspace-topbar { - display: flex; - height: var(--workspace-topbar-height); - min-height: var(--workspace-topbar-height); - flex-shrink: 0; - align-items: center; - } - - /* Fade rows themselves as they pass beneath the top chrome. A mask remains - visible even when the header and timeline share the same background. */ - .chat-timeline-scroll-fade, - .settings-page-scroll-fade, - .pull-requests-scroll-fade { - --topbar-scroll-fade-height: 2.5rem; - -webkit-mask-image: - linear-gradient( - to bottom, - transparent 0%, - rgb(0 0 0 / 10%) 10%, - rgb(0 0 0 / 30%) 24%, - rgb(0 0 0 / 58%) 42%, - rgb(0 0 0 / 82%) 62%, - rgb(0 0 0 / 96%) 82%, - black 100% - ), - linear-gradient(black, black), linear-gradient(black, black); - -webkit-mask-position: top, bottom, right; - -webkit-mask-repeat: no-repeat; - -webkit-mask-size: - 100% var(--topbar-scroll-fade-height), - 100% calc(100% - var(--topbar-scroll-fade-height)), - var(--app-scrollbar-width) 100%; - mask-image: - linear-gradient( - to bottom, - transparent 0%, - rgb(0 0 0 / 10%) 10%, - rgb(0 0 0 / 30%) 24%, - rgb(0 0 0 / 58%) 42%, - rgb(0 0 0 / 82%) 62%, - rgb(0 0 0 / 96%) 82%, - black 100% - ), - linear-gradient(black, black), linear-gradient(black, black); - mask-position: top, bottom, right; - mask-repeat: no-repeat; - mask-size: - 100% var(--topbar-scroll-fade-height), - 100% calc(100% - var(--topbar-scroll-fade-height)), - var(--app-scrollbar-width) 100%; - } - - /* The pull request list sits directly under its topbar, so the tall band the chat and - settings pages fade under would read as empty padding here. A shorter band keeps the - fade while letting the controls start near the chrome. */ - .pull-requests-scroll-fade { - --topbar-scroll-fade-height: 1.5rem; - } - @keyframes settings-search-target-pulse { 0%, 100% { @@ -607,56 +668,29 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } - .settings-page-scroll-fade div.settings-search-target-pulse, - .settings-page-scroll-fade section.settings-search-target-pulse > div:first-child { + [data-settings-page-scroll] div.settings-search-target-pulse, + [data-settings-page-scroll] section.settings-search-target-pulse > div:first-child { animation: settings-search-target-pulse 650ms ease-in-out 2; border-radius: 0.75rem; } /* The pulse is the destination indicator; without it (reduced motion), the focus outline takes over, so exactly one indicator shows at a time. */ - .settings-page-scroll-fade .settings-search-target-pulse:focus { + [data-settings-page-scroll] .settings-search-target-pulse:focus { outline: none; } - .workspace-titlebar-controls { - position: absolute; - top: var(--workspace-controls-top); - right: var(--workspace-controls-right); - display: flex; - height: var(--workspace-topbar-height); - align-items: center; - -webkit-app-region: no-drag; - } - - .surface-subheader { - @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background; - } - - [data-preview-panel-mode="inline"] [data-right-panel-surface-content] [data-surface-subheader] { - height: calc(var(--spacing) * 7); - min-height: calc(var(--spacing) * 7); - margin-bottom: calc(var(--spacing) * 3); - border-bottom-color: transparent; - } - - .chat-composer-horizontal-inset { - padding-inline-start: calc(env(safe-area-inset-left) + 0.75rem); - padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem); - } - - .chat-composer-glass { - background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - } - .chat-composer-glass-shell { --chat-composer-glass-surface: var(--card); --chat-composer-outline: rgb(0 0 0 / 8%); - position: relative; isolation: isolate; + + @variant dark { + --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white)); + --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent); + --chat-composer-highlight: rgb(255 255 255 / 3%); + } } .chat-composer-glass-shell::before { @@ -716,8 +750,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } .chat-composer-glass-host { - position: relative; box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%); + + @variant dark { + box-shadow: none; + + &::after { + box-shadow: inset 0 1px var(--chat-composer-highlight); + } + } } .chat-composer-glass-host::after { @@ -747,6 +788,21 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil .chat-composer-context-strip { position: relative; isolation: isolate; + + @variant dark { + &::before { + border-color: rgb(255 255 255 / 7%); + background: + linear-gradient( + to bottom, + transparent 0 1rem, + rgb(0 0 0 / 18%) 1rem, + transparent calc(1rem + 10px) + ), + rgb(255 255 255 / 2%); + box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%); + } + } } .chat-composer-context-strip::before { @@ -762,33 +818,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil content: ""; } - .dark .chat-composer-glass-shell { - --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white)); - --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent); - --chat-composer-highlight: rgb(255 255 255 / 3%); - } - - .dark .chat-composer-glass-host { - box-shadow: none; - } - - .dark .chat-composer-glass-host::after { - box-shadow: inset 0 1px var(--chat-composer-highlight); - } - - .dark .chat-composer-context-strip::before { - border-color: rgb(255 255 255 / 7%); - background: - linear-gradient( - to bottom, - transparent 0 1rem, - rgb(0 0 0 / 18%) 1rem, - transparent calc(1rem + 10px) - ), - rgb(255 255 255 / 2%); - box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%); - } - @supports not (clip-path: shape(from 0 0, line to 1px 1px)) { .chat-composer-glass-shell-with-context::before { inset-block-end: var(--chat-composer-context-extension); @@ -806,106 +835,23 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); } - .dark .chat-composer-context-strip::before { - background: - linear-gradient( - to bottom, - transparent 0 1rem, - rgb(0 0 0 / 18%) 1rem, - transparent calc(1rem + 10px) - ), - linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)), - color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent); + .chat-composer-context-strip { + @variant dark { + &::before { + background: + linear-gradient( + to bottom, + transparent 0 1rem, + rgb(0 0 0 / 18%) 1rem, + transparent calc(1rem + 10px) + ), + linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)), + color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent); + } + } } } - .alert-glass { - --alert-glass-tint: transparent; - - background: - linear-gradient( - color-mix(in srgb, var(--alert-glass-tint) 4%, transparent), - color-mix(in srgb, var(--alert-glass-tint) 4%, transparent) - ), - color-mix(in srgb, var(--background) var(--glass-opacity), transparent) !important; - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - } - - .alert-glass[data-variant="error"] { - --alert-glass-tint: var(--destructive); - } - - .alert-glass[data-variant="info"] { - --alert-glass-tint: var(--info); - } - - .alert-glass[data-variant="success"] { - --alert-glass-tint: var(--success); - } - - .alert-glass[data-variant="warning"] { - --alert-glass-tint: var(--warning); - } - - .dialog-glass { - background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - } - - .dialog-backdrop { - background: color-mix(in srgb, var(--background) 60%, transparent); - -webkit-backdrop-filter: blur(4px); - backdrop-filter: blur(4px); - } - - .dropdown-glass { - /* - * Elevated glass needs a denser tint than broad ambient surfaces. Nesting - * the user-controlled mix inside an 18% popover tint preserves the full - * opacity setting range (40% -> 51%, 80% -> 84%, 100% -> 100%) while - * keeping high-contrast page content from blooming through menus. - */ - background: color-mix( - in srgb, - var(--popover) 18%, - color-mix(in srgb, var(--popover) var(--glass-opacity), transparent) - ); - -webkit-backdrop-filter: blur(var(--glass-blur)); - backdrop-filter: blur(var(--glass-blur)); - border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); - box-shadow: 0 16px 40px -18px rgb(0 0 0 / 55%); - } - - .dialog-glass { - border-color: color-mix(in srgb, var(--foreground) 10%, transparent); - box-shadow: 0 24px 64px -24px rgb(0 0 0 / 65%); - } - - .dark .dropdown-glass { - box-shadow: 0 18px 44px -18px rgb(0 0 0 / 80%); - } - - .dark .model-picker-surface.model-picker-surface { - background: color-mix( - in srgb, - var(--popover) 18%, - color-mix(in srgb, var(--popover) var(--glass-opacity), transparent) - ); - } - - .dark .dialog-glass { - border-color: color-mix(in srgb, var(--color-white) 8%, transparent); - box-shadow: - inset 0 1px rgb(255 255 255 / 4%), - 0 24px 72px -20px rgb(0 0 0 / 90%); - } - - .dark .dialog-backdrop { - background: color-mix(in srgb, var(--background) 64%, transparent); - } - .settings-slider { --settings-slider-progress: 0%; --settings-slider-fill-offset: 0.5rem; @@ -1021,32 +967,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } - @media (min-width: 40rem) { - .chat-timeline-scroll-fade, - .settings-page-scroll-fade { - --topbar-scroll-fade-height: 3rem; - } - - .chat-composer-horizontal-inset { - padding-inline-start: calc(env(safe-area-inset-left) + 1.25rem); - padding-inline-end: calc(env(safe-area-inset-right) + 1.25rem); - } - } - @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { - .chat-composer-glass, - .alert-glass { - background: var(--background) !important; - } - .chat-composer-glass-shell::before { background: var(--chat-composer-glass-surface); } - - .dialog-glass, - .dropdown-glass { - background: var(--popover) !important; - } } } @@ -1152,15 +1076,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --terminal-foreground: var(--foreground); --terminal-cursor: rgb(38 56 78); --terminal-selection-background: rgb(37 63 99 / 20%); - --terminal-scrollbar: rgb(0 0 0 / 15%); - --terminal-scrollbar-hover: rgb(0 0 0 / 25%); @variant dark { color-scheme: dark; /* Keep the workspace in the same neutral-black family as sidebar v2. Surfaces lift from this base instead of starting from a milky gray. */ --background: var(--color-neutral-950); - --app-chrome-background: var(--background); --surface-raised: var(--secondary); --foreground: var(--color-neutral-100); --card: color-mix(in srgb, var(--background) 97%, var(--color-white)); @@ -1168,54 +1089,31 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --popover: color-mix(in srgb, var(--background) 94%, var(--color-white)); --popover-foreground: var(--color-neutral-100); --primary: oklch(0.571 0.21 264); - --primary-foreground: var(--color-white); --secondary: --alpha(var(--color-white) / 4%); --secondary-foreground: var(--color-neutral-100); --muted: --alpha(var(--color-white) / 4%); --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); - --placeholder: var(--muted-foreground); - --secondary-label: var(--muted-foreground); - --icon-muted: var(--muted-foreground); - --message-surface: var(--accent); - --message-foreground: var(--foreground); - --message-action: var(--primary); - --message-action-foreground: var(--primary-foreground); - --message-action-hover: color-mix(in srgb, var(--primary) 90%, var(--background)); --accent: --alpha(var(--color-white) / 4%); --accent-foreground: var(--color-neutral-100); --error: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); --error-foreground: var(--color-red-400); --error-surface: color-mix(in srgb, var(--error) 16%, transparent); - --destructive: var(--error); --border: --alpha(var(--color-white) / 6%); --input: --alpha(var(--color-white) / 8%); - --ring: var(--primary); - --destructive-foreground: var(--error-foreground); - --info: var(--color-blue-500); --info-foreground: var(--color-blue-400); - --success: var(--color-emerald-500); --success-foreground: var(--color-emerald-400); - --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-400); --warning-surface: color-mix(in srgb, var(--warning) 16%, transparent); - --update: var(--primary); --update-foreground: var(--color-blue-400); --update-surface: color-mix(in srgb, var(--update) 18%, transparent); --sidebar: var(--card); - --sidebar-foreground: var(--foreground); - --sidebar-muted-foreground: var(--muted-foreground); --sidebar-control-surface: var(--muted); --sidebar-row-hover: var(--accent); --sidebar-row-active: var(--accent); --sidebar-row-selected: var(--muted); - --sidebar-border: var(--border); --sidebar-stage-fade: var(--card); - --terminal-background: var(--background); - --terminal-foreground: var(--foreground); --terminal-cursor: rgb(180 203 255); --terminal-selection-background: rgb(180 203 255 / 25%); - --terminal-scrollbar: rgb(255 255 255 / 10%); - --terminal-scrollbar-hover: rgb(255 255 255 / 18%); } } @@ -1242,32 +1140,28 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar-row-selected: var(--color-white); --sidebar-border: var(--color-zinc-200); --sidebar-stage-fade: var(--sidebar); - background-color: var(--sidebar); -} - -.dark [data-app-sidebar] { - --background: #000; - --foreground: #f1f3f7; - --card: #000; - --card-foreground: var(--foreground); - --accent: #191a1d; - --accent-foreground: #f7f9ff; - --muted: #0a0a0a; - --muted-foreground: #a3a3a3; - --border: rgb(255 255 255 / 8%); - --input: rgb(255 255 255 / 18%); - --sidebar: var(--card); - --sidebar-foreground: var(--foreground); - --sidebar-muted-foreground: var(--muted-foreground); - --sidebar-control-surface: var(--muted); - --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent); - --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent); - --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent); - --sidebar-border: var(--border); - /* The stage-channel header art must ramp to THIS panel's surface, not the - global chrome background, or the fade shows a seam (same rule as the - light palette above). */ - --sidebar-stage-fade: var(--card); + + @variant dark { + --background: #000; + --foreground: #f1f3f7; + --card: #000; + --card-foreground: var(--foreground); + --accent: #191a1d; + --accent-foreground: #f7f9ff; + --muted: #0a0a0a; + --muted-foreground: #a3a3a3; + --border: rgb(255 255 255 / 8%); + --input: rgb(255 255 255 / 18%); + --sidebar: var(--card); + --sidebar-foreground: var(--foreground); + --sidebar-muted-foreground: var(--muted-foreground); + --sidebar-control-surface: var(--muted); + --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent); + --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent); + --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent); + --sidebar-border: var(--border); + --sidebar-stage-fade: var(--card); + } } /* Theme files are expressed in app color roles and mapped to the existing @@ -1275,8 +1169,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil compatibility overrides so both navigation implementations receive the same palette. Success, info, provider, and channel identity colors remain independent; error, warning, and update roles are themeable below. */ -html[data-theme-id], -html.dark[data-theme-id] { +html[data-theme-id] { --background: var(--app-theme-canvas); --app-chrome-background: var(--app-theme-chrome); --toolbar-background: var(--app-theme-toolbar); @@ -1340,8 +1233,6 @@ html.dark[data-theme-id] { --terminal-foreground: var(--app-theme-terminal-foreground); --terminal-cursor: var(--app-theme-terminal-cursor); --terminal-selection-background: var(--app-theme-terminal-selection-background); - --terminal-scrollbar: var(--app-theme-terminal-scrollbar); - --terminal-scrollbar-hover: var(--app-theme-terminal-scrollbar-hover); } /* T3 Chat's composer is a translucent lift over --chat-background. Route its @@ -1349,35 +1240,21 @@ html.dark[data-theme-id] { another tint from the canvas, which made the dark composer too red. */ html[data-theme-id] .chat-composer-glass-shell { --chat-composer-glass-surface: var(--app-theme-surface-raised); -} - -html[data-theme-id]:not(.dark) .chat-composer-glass-shell { --chat-composer-outline: var(--app-theme-toolbar-border); -} -html.dark[data-theme-id] .chat-composer-glass-shell { - --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background)); - --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent); + @variant dark { + --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background)); + --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent); + } } -html.dark[data-theme-id="t3-chat"] .chat-composer-glass-shell { +html[data-theme-id="t3-chat"] .chat-composer-glass-shell { /* T3 Chat's visible composer edge is a dark plum, not the stock translucent white outline. Its highlight is derived from --chat-input-gradient. */ - --chat-composer-outline: #241e28; - --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent); -} - -html[data-theme-id]:not(.dark) { - color-scheme: light; -} - -html.dark[data-theme-id] { - color-scheme: dark; -} - -html[data-theme-id] body { - background-color: var(--app-chrome-background); - color: var(--foreground); + @variant dark { + --chat-composer-outline: #241e28; + --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent); + } } /* Theme-token dependency probes are restored synchronously, before paint. Keep @@ -1477,8 +1354,8 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] { toggle's when the trigger renders the toggle, so match both. */ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], -html[data-theme-id] .workspace-titlebar-controls [data-slot="toggle"], -html[data-theme-id] .workspace-titlebar-controls [data-slot="tooltip-trigger"] { +html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], +html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { --control-icon-color: var(--toolbar-foreground); color: var(--toolbar-foreground); } @@ -1522,19 +1399,23 @@ html[data-theme-id] .chat-markdown .chat-markdown-chrome-action { /* T3 Chat renders inline code and compact chat artifacts with its translucent secondary surface flattened over the light chat canvas. The raw muted and secondary tokens are substantially darker than those visible pixels. */ -html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code, -html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-state], -html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-header] { - background-color: var(--message-surface); -} +html[data-theme-id="t3-chat"] { + @variant light { + & .chat-markdown :not(pre) > code, + & [data-changed-files-state], + & [data-changed-files-header] { + background-color: var(--message-surface); + } -html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code, -html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-state] { - border-color: transparent; -} + & .chat-markdown :not(pre) > code, + & [data-changed-files-state] { + border-color: transparent; + } -html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code { - color: var(--message-foreground); + & .chat-markdown :not(pre) > code { + color: var(--message-foreground); + } + } } html[data-theme-id] .chat-markdown .chat-markdown-chrome-action:hover, @@ -1562,40 +1443,19 @@ html[data-theme-id] [data-app-sidebar] { --sidebar-row-selected: var(--app-theme-sidebar-row-selected); --sidebar-border: var(--app-theme-sidebar-border); --sidebar-stage-fade: var(--app-theme-sidebar); - background-color: var(--sidebar); -} - -/* Keep the navigation edge as quiet as the standard palettes. Theme files may - still use sidebarBorder for controls and internal separators, but the outer - divider should not become more prominent just because a palette is vivid. */ -html[data-theme-id] [data-app-sidebar] { border-color: color-mix(in srgb, var(--sidebar-foreground) 10%, transparent); -} -html.dark[data-theme-id] [data-app-sidebar] { - border-color: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent); + @variant dark { + border-color: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent); + } } /* T3 Chat's panel divider is deliberately pink, and its resize affordance keeps that color while hovered. Do not neutralize this branded edge. */ -html.dark[data-theme-id="t3-chat"] [data-app-sidebar] { - border-color: var(--sidebar-border); -} - -.theme-json-key { - color: var(--app-theme-accent, var(--color-blue-600)); -} - -.theme-json-string { - color: var(--app-theme-message-action, var(--color-emerald-600)); -} - -.theme-json-number { - color: var(--app-theme-secondary-foreground, var(--color-amber-600)); -} - -.theme-json-constant { - color: var(--app-theme-accent-surface-foreground, var(--color-violet-600)); +html[data-theme-id="t3-chat"] [data-app-sidebar] { + @variant dark { + border-color: var(--sidebar-border); + } } body { @@ -1715,125 +1575,7 @@ code { background: var(--app-scrollbar-thumb-hover); } -/* Settings -> Appearance can point the composer at its own face (for example a - mono font); default follows the sans stack. Applied on the surface wrapper so - the editor and its placeholder inherit together. */ -.composer-editor-surface { - font-family: var(--font-composer, var(--font-sans)); - font-size: var(--font-size-prompt, 0.875rem); -} - -/* Touch browsers zoom the page when a focused field is under 16px, so keep - the floor there regardless of the preference. Gated on a coarse pointer: - the zoom quirk does not exist on desktop, where a narrow window must not - silently override a smaller chosen prompt size. */ -@media (max-width: 39.999rem) and (pointer: coarse) { - .composer-editor-surface { - font-size: max(var(--font-size-prompt, 1rem), 16px); - } -} - -.t3-ghostty-canvas { - cursor: text; -} - -.t3-ghostty-scrollbar { - position: absolute; - z-index: 1; - top: 4px; - right: 1px; - bottom: 4px; - width: var(--app-scrollbar-width); - cursor: default; - touch-action: none; -} - -.t3-ghostty-scrollbar-thumb { - position: absolute; - top: 0; - right: 1px; - left: 1px; - border-radius: 3px; - background: var(--app-scrollbar-thumb); - transition: background-color 120ms ease-out; -} - -.t3-ghostty-scrollbar:hover .t3-ghostty-scrollbar-thumb, -.t3-ghostty-scrollbar:focus-visible .t3-ghostty-scrollbar-thumb { - background: var(--app-scrollbar-thumb-hover); -} - -.model-picker-list::-webkit-scrollbar-track { - margin-block: 0.5rem; -} - -.model-picker-list-scroll-fade-top, -.model-picker-list-scroll-fade-bottom { - -webkit-mask-image: var(--model-picker-list-scroll-mask), linear-gradient(black, black); - -webkit-mask-position: left, right; - -webkit-mask-repeat: no-repeat; - -webkit-mask-size: - calc(100% - var(--app-scrollbar-width)) 100%, - var(--app-scrollbar-width) 100%; - mask-image: var(--model-picker-list-scroll-mask), linear-gradient(black, black); - mask-position: left, right; - mask-repeat: no-repeat; - mask-size: - calc(100% - var(--app-scrollbar-width)) 100%, - var(--app-scrollbar-width) 100%; -} - -.model-picker-list-scroll-fade-top { - --model-picker-list-scroll-mask: linear-gradient(to bottom, transparent, black var(--fade-size)); -} - -.model-picker-list-scroll-fade-bottom { - --model-picker-list-scroll-mask: linear-gradient( - to bottom, - black calc(100% - var(--fade-size)), - transparent - ); -} - -.model-picker-list-scroll-fade-top.model-picker-list-scroll-fade-bottom { - --model-picker-list-scroll-mask: linear-gradient( - to bottom, - transparent, - black var(--fade-size), - black calc(100% - var(--fade-size)), - transparent - ); -} - -.turn-chip-strip { - scrollbar-width: none; - -ms-overflow-style: none; - overscroll-behavior-x: contain; -} - -.turn-chip-strip::-webkit-scrollbar { - display: none; -} - -/* Reasoning select -- clickable label surface */ -label:has(> select#reasoning-effort) { - position: relative; -} -label:has(> select#reasoning-effort) select { - position: absolute; - inset: 0; - opacity: 0; - cursor: pointer; - width: 100%; - height: 100%; -} - /* Chat markdown rendering */ -.chat-markdown { - min-width: 0; - overflow-wrap: anywhere; - word-break: break-word; -} .chat-markdown > :first-child { margin-top: 0; @@ -1945,18 +1687,6 @@ label:has(> select#reasoning-effort) select { background-size: 4px 2px; } -.chat-markdown .chat-markdown-link-favicon { - @apply inline-flex; - width: 14px; - height: 14px; - margin-inline: 0.25em 0.2em; - vertical-align: -0.125em; -} - -.chat-markdown .chat-markdown-link-leading { - white-space: nowrap; -} - .chat-markdown blockquote { border-left: 2px solid var(--border); padding-left: 0.8rem; @@ -2001,11 +1731,7 @@ label:has(> select#reasoning-effort) select { font-size: 0.75rem; } -.chat-markdown a.chat-markdown-file-link { - color: var(--foreground); - text-decoration: none; -} - +.chat-markdown a.chat-markdown-file-link, .chat-markdown a.chat-markdown-file-link:hover { color: var(--foreground); text-decoration: none; @@ -2023,75 +1749,26 @@ label:has(> select#reasoning-effort) select { border-radius: 0.75rem; background: var(--muted); padding: 0.8rem 0.9rem; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; } .chat-markdown pre code { border: none; background: transparent; padding: 0; - font-size: 0.75rem; -} - -.chat-markdown pre { - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; } .chat-markdown pre::-webkit-scrollbar { height: 7px; } -.chat-markdown pre::-webkit-scrollbar-track { - background: transparent; -} - .chat-markdown pre::-webkit-scrollbar-thumb { border-radius: 999px; background: color-mix(in srgb, var(--border) 78%, transparent); } -.markdown-file-link-tooltip-scroll { - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; -} - -.markdown-file-link-tooltip-scroll::-webkit-scrollbar { - height: 6px; -} - -.markdown-file-link-tooltip-scroll::-webkit-scrollbar-track { - background: transparent; -} - -.markdown-file-link-tooltip-scroll::-webkit-scrollbar-thumb { - border-radius: 999px; - background: color-mix(in srgb, var(--border) 78%, transparent); -} - -.chat-markdown .chat-markdown-codeblock { - margin: 0.65rem 0; - overflow: hidden; - border-radius: var(--radius); -} - -.chat-markdown .chat-markdown-codeblock-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.375rem 0.375rem 0 0.75rem; - color: color-mix(in srgb, var(--foreground) 72%, transparent); -} - -.chat-markdown .chat-markdown-codeblock-title { - display: inline-flex; - min-width: 0; - align-items: center; - gap: 0.4rem; - font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: 0.6875rem; -} - +.chat-markdown .chat-markdown-codeblock-header, .chat-markdown .chat-markdown-chrome-action { color: color-mix(in srgb, var(--foreground) 72%, transparent); } @@ -2167,13 +1844,6 @@ label:has(> select#reasoning-effort) select { overflow-wrap: anywhere; } -.chat-markdown .chat-markdown-table-footer { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 0.125rem; -} - /* Prompt-stash save acknowledgement: the new count fades up from just below its resting position, once, then stops. One-shot and event-driven (React remounts the element by key on each stash) — no continuous animation. */ @@ -2188,19 +1858,6 @@ label:has(> select#reasoning-effort) select { } } -.prompt-stash-count-enter { - animation: prompt-stash-count-enter 180ms ease-out both; -} - -@media (prefers-reduced-motion: reduce) { - .prompt-stash-count-enter { - animation: none; - } - [data-slot="skeleton"]::after { - content: none; - } -} - @keyframes provider-update-pill-countdown { from { transform: scaleX(1); @@ -2210,23 +1867,6 @@ label:has(> select#reasoning-effort) select { } } -.provider-update-pill-progress { - animation: provider-update-pill-countdown var(--provider-update-pill-dismiss-ms) linear forwards; -} - -/* Diffs theme bridge (match diff surfaces to app palette) */ -.diff-panel-viewport { - background: var(--background); -} - -/* Diffs live directly on the panel canvas. Normal chat code blocks may use a - raised code surface, but carrying that fill into the diff creates a card-like - rectangle that does not belong in the panel. */ -.diff-render-surface { - --code-background: var(--background); -} - -.diff-render-file, .diff-render-surface diffs-container { border: 0; border-radius: 0; @@ -2307,40 +1947,3 @@ label:has(> select#reasoning-effort) select { .ultrathink-chroma { animation: ultrathink-chroma-shift 10s linear infinite; } - -.ultrathink-pill { - background: - linear-gradient(var(--card), var(--card)) padding-box, - var(--ultrathink-spectrum) border-box; - background-size: - 100% 100%, - 220% 220%; - background-position: - 0 0, - 0% 50%; - animation: ultrathink-rainbow 10s linear infinite; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--card) 82%, transparent); -} - -.ultrathink-word { - display: inline-block; - color: transparent; - background-image: var(--ultrathink-spectrum); - background-size: 220% 220%; - background-position: 0% 50%; - background-clip: text; - -webkit-background-clip: text; - animation: ultrathink-rainbow 10s linear infinite; -} - -/* Composer chips are non-editable decorators, so the browser skips them when - painting text selection; this overlay stands in for the native highlight. */ -.composer-inline-chip[data-composer-chip-selected]::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 6px; - background-color: Highlight; - opacity: 0.3; - pointer-events: none; -} diff --git a/apps/web/src/routes/-chatIndexTitlebar.test.ts b/apps/web/src/routes/-chatIndexTitlebar.test.ts index 5e74103a542..803ba787116 100644 --- a/apps/web/src/routes/-chatIndexTitlebar.test.ts +++ b/apps/web/src/routes/-chatIndexTitlebar.test.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off - Regression coverage compares the onboarding header with the shared titlebar contract. +// @effect-diagnostics nodeBuiltinImport:off +// Regression coverage compares the onboarding header with the shared titlebar contract. import * as NodeFS from "node:fs"; import { describe, expect, it } from "vite-plus/test"; @@ -14,7 +15,9 @@ describe("hosted static onboarding header", () => { const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); - expect(onboardingHeader).toContain("workspace-topbar"); + expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]"); + expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]"); + expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS"); expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); }); }); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 12bbdf666c4..4f4da0c751e 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -145,7 +145,7 @@ function HostedStaticOnboardingState() {
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index aee41fb696d..66d9f0caa5d 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1308,7 +1308,8 @@ function PullRequestsRouteView() { // anchor the thread view's controls and the sidebar trigger use, so // every titlebar cluster in the app sits one shared inset from its // edge. - className="workspace-titlebar-controls z-50 mr-px gap-1 [-webkit-app-region:no-drag]" + className="absolute top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] z-50 mr-px flex h-[var(--workspace-topbar-height)] items-center gap-1 [-webkit-app-region:no-drag]" + data-workspace-titlebar-controls > {panelToggleControls}
@@ -1828,7 +1829,7 @@ function PullRequestsColumn({
{/* The top padding is the fade band's own height (1.5rem here), the same pairing the settings page makes: at rest the controls sit fully below the mask, and only diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index f14793ba544..a4b248c84ed 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -75,7 +75,7 @@ function SettingsContentLayout() { {!isElectron && (
diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 8a9c796b948..0bb33875568 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -578,8 +578,7 @@ export class GhosttyTerminalSurface { options: GhosttyTerminalSurfaceOptions, ): Promise { const canvas = document.createElement("canvas"); - canvas.className = "t3-ghostty-canvas"; - canvas.style.cssText = "display:block;width:100%;height:100%;"; + canvas.className = "block size-full cursor-text"; canvas.setAttribute("aria-hidden", "true"); const input = document.createElement("textarea"); @@ -592,14 +591,16 @@ export class GhosttyTerminalSurface { "position:absolute;left:4px;top:4px;width:1px;height:1px;opacity:0;padding:0;border:0;resize:none;pointer-events:none;"; const scrollbar = document.createElement("div"); - scrollbar.className = "t3-ghostty-scrollbar"; + scrollbar.className = + "group absolute top-1 right-px bottom-1 z-1 w-[var(--app-scrollbar-width)] cursor-default touch-none"; scrollbar.setAttribute("role", "scrollbar"); scrollbar.setAttribute("aria-label", "Terminal scrollback"); scrollbar.setAttribute("aria-orientation", "vertical"); scrollbar.tabIndex = 0; scrollbar.hidden = true; const scrollbarThumb = document.createElement("div"); - scrollbarThumb.className = "t3-ghostty-scrollbar-thumb"; + scrollbarThumb.className = + "absolute inset-x-px top-0 rounded-[3px] bg-[var(--app-scrollbar-thumb)] transition-[background-color] duration-[120ms] ease-[ease-out] group-hover:bg-[var(--app-scrollbar-thumb-hover)] group-focus-visible:bg-[var(--app-scrollbar-thumb-hover)]"; scrollbar.append(scrollbarThumb); mount.replaceChildren(canvas, input, scrollbar); From f0719072a1c6435b5a91243afc57bc8bf1f3e2b6 Mon Sep 17 00:00:00 2001 From: Simone Date: Fri, 14 Aug 2026 23:56:12 +0200 Subject: [PATCH 006/113] fix(server): handle files named HEAD in git status (#6397) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 21 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 9 +++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 18e594512ee..fc1b4d127bf 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -950,6 +950,27 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports changes to a file named HEAD", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "HEAD", "first line\n"); + yield* git(cwd, ["add", "HEAD"]); + yield* git(cwd, ["commit", "-m", "add HEAD file"]); + yield* writeTextFile(cwd, "HEAD", "first line\nsecond line\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.hasWorkingTreeChanges, true); + assert.deepInclude(status.workingTree.files, { + path: "HEAD", + insertions: 1, + deletions: 0, + }); + }), + ); + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 1489db9b3ff..9162865370b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -420,9 +420,10 @@ function isNonRepositoryGitStderr(stderr: string): boolean { return stderr.toLowerCase().includes("not a git repository"); } function isUnbornHeadStderr(stderr: string): boolean { + const normalized = stderr.toLowerCase(); return ( - stderr.toLowerCase().includes("unknown revision") && - stderr.toLowerCase().includes("path not in the working tree") + normalized.includes("bad revision 'head'") || + (normalized.includes("unknown revision") && normalized.includes("path not in the working tree")) ); } @@ -1600,7 +1601,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", cwd, - ["diff", "HEAD", "--numstat"], + ["diff", "HEAD", "--numstat", "--"], { allowNonZeroExit: true }, ).pipe( Effect.flatMap((result) => { @@ -1642,7 +1643,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetails.numstat", cwd, - args: ["diff", "HEAD", "--numstat"], + args: ["diff", "HEAD", "--numstat", "--"], }), detail: "git diff HEAD --numstat failed.", exitCode: result.exitCode, From e25021af767b10c560862fcec714cf67fb22cfae Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 14 Aug 2026 18:32:12 -0400 Subject: [PATCH 007/113] feat(packaging): maintain AUR packages in-repo (#4128) --- .github/workflows/publish-aur.yml | 65 ++++++++++++++ .github/workflows/release.yml | 10 +++ README.md | 10 +++ docs/user/install.md | 8 ++ packaging/aur/.gitignore | 9 ++ packaging/aur/README.md | 20 +++++ packaging/aur/scripts/release.sh | 97 ++++++++++++++++++++ packaging/aur/t3code-bin/PKGBUILD | 101 +++++++++++++++++++++ packaging/aur/t3code-nightly-bin/PKGBUILD | 102 ++++++++++++++++++++++ 9 files changed, 422 insertions(+) create mode 100644 .github/workflows/publish-aur.yml create mode 100644 packaging/aur/.gitignore create mode 100644 packaging/aur/README.md create mode 100755 packaging/aur/scripts/release.sh create mode 100644 packaging/aur/t3code-bin/PKGBUILD create mode 100644 packaging/aur/t3code-nightly-bin/PKGBUILD diff --git a/.github/workflows/publish-aur.yml b/.github/workflows/publish-aur.yml new file mode 100644 index 00000000000..62f8fd1f547 --- /dev/null +++ b/.github/workflows/publish-aur.yml @@ -0,0 +1,65 @@ +name: Publish AUR package + +# See packaging/aur/README.md. + +on: + workflow_call: + inputs: + release_tag: + required: true + type: string + pkgrel: + required: false + default: "1" + type: string + secrets: + AUR_SSH_PRIVATE_KEY: + required: true + workflow_dispatch: + inputs: + release_tag: + description: "Release tag to publish" + required: true + type: string + pkgrel: + description: "Arch package release override" + required: false + default: "1" + type: string + +permissions: + contents: read + +concurrency: + group: publish-aur + cancel-in-progress: false + +jobs: + publish: + name: Validate and publish + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 30 + container: + image: archlinux:base-devel + + steps: + - name: Install Arch packaging tools + run: pacman -Syu --noconfirm --needed git github-cli jq namcap openssh sudo + + - name: Checkout packaging sources + uses: actions/checkout@v6 + + - name: Create unprivileged build user + run: | + useradd --create-home builder + install -Dm0440 /dev/stdin /etc/sudoers.d/builder <<'EOF' + builder ALL=(root) NOPASSWD: /usr/bin/pacman + EOF + + - name: Validate and publish package sources + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + PKGREL: ${{ inputs.pkgrel || '1' }} + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + run: packaging/aur/scripts/release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81ef25effc8..6abd702bf88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -856,6 +856,16 @@ jobs: fail_on_unmatched_files: true token: ${{ github.token }} + publish_aur: + name: Publish AUR package + needs: [preflight, release] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + uses: ./.github/workflows/publish-aur.yml + with: + release_tag: ${{ needs.preflight.outputs.tag }} + secrets: + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + deploy_web: name: Deploy hosted web app needs: [preflight, relay_public_config, release] diff --git a/README.md b/README.md index c2349e72860..a7264ef62e9 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,20 @@ brew install --cask t3-code #### Arch Linux (AUR) +Stable: + ```bash yay -S t3code-bin ``` +Nightly: + +```bash +yay -S t3code-nightly-bin +``` + +The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur). + ## Some notes We are very very early in this project. Expect bugs. diff --git a/docs/user/install.md b/docs/user/install.md index fe0b418ca1e..96776c7ea1f 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -37,10 +37,18 @@ brew install --cask t3-code Arch Linux: +Stable: + ```bash yay -S t3code-bin ``` +Nightly: + +```bash +yay -S t3code-nightly-bin +``` + ## Providers T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want diff --git a/packaging/aur/.gitignore b/packaging/aur/.gitignore new file mode 100644 index 00000000000..e199a3b4e89 --- /dev/null +++ b/packaging/aur/.gitignore @@ -0,0 +1,9 @@ +src/ +pkg/ +*.AppImage +*.pkg.tar.zst +.SRCINFO +t3code-bin-*.png +t3code-bin-*-LICENSE +t3code-nightly-bin-*.png +t3code-nightly-bin-*-LICENSE diff --git a/packaging/aur/README.md b/packaging/aur/README.md new file mode 100644 index 00000000000..b91da505ace --- /dev/null +++ b/packaging/aur/README.md @@ -0,0 +1,20 @@ +# AUR packaging + +This directory maintains the [`t3code-bin`](https://aur.archlinux.org/packages/t3code-bin) and +[`t3code-nightly-bin`](https://aur.archlinux.org/packages/t3code-nightly-bin) packages. Both +repackage the official x86_64 AppImage from GitHub Releases. + +## Publishing + +The release workflow calls `.github/workflows/publish-aur.yml` after publishing a GitHub release; +the workflow can also be run manually for a specific tag. It selects the stable or nightly +package, then updates its version and checksums, builds it, regenerates `.SRCINFO`, and pushes it +to the AUR. + +To validate a release on Arch Linux: + +```bash +sudo pacman -Syu --needed base-devel github-cli jq namcap +GH_TOKEN=$(gh auth token) RELEASE_TAG=v0.0.33 \ + packaging/aur/scripts/release.sh +``` diff --git a/packaging/aur/scripts/release.sh b/packaging/aur/scripts/release.sh new file mode 100755 index 00000000000..427ca698ad1 --- /dev/null +++ b/packaging/aur/scripts/release.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +repo='pingdotgg/t3code' +tag="${RELEASE_TAG:?RELEASE_TAG is required}" +pkgrel="${PKGREL:-1}" + +if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + pkgname='t3code-bin' + icon_path='assets/prod/black-universal-1024.png' +elif [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$ ]]; then + pkgname='t3code-nightly-bin' + icon_path='assets/nightly/nightly-universal-1024.png' +else + echo "Release $tag does not publish an AUR package." + exit 0 +fi + +version="${tag#v}" +pkgver="${version//-/_}" +asset_name="T3-Code-${version}-x86_64.AppImage" +release_json="$(gh api "repos/$repo/releases/tags/$tag")" +asset_digest="$(jq -r --arg name "$asset_name" \ + '.assets[] | select(.name == $name) | .digest' <<<"$release_json")" +appimage_sha256="${asset_digest#sha256:}" + +if [[ ! "$appimage_sha256" =~ ^[0-9a-f]{64}$ ]]; then + echo "Release $tag is missing $asset_name or its SHA-256 digest." >&2 + exit 1 +fi + +work_dir="$(mktemp -d)" +trap 'rm -rf -- "$work_dir"' EXIT +gh api -H 'Accept: application/vnd.github.raw' \ + "repos/$repo/contents/$icon_path?ref=$tag" > "$work_dir/icon.png" +gh api -H 'Accept: application/vnd.github.raw' \ + "repos/$repo/contents/LICENSE?ref=$tag" > "$work_dir/LICENSE" +icon_sha256="$(sha256sum "$work_dir/icon.png" | awk '{print $1}')" +license_sha256="$(sha256sum "$work_dir/LICENSE" | awk '{print $1}')" + +package_dir="$repo_root/packaging/aur/$pkgname" +cd "$package_dir" +sed -Ei \ + -e "s/^pkgver=.*/pkgver=$pkgver/" \ + -e "s/^pkgrel=.*/pkgrel=$pkgrel/" \ + -e "/# AppImage$/s/'[0-9a-f]{64}'/'$appimage_sha256'/" \ + -e "/# icon$/s/'[0-9a-f]{64}'/'$icon_sha256'/" \ + -e "/# upstream license$/s/'[0-9a-f]{64}'/'$license_sha256'/" \ + PKGBUILD + +run_as_builder() { + if [[ "$(id -u)" == 0 ]]; then + runuser -u builder -- "$@" + else + "$@" + fi +} + +if [[ "$(id -u)" == 0 ]]; then + chown -R builder:builder "$package_dir" +fi +run_as_builder namcap PKGBUILD +run_as_builder makepkg --printsrcinfo > .SRCINFO +run_as_builder makepkg --syncdeps --cleanbuild --clean --noconfirm +run_as_builder namcap "$(run_as_builder makepkg --packagelist)" + +if [[ -z "${AUR_SSH_PRIVATE_KEY:-}" ]]; then + echo 'AUR_SSH_PRIVATE_KEY is not set; build complete, skipping publish.' + exit 0 +fi + +key_file="$work_dir/id_ed25519" +known_hosts_file="$work_dir/known_hosts" +aur_dir="$work_dir/$pkgname" +printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$key_file" +chmod 600 "$key_file" +printf '%s\n' \ + 'aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN' \ + > "$known_hosts_file" +export GIT_SSH_COMMAND="ssh -i $key_file -o IdentitiesOnly=yes -o UserKnownHostsFile=$known_hosts_file -o StrictHostKeyChecking=yes" + +git clone "ssh://aur@aur.archlinux.org/$pkgname.git" "$aur_dir" +cp PKGBUILD .SRCINFO "$aur_dir/" +cd "$aur_dir" +git rm --ignore-unmatch LICENSE .upstream-commit t3code-icon.png +git config user.name 't3code-ci' +git config user.email 't3code-ci@users.noreply.github.com' +git add -A + +if git diff --cached --quiet; then + echo 'AUR package is already up to date.' + exit 0 +fi + +git commit -m "$pkgname: update to $pkgver-$pkgrel" +git push origin HEAD:master diff --git a/packaging/aur/t3code-bin/PKGBUILD b/packaging/aur/t3code-bin/PKGBUILD new file mode 100644 index 00000000000..0f3d7628413 --- /dev/null +++ b/packaging/aur/t3code-bin/PKGBUILD @@ -0,0 +1,101 @@ +# Maintainer: maria-rcks + +pkgname=t3code-bin +pkgver=0.0.33 +pkgrel=1 +pkgdesc='Desktop control surface for local coding agents' +arch=('x86_64') +url='https://github.com/pingdotgg/t3code' +license=('MIT') +depends=( + 'alsa-lib' + 'at-spi2-core' + 'cairo' + 'dbus' + 'expat' + 'gdk-pixbuf2' + 'glib2' + 'glibc' + 'gtk3' + 'hicolor-icon-theme' + 'libcups' + 'libdrm' + 'libgcc' + 'libstdc++' + 'libx11' + 'libxcb' + 'libxcomposite' + 'libxdamage' + 'libxext' + 'libxfixes' + 'libxkbcommon' + 'libxrandr' + 'mesa' + 'nspr' + 'nss' + 'pango' + 'systemd-libs' + 'xdg-utils' + 'zlib' +) +optdepends=('openai-codex: use the system-installed Codex CLI') +provides=("t3code=$pkgver") +conflicts=('t3code') +options=('!debug' '!strip') + +_appimage="T3-Code-${pkgver}-x86_64.AppImage" +source=( + "$_appimage::https://github.com/pingdotgg/t3code/releases/download/v${pkgver}/$_appimage" + "${pkgname}-${pkgver}.png::https://raw.githubusercontent.com/pingdotgg/t3code/v${pkgver}/assets/prod/black-universal-1024.png" + "${pkgname}-${pkgver}-LICENSE::https://raw.githubusercontent.com/pingdotgg/t3code/v${pkgver}/LICENSE" +) +sha256sums=( + '415c8648f43c3d22d572f27f2c50fdc8c310ea7fcde9537b903e1e2f1c8775a1' # AppImage + '403e874556ffbecee8d1b2b5d612a874303fac791212a261bb3bd1b71d83e78d' # icon + '935d8f2af0c703f9c39517ee57cc4930b19d02d533be930b63f0e82f93614b43' # upstream license +) + +prepare() { + chmod +x "$srcdir/$_appimage" + rm -rf "$srcdir/squashfs-root" + "$srcdir/$_appimage" --appimage-extract >/dev/null + + if [[ ! -x "$srcdir/squashfs-root/AppRun" || + ! -f "$srcdir/squashfs-root/chrome-sandbox" ]]; then + echo 'The AppImage payload is missing its launcher or Chromium sandbox.' >&2 + return 1 + fi +} + +package() { + install -d "$pkgdir/opt/$pkgname" + cp -a --no-preserve=ownership "$srcdir/squashfs-root/." "$pkgdir/opt/$pkgname/" + chmod -R u=rwX,go=rX "$pkgdir/opt/$pkgname" + chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox" + + install -Dm755 /dev/stdin "$pkgdir/usr/bin/t3code" <<'EOF' +#!/bin/sh +exec /opt/t3code-bin/AppRun "$@" +EOF + ln -s t3code "$pkgdir/usr/bin/t3-code-desktop" + + install -Dm644 "$srcdir/${pkgname}-${pkgver}.png" \ + "$pkgdir/usr/share/icons/hicolor/1024x1024/apps/t3code.png" + + install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/t3code.desktop" <<'EOF' +[Desktop Entry] +Name=T3 Code +Comment=Desktop control surface for local coding agents +Exec=t3code %U +TryExec=t3code +Terminal=false +Type=Application +Icon=t3code +StartupWMClass=t3code +Categories=Development; +MimeType=x-scheme-handler/t3code; +EOF + + install -Dm644 "$srcdir/${pkgname}-${pkgver}-LICENSE" \ + "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/packaging/aur/t3code-nightly-bin/PKGBUILD b/packaging/aur/t3code-nightly-bin/PKGBUILD new file mode 100644 index 00000000000..76704be5ef5 --- /dev/null +++ b/packaging/aur/t3code-nightly-bin/PKGBUILD @@ -0,0 +1,102 @@ +# Maintainer: maria-rcks + +pkgname=t3code-nightly-bin +pkgver=0.0.34_nightly.20260814.1095 +pkgrel=1 +pkgdesc='Nightly desktop control surface for local coding agents' +arch=('x86_64') +url='https://github.com/pingdotgg/t3code' +license=('MIT') +depends=( + 'alsa-lib' + 'at-spi2-core' + 'cairo' + 'dbus' + 'expat' + 'gdk-pixbuf2' + 'glib2' + 'glibc' + 'gtk3' + 'hicolor-icon-theme' + 'libcups' + 'libdrm' + 'libgcc' + 'libstdc++' + 'libx11' + 'libxcb' + 'libxcomposite' + 'libxdamage' + 'libxext' + 'libxfixes' + 'libxkbcommon' + 'libxrandr' + 'mesa' + 'nspr' + 'nss' + 'pango' + 'systemd-libs' + 'xdg-utils' + 'zlib' +) +optdepends=('openai-codex: use the system-installed Codex CLI') +provides=("t3code-nightly=$pkgver") +conflicts=('t3code-nightly' 't3code') +options=('!debug' '!strip') + +_upstream_version="${pkgver/_nightly./-nightly.}" +_appimage="T3-Code-${_upstream_version}-x86_64.AppImage" +source=( + "$_appimage::https://github.com/pingdotgg/t3code/releases/download/v${_upstream_version}/$_appimage" + "${pkgname}-${pkgver}.png::https://raw.githubusercontent.com/pingdotgg/t3code/v${_upstream_version}/assets/nightly/nightly-universal-1024.png" + "${pkgname}-${pkgver}-LICENSE::https://raw.githubusercontent.com/pingdotgg/t3code/v${_upstream_version}/LICENSE" +) +sha256sums=( + 'c4dea5bba9ed0b51b2f60f2d4a4867e61d62b57c50ea66f2792a73112e054566' # AppImage + '7e59b6394016ef83ed1e946847769e01bf36d4062c5c5af2577fd3e228285fd9' # icon + '935d8f2af0c703f9c39517ee57cc4930b19d02d533be930b63f0e82f93614b43' # upstream license +) + +prepare() { + chmod +x "$srcdir/$_appimage" + rm -rf "$srcdir/squashfs-root" + "$srcdir/$_appimage" --appimage-extract >/dev/null + + if [[ ! -x "$srcdir/squashfs-root/AppRun" || + ! -f "$srcdir/squashfs-root/chrome-sandbox" ]]; then + echo 'The AppImage payload is missing its launcher or Chromium sandbox.' >&2 + return 1 + fi +} + +package() { + install -d "$pkgdir/opt/$pkgname" + cp -a --no-preserve=ownership "$srcdir/squashfs-root/." "$pkgdir/opt/$pkgname/" + chmod -R u=rwX,go=rX "$pkgdir/opt/$pkgname" + chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox" + + install -Dm755 /dev/stdin "$pkgdir/usr/bin/t3code-nightly" <<'EOF' +#!/bin/sh +exec /opt/t3code-nightly-bin/AppRun "$@" +EOF + ln -s t3code-nightly "$pkgdir/usr/bin/t3-code-nightly-desktop" + + install -Dm644 "$srcdir/${pkgname}-${pkgver}.png" \ + "$pkgdir/usr/share/icons/hicolor/1024x1024/apps/t3code-nightly.png" + + install -Dm644 /dev/stdin "$pkgdir/usr/share/applications/t3code.desktop" <<'EOF' +[Desktop Entry] +Name=T3 Code Nightly +Comment=Nightly desktop control surface for local coding agents +Exec=t3code-nightly %U +TryExec=t3code-nightly +Terminal=false +Type=Application +Icon=t3code-nightly +StartupWMClass=t3code +Categories=Development; +MimeType=x-scheme-handler/t3code; +EOF + + install -Dm644 "$srcdir/${pkgname}-${pkgver}-LICENSE" \ + "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} From 74f7b434865c2d758c7b1cd5f52f4c96b76d03fb Mon Sep 17 00:00:00 2001 From: Simone Date: Sat, 15 Aug 2026 01:32:33 +0200 Subject: [PATCH 008/113] fix(web): bound OKLCH gamut mapping (#6485) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> --- apps/web/src/themePalette.test.ts | 12 ++++++++++++ apps/web/src/themePalette.ts | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 95ce10af931..3c5897c7015 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -246,6 +246,18 @@ describe("theme files", () => { } }); + it("gamut maps extreme finite OKLCH chroma from theme files", () => { + const theme = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Extreme chroma", + appearance: "light", + colors: { accent: "oklch(0.5 1e303 0)" }, + }); + + expect(theme.colors.accent).toBe("oklch(0.5 1e+303 0)"); + expect(themeColorToHex(theme.colors.accent)).toBe("#b5005e"); + }); + it("rejects unknown roles and invalid color values", () => { expect(() => parseThemeFile({ diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 00b29dce83b..7bf4f0426bd 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -902,7 +902,11 @@ function mapThemeOklchToSrgbGamut(color: ThemeOklch): ThemeOklch { let low = 0; let high = color.C; - const steps = Math.max(1, Math.ceil(Math.log2(Math.max(color.C, 0.000001) / 0.000001))); + const chromaResolution = 0.000001; + const steps = Math.max( + 1, + Math.ceil(Math.log2(Math.max(color.C, chromaResolution)) - Math.log2(chromaResolution)), + ); for (let step = 0; step < steps; step += 1) { const mid = (low + high) / 2; if (isInGamut(mid)) low = mid; From 57a299a7852b430613dfdd97ba76249ee9f374d5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 14 Aug 2026 20:35:34 -0400 Subject: [PATCH 009/113] feat(web): open remote environments in your local editor over SSH (#6572) Co-authored-by: Claude Fable 5 --- apps/desktop/src/electron/ElectronShell.ts | 12 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.ts | 28 +++ apps/desktop/src/preload.ts | 1 + .../desktop/src/wsl/DesktopWslBackend.test.ts | 1 + .../src/environment/RemoteOpenTargets.test.ts | 126 ++++++++++++ .../src/environment/RemoteOpenTargets.ts | 72 +++++++ apps/server/src/preview/PortScanner.test.ts | 3 + apps/server/src/server.test.ts | 14 +- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 7 + .../src/components/chat/ChatHeader.test.ts | 21 +- apps/web/src/components/chat/ChatHeader.tsx | 16 +- apps/web/src/components/chat/OpenInPicker.tsx | 100 ++++++--- .../src/components/files/FilePreviewPanel.tsx | 5 +- apps/web/src/remoteOpen.test.ts | 149 ++++++++++++++ apps/web/src/remoteOpen.ts | 189 ++++++++++++++++++ packages/contracts/src/editor.ts | 79 +++++++- packages/contracts/src/ipc.ts | 7 + packages/contracts/src/server.ts | 8 +- packages/shared/src/Net.ts | 7 + packages/ssh/src/tunnel.test.ts | 1 + scripts/dev-runner.test.ts | 1 + 24 files changed, 806 insertions(+), 46 deletions(-) create mode 100644 apps/server/src/environment/RemoteOpenTargets.test.ts create mode 100644 apps/server/src/environment/RemoteOpenTargets.ts create mode 100644 apps/web/src/remoteOpen.test.ts create mode 100644 apps/web/src/remoteOpen.ts diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 316d3138bfa..126be71b6d4 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,3 +1,4 @@ +import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -5,7 +6,16 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); +// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) +// must reach the OS handler; every other non-web scheme stays blocked. +const SAFE_EXTERNAL_PROTOCOLS = new Set([ + "http:", + "https:", + ...REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { + const scheme = remoteSchemeForEditor(id); + return scheme === undefined ? [] : [`${scheme}:`]; + }), +]); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index cb35ad19ac7..3d9ff022c92 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -36,6 +36,7 @@ import { getLocalEnvironmentBearerToken, getWindowFullscreenState, openExternal, + probeRemoteEditors, pickFolder, pickThemeFiles, setTheme, @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 4a1213e4ec6..0e31082afb5 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 7a39eb42927..16f7a4694af 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,12 +3,16 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, + EDITORS, + EditorId, PickedThemeFileSchema, PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, + REMOTE_CAPABLE_EDITOR_IDS, type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; +import { isCommandAvailable } from "@t3tools/shared/shell"; import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -261,6 +265,30 @@ export const openExternal = DesktopIpc.makeIpcMethod({ }), }); +export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, + payload: Schema.Undefined, + result: Schema.Array(EditorId), + // Probes THIS machine (where the renderer runs) for remote-capable editor + // CLIs, unlike the server's probe which walks the environment host's PATH. + // A Finder-launched app can miss PATH entries; an empty result makes the + // renderer fall back to VS Code only, so that fails soft. + handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () { + const available: Array = []; + for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) { + const commands = EDITORS.find((editor) => editor.id === editorId)?.commands; + if (!commands) continue; + for (const command of commands) { + if (yield* isCommandAvailable(command, { env: process.env })) { + available.push(editorId); + break; + } + } + } + return available; + }), +}); + /** Theme files are a few KB; anything larger returns empty text and lets the * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2aa345ee584..61e345b9084 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -105,6 +105,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 2f58c6adcfb..ed8911d4007 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -77,6 +77,7 @@ const backendConfigurationLayer = Layer.succeed( const netLayer = Layer.succeed(NetService.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(41773), findAvailablePort: (preferred) => Effect.succeed(preferred), } satisfies NetService.NetService["Service"]); diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts new file mode 100644 index 00000000000..2f876b9955c --- /dev/null +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -0,0 +1,126 @@ +import { it } from "@effect/vitest"; +import { HostProcessHostname } from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { describe, expect } from "vite-plus/test"; + +import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; + +const encoder = new TextEncoder(); + +const TAILSCALE_STATUS_JSON = JSON.stringify({ + Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, +}); + +/** Spawner whose `tailscale status --json` exits with the given output. */ +const spawnerLayer = (input: { readonly exitCode: number; readonly stdout: string }) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(input.stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), + ); + +const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => + Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: (_port, host) => Effect.succeed(host === "::1" ? input.ipv6 : input.ipv4), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }); + +const resolveTargets = (input: { + readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; + readonly tailscale: { readonly exitCode: number; readonly stdout: string }; + readonly hostname: string; +}) => + Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe( + Effect.provideService(HostProcessHostname, input.hostname), + Effect.provide( + RemoteOpenTargets.layer.pipe( + Layer.provide(Layer.mergeAll(netLayer(input.sshd), spawnerLayer(input.tailscale))), + ), + ), + ); + +const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; +const TAILSCALE_DOWN = { exitCode: 1, stdout: "" }; + +describe("RemoteOpenTargets", () => { + it.effect("advertises nothing when no sshd accepts on either loopback", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: false, ipv6: false }, + tailscale: TAILSCALE_UP, + hostname: "bb-1", + }); + expect(targets).toEqual([]); + }), + ); + + it.effect("orders the tailnet name before the mDNS name", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: true }, + tailscale: TAILSCALE_UP, + hostname: "bb-1", + }); + expect(targets).toEqual([ + { kind: "tailscale", host: "bb-1.tail1234.ts.net" }, + { kind: "mdns", host: "bb-1.local" }, + ]); + }), + ); + + it.effect("accepts an sshd bound to IPv6 loopback only", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: false, ipv6: true }, + tailscale: TAILSCALE_DOWN, + hostname: "bb-1", + }); + expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); + }), + ); + + it.effect("falls back to mDNS alone when tailscale is unavailable", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: false }, + tailscale: TAILSCALE_DOWN, + hostname: "bb-1", + }); + expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); + }), + ); + + it.effect("shortens an FQDN hostname to its first label for mDNS", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: true }, + tailscale: TAILSCALE_DOWN, + hostname: "bb-1.example.com", + }); + expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); + }), + ); +}); diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts new file mode 100644 index 00000000000..f70dfa68aaa --- /dev/null +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -0,0 +1,72 @@ +/** + * RemoteOpenTargets - resolves the SSH hostnames this environment advertises + * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`). + * + * The server can only check itself: sshd listening locally, tailscaled + * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a + * given name resolves from the viewer's machine is inherently client-side. + * Targets are ordered most-reachable first (tailnet name works from anywhere + * on the tailnet; `.local` only on the same LAN). + */ +import { type RemoteOpenTarget } from "@t3tools/contracts"; +import { HostProcessHostname } from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import { readTailscaleStatus } from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +const SSH_PORT = 22; + +export class RemoteOpenTargets extends Context.Service< + RemoteOpenTargets, + { + readonly resolveTargets: () => Effect.Effect>; + } +>()("t3/environment/RemoteOpenTargets") {} + +export const make = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const net = yield* NetService.NetService; + + const resolveTargets = Effect.gen(function* () { + // No local sshd means no name can work; advertise nothing so clients + // render a clear "no SSH route" state instead of links that hang. + // Check both loopback families: sshd can be bound IPv6-only. + const sshdListening = yield* Effect.zipWith( + net.hasListenerOnHost(SSH_PORT, "127.0.0.1"), + net.hasListenerOnHost(SSH_PORT, "::1"), + (ipv4, ipv6) => ipv4 || ipv6, + ); + if (!sshdListening) { + return []; + } + + const targets: Array = []; + + // Tailscale absent or down is the common case, not an error. + const magicDnsName = yield* readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + if (magicDnsName !== null) { + targets.push({ kind: "tailscale", host: magicDnsName }); + } + + // os.hostname() may already be an FQDN (macOS often reports + // "Name.local"); mDNS names are always `.local`. + const hostname = yield* HostProcessHostname; + const shortHostname = hostname.split(".")[0]?.trim(); + if (shortHostname !== undefined && shortHostname.length > 0) { + targets.push({ kind: "mdns", host: `${shortHostname}.local` }); + } + + return targets; + }); + + return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets }); +}); + +export const layer = Layer.effect(RemoteOpenTargets, make); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 944cbd85a9c..7fa15defeca 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -47,6 +47,7 @@ let integrationListeningPort: number | null = null; const TestIntegrationNet = Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), + hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort), reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }); @@ -62,6 +63,7 @@ const makeProbeFailureLayer = ( Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }), @@ -107,6 +109,7 @@ const makeLsofScannerLayer = (input: { Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3f63eb4dbef..89f903c4f89 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -107,6 +107,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; +import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -650,10 +651,15 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ExternalLauncher.ExternalLauncher)({ - resolveAvailableEditors: () => Effect.succeed([]), - ...options?.layers?.externalLauncher, - }), + Layer.mergeAll( + Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), + ...options?.layers?.externalLauncher, + }), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ + resolveTargets: () => Effect.succeed([]), + }), + ), ), Layer.provide( Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 32bcaaa8b96..2226449eec0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -81,6 +81,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -420,6 +421,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), + Layer.provideMerge(RemoteOpenTargets.layer), Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6436a5e441a..56ea24a4a8b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -99,6 +99,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; @@ -361,6 +362,7 @@ const makeWsRpcLayer = ( const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; + const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; @@ -1010,6 +1012,11 @@ const makeWsRpcLayer = ( availableEditors: yield* resolveAvailableEditorsForConfig( externalLauncher.resolveAvailableEditors(), ), + // Same discovery-with-timeout treatment as editors: a slow probe + // must not stall server.getConfig, so it degrades to no targets. + remoteOpenTargets: yield* resolveAvailableEditorsForConfig( + remoteOpenTargets.resolveTargets(), + ), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index 94fe070ee3d..a200a206924 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -12,26 +12,40 @@ describe("shouldShowOpenInPicker", () => { activeProjectName: "codething-mvp", activeThreadEnvironmentId: primaryEnvironmentId, primaryEnvironmentId, + remoteOpenMode: "local-exec", }), ).toBe(true); }); - it("hides the picker when hosted static mode has no primary environment", () => { + it("shows the picker for remote environments in deep-link mode", () => { + expect( + shouldShowOpenInPicker({ + activeProjectName: "codething-mvp", + activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), + primaryEnvironmentId, + remoteOpenMode: "remote-links", + }), + ).toBe(true); + }); + + it("shows the picker's unavailable state for remote environments without an SSH route", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId: null, + remoteOpenMode: "remote-unavailable", }), - ).toBe(false); + ).toBe(true); }); - it("hides the picker for remote environments", () => { + it("hides the picker for non-primary local backends", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId, + remoteOpenMode: "local-exec", }), ).toBe(false); }); @@ -42,6 +56,7 @@ describe("shouldShowOpenInPicker", () => { activeProjectName: undefined, activeThreadEnvironmentId: primaryEnvironmentId, primaryEnvironmentId, + remoteOpenMode: "remote-links", }), ).toBe(false); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 643cf95ee88..08e0422dd25 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -30,6 +30,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; +import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; @@ -91,12 +92,19 @@ export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; + readonly remoteOpenMode: RemoteOpenMode; }): boolean { - return ( - Boolean(input.activeProjectName) && + if (!input.activeProjectName) return false; + if ( input.primaryEnvironmentId !== null && input.activeThreadEnvironmentId === input.primaryEnvironmentId - ); + ) { + return true; + } + // Remote environments get the picker in deep-link mode (or its explicit + // "no SSH route" state). Non-primary local backends (e.g. WSL) keep it + // hidden, matching pre-remote behavior. + return input.remoteOpenMode !== "local-exec"; } export const ChatHeader = memo(function ChatHeader({ @@ -128,10 +136,12 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeProjectScripts ? activeProjectCwd : null, ); + const remoteOpenState = useRemoteOpenState(activeThreadEnvironmentId); const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, primaryEnvironmentId, + remoteOpenMode: remoteOpenState.mode, }); const activeThreadRef = useMemo( () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 8b7a96880b8..afe35e18520 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,7 +1,19 @@ -import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + buildRemoteOpenUrl, + EditorId, + type EnvironmentId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; +import { + openRemoteEditorUrl, + useRemoteCapableEditors, + useRemoteOpenHint, + useRemoteOpenState, +} from "../../remoteOpen"; +import { useEnvironment } from "../../state/environments"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; @@ -199,10 +211,17 @@ export const OpenInPicker = memo(function OpenInPicker({ enableShortcut?: boolean; }) { const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); - const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); + const remote = useRemoteOpenState(environmentId); + const remoteCapableEditors = useRemoteCapableEditors(); + const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint(); + const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine"; + // Remote mode ignores the server's PATH probe: what matters is what runs on + // the viewing machine, which only the desktop app can probe. + const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors; + const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors); const options = useMemo( - () => resolveOptions(navigator.platform, availableEditors), - [availableEditors], + () => resolveOptions(navigator.platform, effectiveEditors), + [effectiveEditors], ); const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; @@ -211,6 +230,23 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; + if (remote.mode === "remote-unavailable") return; + if (remote.mode === "remote-links") { + const url = buildRemoteOpenUrl({ + editor, + host: remote.host.host, + absolutePath: openInCwd, + }); + if (url === undefined) return; + // Only record hint-seen/preferred when the shell actually accepted + // the URL (an older desktop build can refuse the editor scheme). + void openRemoteEditorUrl(url).then((opened) => { + if (!opened) return; + markRemoteHintSeen(); + setPreferredEditor(editor); + }); + return; + } const result = openInEditorMutation({ environmentId, input: { @@ -221,7 +257,15 @@ export const OpenInPicker = memo(function OpenInPicker({ setPreferredEditor(editor); return result; }, - [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor], + [ + environmentId, + markRemoteHintSeen, + openInCwd, + openInEditorMutation, + preferredEditor, + remote, + setPreferredEditor, + ], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -237,24 +281,11 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!preferredEditor) return; e.preventDefault(); - void openInEditorMutation({ - environmentId, - input: { - cwd: openInCwd, - editor: preferredEditor, - }, - }); + void openInEditor(preferredEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [ - enableShortcut, - environmentId, - keybindings, - openInCwd, - openInEditorMutation, - preferredEditor, - ]); + }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); return ( @@ -263,7 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({ className="ps-[8.5px]" size="xs" variant="outline" - disabled={!preferredEditor || !openInCwd} + disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"} onClick={() => openInEditor(preferredEditor)} > {primaryOption?.Icon && ( @@ -296,16 +327,25 @@ export const OpenInPicker = memo(function OpenInPicker({
diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index c4ca57b805f..19bf2d5ad16 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -20,6 +20,7 @@ import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPre import { useAssetUrlState } from "~/assets/assetUrls"; import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; +import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; @@ -771,6 +772,7 @@ export default function FilePreviewPanel({ const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const remoteOpenState = useRemoteOpenState(environmentId); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, @@ -893,7 +895,8 @@ export default function FilePreviewPanel({ ))}
- {absolutePath && environmentId === primaryEnvironmentId ? ( + {absolutePath && + (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? ( + new PrimaryConnectionTarget({ + environmentId, + label: "sol", + httpBaseUrl, + wsBaseUrl: httpBaseUrl.replace("http", "ws"), + }); + +const TAILSCALE_TARGETS = [ + { kind: "tailscale", host: "sol.tail1234.ts.net" }, + { kind: "mdns", host: "sol.local" }, +] as const; + +describe("resolveRemoteOpenState", () => { + it("keeps exec behavior for a loopback primary target", () => { + expect( + resolveRemoteOpenState({ + target: primaryTarget("http://127.0.0.1:8000"), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "local-exec" }); + }); + + it("uses deep links for a primary target reached over the network", () => { + expect( + resolveRemoteOpenState({ + target: primaryTarget("https://sol.tail1234.ts.net"), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ + mode: "remote-links", + host: { kind: "tailscale", host: "sol.tail1234.ts.net" }, + }); + }); + + it("keeps exec behavior for the desktop app's own primary even on a NAT URL", () => { + // wsl-only mode binds the primary to the WSL2 NAT address; it is still + // this machine because the desktop app manages its own primary backend. + expect( + resolveRemoteOpenState({ + target: primaryTarget("http://172.29.112.1:14369"), + sshAlias: null, + isDesktopRenderer: true, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "local-exec" }); + }); + + it("keeps exec behavior for desktop-local secondary backends", () => { + expect( + resolveRemoteOpenState({ + target: new BearerConnectionTarget({ + environmentId, + label: "WSL (Ubuntu)", + connectionId: "local:wsl-1", + }), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "local-exec" }); + }); + + it("prefers the desktop SSH alias over server-advertised hosts", () => { + expect( + resolveRemoteOpenState({ + target: new SshConnectionTarget({ + environmentId, + label: "sol", + connectionId: "ssh-1", + }), + sshAlias: "sol", + isDesktopRenderer: true, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }); + }); + + it("reports unavailable when a remote environment advertises no hosts", () => { + for (const remoteOpenTargets of [[], undefined] as const) { + expect( + resolveRemoteOpenState({ + target: new RelayConnectionTarget({ environmentId, label: "sol" }), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets, + }), + ).toEqual({ mode: "remote-unavailable" }); + } + }); + + it("falls back to exec when the environment has no catalog entry", () => { + expect( + resolveRemoteOpenState({ + target: null, + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: undefined, + }), + ).toEqual({ mode: "local-exec" }); + }); +}); + +describe("buildRemoteOpenUrl", () => { + it("builds a vscode-remote deep link", () => { + expect( + buildRemoteOpenUrl({ + editor: "vscode", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe("vscode://vscode-remote/ssh-remote+sol.tail1234.ts.net/home/theo/code/my%20repo"); + }); + + it("uses the fork's scheme", () => { + expect(buildRemoteOpenUrl({ editor: "cursor", host: "sol", absolutePath: "/tmp/x" })).toBe( + "cursor://vscode-remote/ssh-remote+sol/tmp/x", + ); + }); + + it("roots Windows paths", () => { + expect( + buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "C:\\Users\\theo" }), + ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); + }); + + it("returns undefined for editors without remote support", () => { + expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( + undefined, + ); + }); +}); diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts new file mode 100644 index 00000000000..dff8e9afa88 --- /dev/null +++ b/apps/web/src/remoteOpen.ts @@ -0,0 +1,189 @@ +/** + * Remote open-in-editor: when this client is not on the environment's + * machine, "Open" must hand the OS a `vscode://vscode-remote/ssh-remote+…` + * deep link (local editor connects over SSH) instead of exec'ing an editor + * on the environment host. + * + * Host precedence: a desktop-SSH environment's real `~/.ssh/config` alias + * beats server-advertised names; among advertised names the tailnet MagicDNS + * name beats mDNS `.local` (server sends them in that order). + */ +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; +import { + REMOTE_CAPABLE_EDITOR_IDS, + type EditorId, + type EnvironmentId, + type RemoteOpenTarget, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { useEffect, useMemo, useState } from "react"; + +import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; +import { isLoopbackHostname } from "~/environments/primary/target"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useEnvironmentPresentation } from "~/state/presentation"; + +export interface RemoteOpenHost { + readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; + readonly host: string; +} + +export type RemoteOpenState = + | { readonly mode: "local-exec" } + | { readonly mode: "remote-links"; readonly host: RemoteOpenHost } + | { readonly mode: "remote-unavailable" }; + +export type RemoteOpenMode = RemoteOpenState["mode"]; + +const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" }; +const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" }; + +function parseHostname(url: string): string | null { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +export function resolveRemoteOpenState(input: { + readonly target: ConnectionTarget | null; + /** Real ssh alias for desktop-SSH environments; null elsewhere. */ + readonly sshAlias: string | null; + /** Server-advertised hosts; undefined on servers that predate the feature. */ + readonly remoteOpenTargets: ReadonlyArray | undefined; + /** True when running inside the desktop app's renderer. */ + readonly isDesktopRenderer: boolean; +}): RemoteOpenState { + const { target } = input; + // No catalog entry: keep today's exec behavior rather than guessing. + if (target === null) { + return LOCAL_EXEC; + } + if (target._tag === "PrimaryConnectionTarget") { + // The desktop app manages its own primary backend, so it is always on + // this machine even when its URL is not loopback (wsl-only mode binds + // the WSL2 NAT address). In a browser, a loopback primary means the + // browser runs on the serving machine; a tailnet/LAN URL means remote. + if (input.isDesktopRenderer) { + return LOCAL_EXEC; + } + const hostname = parseHostname(target.httpBaseUrl); + if (hostname !== null && isLoopbackHostname(hostname)) { + return LOCAL_EXEC; + } + } else if (isDesktopLocalConnectionTarget(target)) { + return LOCAL_EXEC; + } + + if (input.sshAlias !== null && input.sshAlias.length > 0) { + return { mode: "remote-links", host: { kind: "ssh-alias", host: input.sshAlias } }; + } + const advertised = input.remoteOpenTargets?.[0]; + if (advertised !== undefined) { + return { mode: "remote-links", host: advertised }; + } + return REMOTE_UNAVAILABLE; +} + +export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState { + const { presentation } = useEnvironmentPresentation(environmentId); + + return useMemo(() => { + if (presentation === null) { + return LOCAL_EXEC; + } + const profile = Option.getOrNull(presentation.entry.profile); + const sshAlias = + profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null; + return resolveRemoteOpenState({ + target: presentation.entry.target, + sshAlias, + remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets, + isDesktopRenderer: window.desktopBridge !== undefined, + }); + }, [presentation]); +} + +/** + * Editors offered in remote-link mode. The desktop app probes the machine the + * renderer runs on; a browser cannot, so it offers VS Code only. + */ +const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode"]; + +let cachedProbedEditors: ReadonlyArray | null = null; + +export function __resetRemoteEditorProbeForTests(): void { + cachedProbedEditors = null; +} + +export function useRemoteCapableEditors(): ReadonlyArray { + const [editors, setEditors] = useState>( + () => cachedProbedEditors ?? REMOTE_FALLBACK_EDITORS, + ); + + useEffect(() => { + if (cachedProbedEditors !== null) { + return; + } + const probe = window.desktopBridge?.probeRemoteEditors; + if (probe === undefined) { + cachedProbedEditors = REMOTE_FALLBACK_EDITORS; + return; + } + let cancelled = false; + probe().then( + (ids) => { + const remoteCapable = ids.filter((id) => REMOTE_CAPABLE_EDITOR_IDS.includes(id)); + cachedProbedEditors = remoteCapable.length > 0 ? remoteCapable : REMOTE_FALLBACK_EDITORS; + if (!cancelled) { + setEditors(cachedProbedEditors); + } + }, + () => { + cachedProbedEditors = REMOTE_FALLBACK_EDITORS; + }, + ); + return () => { + cancelled = true; + }; + }, []); + + return editors; +} + +/** + * Fire a remote editor deep link. In desktop, route through the Electron + * shell so the OS handler opens without navigating the renderer; in a + * browser, assign the location — unlike window.open this does not leave a + * blank tab behind. + * + * Resolves false when the desktop shell refused the URL (e.g. an older + * build whose protocol allowlist predates editor schemes) so callers do not + * record a successful open that never happened. + */ +export async function openRemoteEditorUrl(url: string): Promise { + const bridge = window.desktopBridge; + if (bridge !== undefined) { + try { + return await bridge.openExternal(url); + } catch { + return false; + } + } + window.location.assign(url); + return true; +} + +/** + * One-time "you need SSH keys on that machine" hint, shown in the picker menu + * until the first remote open fires (we cannot observe SSH success from here, + * so first click is the dismiss signal). + */ +const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen"; + +export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] { + const [seen, setSeen] = useLocalStorage(REMOTE_OPEN_HINT_KEY, false, Schema.Boolean); + return [seen, () => setSeen(true)] as const; +} diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 5948d87e1d2..d714a0e0266 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -10,20 +10,45 @@ type EditorDefinition = { readonly commands: readonly [string, ...string[]] | null; readonly baseArgs?: readonly string[]; readonly launchStyle: EditorLaunchStyle; + /** + * URL scheme for editors that support VS Code's remote deep links + * (`://vscode-remote/ssh-remote+`). Only set for VS Code + * and forks that ship the Remote-SSH machinery. + */ + readonly remoteScheme?: string; }; export const EDITORS = [ - { id: "cursor", label: "Cursor", commands: ["cursor"], launchStyle: "goto" }, + { + id: "cursor", + label: "Cursor", + commands: ["cursor"], + launchStyle: "goto", + remoteScheme: "cursor", + }, { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" }, - { id: "vscode", label: "VS Code", commands: ["code"], launchStyle: "goto" }, + { + id: "vscode", + label: "VS Code", + commands: ["code"], + launchStyle: "goto", + remoteScheme: "vscode", + }, { id: "vscode-insiders", label: "VS Code Insiders", commands: ["code-insiders"], launchStyle: "goto", + remoteScheme: "vscode-insiders", + }, + { + id: "vscodium", + label: "VSCodium", + commands: ["codium"], + launchStyle: "goto", + remoteScheme: "vscodium", }, - { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto" }, { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, @@ -50,6 +75,54 @@ export const LaunchEditorInput = Schema.Struct({ }); export type LaunchEditorInput = typeof LaunchEditorInput.Type; +const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; + +/** Editors that can open a remote workspace via `vscode-remote` deep links. */ +export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => + remoteSchemeOf(editor) !== undefined ? [editor.id] : [], +); + +export const remoteSchemeForEditor = (id: EditorId): string | undefined => { + const editor = EDITORS.find((candidate) => candidate.id === id); + return editor === undefined ? undefined : remoteSchemeOf(editor); +}; + +/** + * Builds a `://vscode-remote/ssh-remote+` deep link that + * opens `absolutePath` on `host` in the local editor over SSH. Returns + * undefined for editors without remote deep-link support. + */ +export const buildRemoteOpenUrl = (input: { + readonly editor: EditorId; + readonly host: string; + readonly absolutePath: string; +}): string | undefined => { + const scheme = remoteSchemeForEditor(input.editor); + if (scheme === undefined) { + return undefined; + } + // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. + const posixPath = input.absolutePath.replaceAll("\\", "/"); + const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; + const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); + return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; +}; + +/** + * SSH hostnames an environment advertises for remote open links. Reachability + * is client-side; the server only advertises names that resolve to itself and + * gates them on a local sshd listen check. Ordered most-reachable first + * (tailnet MagicDNS name, then mDNS `.local`). + */ +export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); +export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type; + +export const RemoteOpenTarget = Schema.Struct({ + kind: RemoteOpenTargetKind, + host: TrimmedNonEmptyString, +}); +export type RemoteOpenTarget = typeof RemoteOpenTarget.Type; + export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass()( "ExternalLauncherUnknownEditorError", { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f99d4d34b4d..09d7d7a4602 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -92,6 +92,7 @@ import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } fr import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import type { ClientSettings } from "./settings.ts"; +import type { EditorId } from "./editor.ts"; import type { SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -1072,6 +1073,12 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + /** + * Probe this desktop machine for installed remote-capable editor CLIs + * (used for remote open-in-editor deep links). Optional: older desktop + * builds lack it; callers fall back to VS Code only. + */ + probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index d7bc4c5c189..9791a4f6218 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -17,7 +17,7 @@ import { KeybindingWhen, ResolvedKeybindingsConfig, } from "./keybindings.ts"; -import { EditorId } from "./editor.ts"; +import { EditorId, RemoteOpenTarget } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerSettings } from "./settings.ts"; @@ -428,6 +428,12 @@ export const ServerConfig = Schema.Struct({ // Editor ids grow over time; drop ones this build does not know rather than // failing the whole config decode. availableEditors: ForwardCompatibleArray(EditorId), + /** + * SSH hosts this environment advertises for remote open-in-editor links. + * Absent on servers that predate the feature; empty when the machine has no + * sshd or no advertisable name. + */ + remoteOpenTargets: Schema.optionalKey(ForwardCompatibleArray(RemoteOpenTarget)), observability: ServerObservability, settings: ServerSettings, /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index d7713a72612..4644576296b 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -39,6 +39,12 @@ export interface NetServiceShape { */ readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect; + /** + * Returns true when something accepts TCP connections on {host, port}. + * Unlike the bind-side checks this works for privileged ports (<1024). + */ + readonly hasListenerOnHost: (port: number, host: string) => Effect.Effect; + /** * Reserve an ephemeral loopback port and release it immediately. */ @@ -183,6 +189,7 @@ export const make = () => { return { canListenOnHost, isPortAvailableOnLoopback, + hasListenerOnHost, reserveLoopbackPort, findAvailablePort: (preferred) => Effect.gen(function* () { diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 4c2ecb33183..76b8ecccb30 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -80,6 +80,7 @@ const hangingHttpClient = HttpClient.make(() => Effect.never); const testNetService = NetService.NetService.of({ canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(41_773), findAvailablePort: (preferred) => Effect.succeed(preferred), }); diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 6914ebb6977..9b4f44475d9 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -35,6 +35,7 @@ const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} } const netServiceLayer = Layer.succeed(NetService.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(49_152), findAvailablePort: (port) => Effect.succeed(port), }); From d7abd7f3bb6f392ecb4ca21a1eeaec5bbc2d9393 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 14 Aug 2026 21:54:04 -0400 Subject: [PATCH 010/113] feat(web): refresh workspace layouts and tool activity --- .../desktop/src/electron/ElectronMenu.test.ts | 11 +- apps/desktop/src/electron/ElectronMenu.ts | 10 +- .../ActivityPayloadProjection.test.ts | 41 +- .../ActivityPayloadProjection.ts | 34 +- .../Layers/ProviderRuntimeIngestion.test.ts | 30 +- .../Layers/ProviderRuntimeIngestion.ts | 6 + apps/web/src/components/ChatView.tsx | 58 +- .../src/components/NoActiveThreadState.tsx | 19 +- apps/web/src/components/Sidebar.logic.ts | 3 +- apps/web/src/components/Sidebar.tsx | 316 ++++----- .../src/components/ThreadTerminalDrawer.tsx | 664 ++++++++++-------- .../src/components/WorkspacePageContainer.tsx | 62 ++ .../components/chat/ChangedFilesTree.test.tsx | 23 +- .../src/components/chat/ChangedFilesTree.tsx | 62 +- .../chat/MessagesTimeline.logic.test.ts | 216 +++++- .../components/chat/MessagesTimeline.logic.ts | 412 ++++++++++- .../components/chat/MessagesTimeline.test.tsx | 46 +- .../src/components/chat/MessagesTimeline.tsx | 491 +++++++++---- .../components/chat/PanelLayoutControls.tsx | 18 +- apps/web/src/components/composerInlineChip.ts | 14 +- .../pullRequest/PullRequestDetailPanel.tsx | 617 ++++++++-------- .../pullRequest/PullRequestGhosts.tsx | 115 ++- .../pullRequest/PullRequestListFilters.tsx | 15 +- .../pullRequest/PullRequestSummaryTab.tsx | 6 +- .../settings/DiagnosticsSettings.tsx | 2 +- .../settings/KeybindingsSettings.tsx | 2 +- .../settings/ProjectSettingsPanel.tsx | 26 +- .../settings/SettingsSidebarNav.tsx | 25 +- .../src/components/settings/ThemeSettings.tsx | 2 +- .../components/settings/settingsLayout.tsx | 9 +- .../src/components/sidebar/SidebarChrome.tsx | 146 ++-- .../components/threadActionMenu.logic.test.ts | 14 +- .../src/components/threadActionMenu.logic.ts | 42 +- apps/web/src/components/ui/segmented-tabs.tsx | 40 ++ apps/web/src/components/ui/toggle.tsx | 6 + apps/web/src/components/usage/UsagePage.tsx | 393 ++++------- .../components/usage/UsageProviderChart.tsx | 137 ++-- .../src/components/usage/usageProviders.ts | 4 +- apps/web/src/contextMenuFallback.ts | 98 ++- apps/web/src/index.css | 83 ++- apps/web/src/lib/openPullRequestLink.ts | 11 + .../web/src/routes/-chatIndexTitlebar.test.ts | 4 +- apps/web/src/routes/_chat.index.tsx | 12 +- apps/web/src/routes/_chat.pull-requests.tsx | 103 ++- apps/web/src/routes/settings.tsx | 46 +- apps/web/src/session-logic.test.ts | 133 +++- apps/web/src/session-logic.ts | 105 ++- apps/web/src/terminalUiStateStore.test.ts | 8 + apps/web/src/terminalUiStateStore.ts | 170 ++++- packages/contracts/src/ipc.ts | 4 + packages/shared/src/usageMerge.test.ts | 1 + packages/shared/src/usageMerge.ts | 40 +- 52 files changed, 3228 insertions(+), 1727 deletions(-) create mode 100644 apps/web/src/components/WorkspacePageContainer.tsx create mode 100644 apps/web/src/components/ui/segmented-tabs.tsx diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 58870bbab1d..e3c5d5dd643 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -98,7 +98,10 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(2), - items: [{ id: "copy", label: "Copy" }], + items: [ + { id: "copy", label: "Copy" }, + { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, + ], position: Option.some({ x: 10.8, y: 20.2 }), }); @@ -110,6 +113,12 @@ describe("ElectronMenu", () => { enabled: true, click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, }); + assert.deepEqual( + buildFromTemplateMock.mock.calls[0]?.[0].map( + (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label, + ), + ["Copy", "separator", "Delete"], + ); }).pipe(Effect.provide(TestLayer)), ); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 4d3e5a1c241..ca8cc246e89 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -78,6 +78,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM label: sourceItem.label, destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, + ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), }; if (sourceItem.children) { @@ -141,10 +142,17 @@ export const make = Effect.gen(function* () { ): Electron.MenuItemConstructorOptions[] => { const template: Electron.MenuItemConstructorOptions[] = []; let hasInsertedDestructiveSeparator = false; + const appendSeparator = () => { + if (template.length === 0 || template.at(-1)?.type === "separator") return; + template.push({ type: "separator" }); + }; for (const item of entries) { + if (item.separatorBefore) { + appendSeparator(); + } if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); + appendSeparator(); hasInsertedDestructiveSeparator = true; } diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index fc9ea4b6226..047e40ccf49 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -20,7 +20,7 @@ function activity(payload: Record): OrchestrationThreadActivity * If slimming ever moves to an allowlist over the whole payload, these * assertions are the tripwire. */ -describe("projectActivityPayload agent-field survival", () => { +describe("projectActivityPayload", () => { it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { const projected = projectActivityPayload( activity({ @@ -44,6 +44,45 @@ describe("projectActivityPayload agent-field survival", () => { expect(data.somethingClientNeverReads).toBeUndefined(); }); + it("normalizes Claude and OpenCode command inputs before slimming provider data", () => { + const claude = projectActivityPayload( + activity({ + itemType: "command_execution", + toolCallId: "claude-call-1", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + result: { content: "x".repeat(5_000) }, + }, + }), + ); + const openCode = projectActivityPayload( + activity({ + itemType: "command_execution", + toolCallId: "opencode-call-1", + data: { + tool: "bash", + state: { + status: "running", + input: { command: "vp lint" }, + output: "x".repeat(5_000), + }, + }, + }), + ); + + expect(claude.payload).toMatchObject({ + toolCallId: "claude-call-1", + data: { command: "vp test run" }, + }); + expect(openCode.payload).toMatchObject({ + toolCallId: "opencode-call-1", + data: { command: "vp lint" }, + }); + expect(JSON.stringify(claude.payload).length).toBeLessThan(200); + expect(JSON.stringify(openCode.payload).length).toBeLessThan(200); + }); + it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index f68a3ee96e9..659760c049a 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -104,6 +104,24 @@ function projectCommandData(data: Record): Record 0 ? projectedItem : undefined; } +function projectCommandValue(data: Record): unknown { + if (data.command !== undefined) { + return data.command; + } + + const input = asRecord(data.input); + if (input?.command !== undefined) { + return input.command; + } + + const stateInput = asRecord(asRecord(data.state)?.input); + if (stateInput?.command !== undefined) { + return stateInput.command; + } + + return undefined; +} + function summarizeToolTextOutput(value: string): string | null { const lines: string[] = []; for (const rawLine of value.split(/\r?\n/u)) { @@ -287,8 +305,9 @@ export function projectActivityPayload( if (item) { projectedData.item = item; } - if ("command" in data) { - projectedData.command = data.command; + const command = projectCommandValue(data); + if (command !== undefined) { + projectedData.command = command; } const changedFiles: string[] = []; @@ -368,10 +387,10 @@ function dropStaleContextWindowActivities( /** * Identity both clients use to fold a tool lifecycle row into the call it * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and - * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter - * emits one, otherwise the itemType/title/detail triple. Returns null for rows - * with no identity at all — those never collapse on the client either, so they - * must not be dropped here. + * mobile's `threadActivity`): the runtime item id ingestion stamps as + * `toolCallId`, a legacy `data.toolCallId`, or the itemType/title/detail triple. + * Returns null for rows with no identity at all — those never collapse on the + * client either, so they must not be dropped here. */ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null { const payload = asRecord(activity.payload); @@ -379,7 +398,8 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | return null; } - const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId); + const toolCallId = + asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId); if (toolCallId) { return `id:${toolCallId}`; } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 258aa010e3e..b5feda5052d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2811,11 +2811,16 @@ describe("ProviderRuntimeIngestion", () => { createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-9"), + itemId: asItemId("tool-call-9"), payload: { itemType: "command_execution", - status: "in_progress", - title: "Read file", - detail: "/tmp/file.ts", + status: "inProgress", + title: "Command run", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, }, }); @@ -2830,11 +2835,20 @@ describe("ProviderRuntimeIngestion", () => { ); expect(thread.session?.status).toBe("ready"); - expect( - thread.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", - ), - ).toBe(true); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started", + ); + const payload = activity?.payload as Record | undefined; + expect(payload).toMatchObject({ + itemType: "command_execution", + toolCallId: "tool-call-9", + status: "inProgress", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, + }); }); it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 03253797242..1eb7e54b3b3 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -794,6 +794,7 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool updated", payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), @@ -821,6 +822,8 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool", payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), + ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), @@ -847,7 +850,10 @@ export function runtimeEventToActivities( summary: `${event.payload.title ?? "Tool"} started`, payload: { itemType: event.payload.itemType, + ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), + ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), ...(event.payload.parentToolUseId ? { parentToolUseId: event.payload.parentToolUseId } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6eab33aec1c..e7193a7d0ff 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -167,7 +167,6 @@ import { WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -220,7 +219,11 @@ import { import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { + selectThreadTerminalCustomLabels, + selectThreadTerminalUiState, + useTerminalUiStateStore, +} from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; @@ -256,6 +259,7 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; +import { WorkspacePageHeader } from "./WorkspacePageContainer"; import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, @@ -653,6 +657,7 @@ interface PersistentThreadTerminalDrawerProps { newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; + onHide: () => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -667,6 +672,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra newShortcutLabel, closeShortcutLabel, keybindings, + onHide, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); @@ -990,6 +996,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra onSplitTerminal={splitTerminal} onSplitTerminalVertical={splitTerminalVertical} onNewTerminal={createNewTerminal} + onHide={onHide} splitShortcutLabel={visible ? splitShortcutLabel : undefined} splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} @@ -1540,6 +1547,16 @@ function ChatViewContent(props: ChatViewProps) { const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; const activeThreadEnvironmentId = activeThread?.environmentId ?? null; + const activeThreadRef = useMemo( + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], + ); + const activeTerminalCustomLabels = useTerminalUiStateStore((state) => + selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, activeThreadRef), + ); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, @@ -1569,18 +1586,15 @@ function ChatViewContent(props: ChatViewProps) { for (const session of activeThreadKnownSessions) { labels.set( session.target.terminalId, - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + activeTerminalCustomLabels[session.target.terminalId] ?? + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } + for (const [terminalId, label] of Object.entries(activeTerminalCustomLabels)) { + if (!labels.has(terminalId)) labels.set(terminalId, label); + } return labels; - }, [activeThreadKnownSessions]); - const activeThreadRef = useMemo( - () => - activeThreadEnvironmentId && activeThreadId - ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) - : null, - [activeThreadEnvironmentId, activeThreadId], - ); + }, [activeTerminalCustomLabels, activeThreadKnownSessions]); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -2808,6 +2822,7 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, storeSetTerminalOpen], ); + const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; @@ -6114,7 +6129,6 @@ function ChatViewContent(props: ChatViewProps) { ? "thread" : "page" } - chromeVariant="collapse" composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} /> @@ -6160,20 +6174,11 @@ function ChatViewContent(props: ChatViewProps) { data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} > {/* Top bar */} -
{!rightPanelOpen ? panelLayoutControls : null} -
+ ))} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 82dddd8f41e..cfc40f93638 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,26 +1,15 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; import { isElectron } from "../env"; -import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; +import { WorkspacePageHeader } from "./WorkspacePageContainer"; export function NoActiveThreadState() { return (
-
+ {isElectron ? ( - - No active thread - + No active thread ) : (
@@ -28,7 +17,7 @@ export function NoActiveThreadState() {
)} -
+
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9cb09219df0..f43bd5ea629 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -299,8 +299,9 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, projectGroupCount: number, + hasProjectScope = false, ): boolean { - return shiftKey || projectGroupCount <= 1; + return hasProjectScope || shiftKey || projectGroupCount <= 1; } export function orderItemsByPreferredIds(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2f0c5a22140..010571b915d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -184,6 +184,7 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Keep the v2 key so existing preferences survive the v2-to-default rename. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; +const SIDEBAR_LIFECYCLE_ICON_CLASS = "size-3 shrink-0"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -366,26 +367,20 @@ function SnoozePopoverButton(props: { ); return ( - - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" - /> - } - /> - } - > - - - Snooze thread - + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + /> + } + > + + {presets.map((preset) => ( ) : ( @@ -1223,7 +1218,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( @@ -1236,7 +1231,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) : ( )} @@ -1317,130 +1312,128 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} - {props.isPinned ? ( - props.pinningSupported ? ( - - - } + + {props.isPinned ? ( + props.pinningSupported ? ( + + ) : ( + + + + ) + ) : null} + {/* Only the visible state owns this slot's width: the pin stays + directly beside the idle status and beside the first action + when the hover controls replace it. */} - {topStatus ? ( - isWokeStatus ? ( - - ) : ( - - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( - - - - ) : null} - - ) - ) : ( - threadTimeLabel(thread) - )} - - {props.settlementSupported || showSnoozeButton ? ( + {/* Read-only status labels yield to the hover actions. Woke is + itself an action, so it stays pointer-enabled and visible + while the other controls appear beside it. */} - {showSnoozeButton ? ( - - ) : null} - {props.settlementSupported ? ( - - + + {topStatus.label} + + ) : ( + + {topStatus.icon === "working" ? ( + - } + ) : topStatus.icon === "done" ? ( + + ) : null} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} + + ) + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported || showSnoozeButton ? ( + + {showSnoozeButton ? ( + + ) : null} + {props.settlementSupported ? ( + + ) : null} + + ) : null} +
@@ -3218,17 +3211,25 @@ export default function Sidebar() { autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); - // New thread defaults to the project you're in (active thread's project, - // falling back to the top project) — same resolution the command palette - // uses. The command palette already offers a "New thread in..." submenu - // for multi-project setups. + // A selected project scope owns creation: users should not have to choose + // the same project twice. "All projects" keeps the picker in multi-project + // setups, while Shift+click retains the direct-create shortcut. const handleNewThreadClick = useCallback( (event?: ReactMouseEvent) => { - // One project: nothing to pick, create immediately. Shift+click creates - // directly in the current project even with several projects, skipping - // the palette picker. - if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { + if ( + shouldCreateNewThreadInCurrentProject( + event?.shiftKey ?? false, + projectGroups.length, + scopedProjectGroup !== null, + ) + ) { if (isMobile) setOpenMobile(false); + if (scopedProjectGroup) { + void newThreadContext.handleNewThread( + scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id), + ); + return; + } void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, @@ -3240,20 +3241,19 @@ export default function Sidebar() { if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); }, - [isMobile, newThreadContext, projectGroups.length, setOpenMobile], + [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile], ); - // The button mirrors chat.new: in multi-project setups both route through - // the command palette's "New thread in..." picker, and in single-project - // setups both create immediately. In multi-project setups the label is only - // the picker's shortcut: falling back to chat.newLocal would advertise the - // same shortcut for both the picker and direct create. In single-project - // setups both commands create directly, so chat.newLocal is a valid - // fallback. The second tooltip line (multi-project only) advertises - // shift+click and its keyboard twin chat.newLocal for direct create. + // With no explicit scope the button mirrors chat.new. A scoped button has + // intentionally more specific behavior, so it does not advertise the + // broader command's shortcut. const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + scopedProjectGroup === null + ? (shortcutLabelForCommand(keybindings, "chat.new") ?? + (projectGroups.length <= 1 + ? shortcutLabelForCommand(keybindings, "chat.newLocal") + : undefined)) + : undefined; const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -3332,7 +3332,9 @@ export default function Sidebar() { /> - {projectGroups.length > 1 ? ( + {scopedProjectGroup ? ( + `New thread in ${scopedProjectGroup.displayName}` + ) : projectGroups.length > 1 ? ( {newThreadShortcutLabel diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1266e5ed7e9..deec13ec3bd 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -5,11 +5,11 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; import { + PanelBottomCloseIcon, Plus, SquareSplitHorizontal, SquareSplitVertical, TerminalSquare, - Trash2, XIcon, } from "lucide-react"; import { @@ -21,7 +21,6 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, - type ReactNode, type SetStateAction, useCallback, useEffect, @@ -30,9 +29,9 @@ import { useRef, useState, } from "react"; -import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { useResizableWidth } from "~/hooks/useResizableWidth"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { @@ -60,6 +59,7 @@ import { import { readLocalApi } from "~/localApi"; import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; +import { selectThreadTerminalCustomLabels, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; @@ -72,10 +72,15 @@ import { resolveTerminalFontSizePreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../appearanceFonts"; +import { RightPanelResizeHandle } from "./preview/RightPanelResizeHandle"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; +const TERMINAL_SIDEBAR_DEFAULT_WIDTH = 144; +const TERMINAL_SIDEBAR_MIN_WIDTH = 144; +const TERMINAL_SIDEBAR_MAX_WIDTH = 320; +const TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY = "t3code:terminal-sidebar-width"; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; @@ -244,6 +249,10 @@ export function shouldHandleTerminalSelectionMouseUp( return selectionGestureActive && button === 0; } +export function shouldShowTerminalSidebar(terminalCount: number): boolean { + return terminalCount > 1; +} + export function terminalSelectionLineRange(position: { start: { y: number }; end: { y: number }; @@ -876,6 +885,7 @@ interface ThreadTerminalDrawerProps { onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; + onHide?: () => void; splitShortcutLabel?: string | undefined; splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; @@ -891,35 +901,6 @@ interface ThreadTerminalDrawerProps { terminalLaunchLocationsById?: ReadonlyMap; } -interface TerminalActionButtonProps { - label: string; - className: string; - onClick: () => void; - children: ReactNode; -} - -function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { - return ( - - } - > - {children} - - - {label} - - - ); -} - export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, @@ -937,6 +918,7 @@ export default function ThreadTerminalDrawer({ onSplitTerminal, onSplitTerminalVertical, onNewTerminal, + onHide, splitShortcutLabel, splitVerticalShortcutLabel, newShortcutLabel, @@ -950,6 +932,21 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; + const { width: terminalSidebarWidth, handlers: terminalSidebarResizeHandlers } = + useResizableWidth({ + storageKey: TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY, + defaultWidth: TERMINAL_SIDEBAR_DEFAULT_WIDTH, + minWidth: TERMINAL_SIDEBAR_MIN_WIDTH, + maxWidth: TERMINAL_SIDEBAR_MAX_WIDTH, + edge: "left", + }); + const terminalCustomLabels = useTerminalUiStateStore((state) => + selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, threadRef), + ); + const setTerminalCustomLabel = useTerminalUiStateStore((state) => state.setTerminalCustomLabel); + const [renamingTerminalId, setRenamingTerminalId] = useState(null); + const [terminalRenameDraft, setTerminalRenameDraft] = useState(""); + const cancelTerminalRenameRef = useRef(false); const [advancedTypography] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, false, @@ -1098,19 +1095,28 @@ export default function ThreadTerminalDrawer({ (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; - const hasTerminalSidebar = normalizedTerminalIds.length > 1; + const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length); const isSplitView = visibleTerminalIds.length > 1; - const showGroupHeaders = - resolvedTerminalGroups.length > 1 || - resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; - const terminalLabelById = useMemo(() => { + const automaticTerminalLabelById = useMemo(() => { const next = new Map(); for (const terminalId of normalizedTerminalIds) { next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); } return next; }, [normalizedTerminalIds, terminalLabelsById]); + const terminalLabelById = useMemo(() => { + const next = new Map(); + for (const terminalId of normalizedTerminalIds) { + next.set( + terminalId, + terminalCustomLabels[terminalId]?.trim() || + automaticTerminalLabelById.get(terminalId) || + getTerminalLabel(terminalId), + ); + } + return next; + }, [automaticTerminalLabelById, normalizedTerminalIds, terminalCustomLabels]); const resolveTerminalLaunchLocation = useCallback( (terminalId: string): TerminalLaunchLocation => { return ( @@ -1123,6 +1129,9 @@ export default function ThreadTerminalDrawer({ }, [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], ); + const newTerminalActionLabel = newShortcutLabel + ? `New Terminal (${newShortcutLabel})` + : "New Terminal"; const splitTerminalActionLabel = hasReachedSplitLimit ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel @@ -1133,9 +1142,6 @@ export default function ThreadTerminalDrawer({ : splitVerticalShortcutLabel ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` : "Split Terminal Vertically"; - const newTerminalActionLabel = newShortcutLabel - ? `New Terminal (${newShortcutLabel})` - : "New Terminal"; const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; @@ -1147,9 +1153,43 @@ export default function ThreadTerminalDrawer({ if (hasReachedSplitLimit) return; onSplitTerminalVertical(); }, [hasReachedSplitLimit, onSplitTerminalVertical]); - const onNewTerminalAction = useCallback(() => { - onNewTerminal(); - }, [onNewTerminal]); + const startTerminalRename = useCallback( + (terminalId: string) => { + cancelTerminalRenameRef.current = false; + setRenamingTerminalId(terminalId); + setTerminalRenameDraft( + terminalCustomLabels[terminalId] ?? terminalLabelById.get(terminalId) ?? "", + ); + }, + [terminalCustomLabels, terminalLabelById], + ); + const finishTerminalRename = useCallback(() => { + if (!renamingTerminalId) return; + const nextLabel = terminalRenameDraft.trim(); + const automaticLabel = automaticTerminalLabelById.get(renamingTerminalId) ?? ""; + setTerminalCustomLabel( + threadRef, + renamingTerminalId, + nextLabel.length === 0 || nextLabel === automaticLabel ? null : nextLabel, + ); + setRenamingTerminalId(null); + }, [ + automaticTerminalLabelById, + renamingTerminalId, + setTerminalCustomLabel, + terminalRenameDraft, + threadRef, + ]); + const cancelTerminalRename = useCallback(() => { + cancelTerminalRenameRef.current = true; + setRenamingTerminalId(null); + }, []); + + useEffect(() => { + cancelTerminalRenameRef.current = false; + setRenamingTerminalId(null); + setTerminalRenameDraft(""); + }, [threadRef.environmentId, threadRef.threadId]); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -1274,7 +1314,7 @@ export default function ThreadTerminalDrawer({ ) : null}

No terminal sessions for this thread yet.

-
@@ -1283,7 +1323,72 @@ export default function ThreadTerminalDrawer({ } const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); - + const compactTerminalToolbar = ( + <> + + + + + {!isPanel && onHide ? ( + <> + + + + ) : null} + + ); return (
); diff --git a/apps/web/src/components/WorkspacePageContainer.tsx b/apps/web/src/components/WorkspacePageContainer.tsx new file mode 100644 index 00000000000..4613dd465b1 --- /dev/null +++ b/apps/web/src/components/WorkspacePageContainer.tsx @@ -0,0 +1,62 @@ +import type { ComponentPropsWithoutRef } from "react"; + +import { cn } from "../lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; + +export type WorkspacePageWidth = "readable" | "wide" | "expanded"; + +const WIDTH_CLASS: Record = { + readable: "max-w-4xl", + wide: "max-w-5xl", + expanded: "max-w-6xl", +}; + +/** Shared full-page frame for workspace routes beneath their top bar. */ +export function WorkspacePageContainer({ + width = "readable", + className, + ...props +}: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) { + return ( +
+ ); +} + +/** Shared top-bar geometry for every full-width workspace surface. */ +export function WorkspacePageHeader({ + electron = false, + reserveNativeControls = electron, + className, + ...props +}: ComponentPropsWithoutRef<"header"> & { + readonly electron?: boolean; + readonly reserveNativeControls?: boolean; +}) { + return ( +
+ ); +} + +/** Keeps an icon glyph on the content edge while its larger hit target extends outward. */ +export function WorkspacePageHeaderEdgeControl({ + className, + ...props +}: ComponentPropsWithoutRef<"div">) { + return
; +} diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index e9fa1895bf9..bc3c4fa80df 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -23,13 +23,9 @@ describe("ChangedFilesCard", () => { expect(markup).toContain('data-changed-files-state="expanded"'); expect(markup).toContain('aria-expanded="true"'); expect(markup).toContain("whitespace-nowrap"); - expect(markup).toContain( - 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden', - ); + expect(markup).toContain('class="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1'); expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap'); - expect(markup).toContain('class="ml-1 hidden min-w-0 flex-1 truncate'); - expect(markup).toContain("@[24rem]/changed-files:inline"); - expect(markup).not.toContain("sm:inline"); + expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff'); expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"'); expect(markup).toContain("!size-[22px]"); expect(markup).toContain("size-3"); @@ -38,9 +34,11 @@ describe("ChangedFilesCard", () => { expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); expect(markup).toContain("1 changed file"); expect(markup).not.toContain("1 changed files"); + expect(markup).not.toContain("Hide files"); + expect(markup).not.toContain("ml-auto"); }); - it("renders a scope and representative-file preview for a large latest change", () => { + it("renders a clean representative-file preview for a large latest change", () => { const markup = renderToStaticMarkup( { expect(markup).toContain('data-changed-files-state="preview"'); expect(markup).toContain('aria-expanded="false"'); - expect(markup).toContain("apps"); - expect(markup).toContain("2 files"); - expect(markup).toContain("packages"); - expect(markup).toContain("root"); + expect(markup).toContain("apps/web/src/"); + expect(markup).toContain("packages/shared/src/"); expect(markup).toContain("App.tsx"); expect(markup).toContain("git.ts"); expect(markup).toContain("README.md"); - expect(markup).toContain("Show all 4 files"); + expect(markup).not.toContain("basis-0"); + expect(markup).not.toContain("+1 more"); + expect(markup).not.toContain("Show files"); + expect(markup).toContain('aria-label="120 additions, 20 deletions"'); expect(markup).not.toContain("App.test.tsx"); }); diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index d29d8b7f2f4..a8bb461c0e1 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,11 +19,7 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - changedFileName, - selectChangedFilePreview, - summarizeChangedFileScopes, -} from "./changedFilesPresentation"; +import { changedFileName, selectChangedFilePreview } from "./changedFilesPresentation"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -50,13 +46,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff, } = props; const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); - const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); const compactPreviewVisible = showCompactPreview && !expanded; return (
onExpandedChange(!expanded)} > )} - - {expanded ? "Hide files" : "Show files"} -
{expanded ? ( @@ -158,43 +150,35 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff={onOpenTurnDiff} /> ) : compactPreviewVisible ? ( -
-

- {scopeSummary.map((scope, index) => ( - - {index > 0 ? : null} - {scope.label} - - {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} - - - ))} -

-
+
+
{previewFiles.map((file) => ( ))} -
) : null} @@ -270,11 +254,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { ) : ( )} - + {node.name} {hasNonZeroStat(node.stat) && ( - + )} @@ -305,11 +289,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { theme={resolvedTheme} className="size-3.5 text-muted-foreground/70" /> - + {node.name} {node.stat && ( - + )} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6d74204bc1c..82338dec2a8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => { expect(expandedRows.map((row) => row.id)).toEqual([ "user-entry", - "turn-fold:turn-1", "assistant-thought-entry", - "work-entry-1", + "work-toggle:work-entry-1", + "turn-fold:turn-1", "assistant-final-entry", ]); expect( @@ -638,6 +638,84 @@ describe("deriveMessagesTimelineRows", () => { expect(foldRow?.label).toBe("Worked for 12s"); }); + it("keeps a superseded turn fold beside the final response after a steer", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "initial-user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "initial-user" as never, + role: "user", + text: "Start the work", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "superseded-work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:10Z", + entry: { + id: "superseded-work", + createdAt: "2026-01-01T00:00:10Z", + turnId: "turn-1" as never, + label: "Ran command", + tone: "tool", + }, + }, + { + id: "steer-user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:12Z", + message: { + id: "steer-user" as never, + role: "user", + text: "Change the approach", + turnId: null, + createdAt: "2026-01-01T00:00:12Z", + updatedAt: "2026-01-01T00:00:12Z", + streaming: false, + }, + }, + { + id: "assistant-final-entry", + kind: "message", + createdAt: "2026-01-01T00:00:20Z", + message: { + id: "assistant-final" as never, + role: "assistant", + text: "Implemented locally, uncommitted.", + turnId: "turn-2" as never, + createdAt: "2026-01-01T00:00:20Z", + updatedAt: "2026-01-01T00:00:21Z", + streaming: false, + }, + }, + ], + latestTurn: { + turnId: "turn-2" as never, + state: "completed", + startedAt: "2026-01-01T00:00:12Z", + completedAt: "2026-01-01T00:00:21Z", + }, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.map((row) => row.id)).toEqual([ + "initial-user-entry", + "steer-user-entry", + "turn-fold:turn-1", + "assistant-final-entry", + ]); + }); + it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ @@ -771,6 +849,7 @@ describe("deriveMessagesTimelineRows", () => { turnId: "turn-1" as never, label: "Ran command", tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, }, }, ], @@ -788,10 +867,133 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); expect(rows.map((row) => row.id)).toEqual([ + "working-indicator-row", "assistant-thought-entry", - "work-entry-1", + "work-live:work-entry-1", + ]); + }); + + it("keeps the current tool batch expandable while live entries append", () => { + const timelineEntries = [ + { + id: "work-entry-1", + kind: "work" as const, + createdAt: "2026-01-01T00:00:01Z", + entry: { + id: "work-1", + createdAt: "2026-01-01T00:00:01Z", + turnId: "turn-1" as never, + toolCallId: "call-1", + label: "Read file", + tone: "tool" as const, + }, + }, + { + id: "work-entry-2", + kind: "work" as const, + createdAt: "2026-01-01T00:00:02Z", + entry: { + id: "work-2", + createdAt: "2026-01-01T00:00:02Z", + turnId: "turn-1" as never, + toolCallId: "call-2", + label: "Run command", + command: "vp test run", + tone: "tool" as const, + }, + }, + ]; + const baseInput = { + timelineEntries, + latestTurn: { + turnId: "turn-1" as never, + state: "running" as const, + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }; + + const collapsedRows = deriveMessagesTimelineRows(baseInput); + const expandedRows = deriveMessagesTimelineRows({ + ...baseInput, + expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), + }); + + expect(collapsedRows.map((row) => row.id)).toEqual([ "working-indicator-row", + "work-live:tool:call-1", ]); + expect(collapsedRows.find((row) => row.kind === "work-live")).toMatchObject({ + groupId: "work-group:tool:call-1", + expanded: false, + groupedEntries: [{ id: "work-1" }, { id: "work-2" }], + }); + expect(expandedRows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:tool:call-1", + "work-1", + "work-2", + ]); + expect(expandedRows.find((row) => row.kind === "work-live")).toMatchObject({ + groupId: "work-group:tool:call-1", + expanded: true, + }); + + const appendedRows = deriveMessagesTimelineRows({ + ...baseInput, + timelineEntries: [ + ...timelineEntries, + { + id: "work-entry-3", + kind: "work" as const, + createdAt: "2026-01-01T00:00:03Z", + entry: { + id: "work-3", + createdAt: "2026-01-01T00:00:03Z", + turnId: "turn-1" as never, + toolCallId: "call-3", + label: "Changed file", + tone: "tool" as const, + }, + }, + ], + expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), + }); + + expect(appendedRows.map((row) => row.id)).toEqual([ + "working-indicator-row", + "work-live:tool:call-1", + "work-1", + "work-2", + "work-3", + ]); + + const rowsWithLaterPlan = deriveMessagesTimelineRows({ + ...baseInput, + timelineEntries: [ + ...timelineEntries, + { + id: "plan:thread-1:turn:turn-1", + kind: "proposed-plan" as const, + createdAt: "2026-01-01T00:00:03Z", + proposedPlan: { + id: "plan:thread-1:turn:turn-1", + turnId: "turn-1" as never, + planMarkdown: "# Next steps", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-01-01T00:00:03Z", + updatedAt: "2026-01-01T00:00:03Z", + }, + }, + ], + }); + expect(rowsWithLaterPlan.some((row) => row.kind === "work-live")).toBe(false); + expect(rowsWithLaterPlan.some((row) => row.kind === "proposed-plan")).toBe(true); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -852,7 +1054,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ "turn-1", ]); - expect(rows.map((row) => row.id)).toContain("running-work-entry"); + expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -994,18 +1196,18 @@ describe("deriveMessagesTimelineRows", () => { expandedWorkGroupIds: new Set(["work-group:work-entry-1"]), }); - expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]); + expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]); expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ groupId: "work-group:work-entry-1", - hiddenCount: 2, + hiddenCount: 3, expanded: false, onlyToolEntries: true, }); expect(expandedRows.map((row) => row.id)).toEqual([ + "work-toggle:work-entry-1", "work-1", "work-2", "work-3", - "work-toggle:work-entry-1", ]); expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ expanded: true, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 6bc0a2a6203..8d7fc52fdca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,6 +1,7 @@ import * as Equal from "effect/Equal"; import { formatDuration, + workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, @@ -166,6 +167,17 @@ export type MessagesTimelineRow = id: string; createdAt: string; groupedEntries: WorkLogEntry[]; + isExpandedToolGroupEntry: boolean; + isLastExpandedToolGroupEntry: boolean; + } + | { + kind: "work-live"; + id: string; + createdAt: string; + entry: WorkLogEntry; + groupedEntries: WorkLogEntry[]; + groupId: string; + expanded: boolean; } | { kind: "work-toggle"; @@ -175,6 +187,9 @@ export type MessagesTimelineRow = hiddenCount: number; expanded: boolean; onlyToolEntries: boolean; + summary: string | null; + summaryKind: ToolGroupAction | "mixed" | null; + hasFailure: boolean; } | { kind: "turn-fold"; @@ -208,7 +223,12 @@ export type MessagesTimelineRow = createdAt: string; turnPlan: TurnPlanEntry; } - | { kind: "working"; id: string; createdAt: string | null }; + | { + kind: "working"; + id: string; + createdAt: string | null; + showThinking: boolean; + }; export interface StableMessagesTimelineRowsState { byId: Map; @@ -238,6 +258,90 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +type ToolGroupAction = "read" | "edit" | "command" | "search" | "other"; + +function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { + if (entry.requestKind === "file-read" || entry.itemType === "image_view") return "read"; + if ( + entry.requestKind === "file-change" || + entry.itemType === "file_change" || + (entry.changedFiles?.length ?? 0) > 0 + ) { + return "edit"; + } + if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) { + return "command"; + } + if (entry.itemType === "web_search") return "search"; + return "other"; +} + +function toolGroupActionCount( + action: ToolGroupAction, + entries: ReadonlyArray, +): number { + if (action !== "edit") return entries.length; + + const changedFiles = new Set(); + let editsWithoutFileDetails = 0; + for (const entry of entries) { + if (!entry.changedFiles || entry.changedFiles.length === 0) { + editsWithoutFileDetails += 1; + continue; + } + for (const file of entry.changedFiles) changedFiles.add(file); + } + return changedFiles.size + editsWithoutFileDetails; +} + +function toolGroupActionLabel(action: ToolGroupAction, count: number): string { + switch (action) { + case "read": + return `Read ${count} ${count === 1 ? "file" : "files"}`; + case "edit": + return `Changed ${count} ${count === 1 ? "file" : "files"}`; + case "command": + return `Ran ${count} ${count === 1 ? "command" : "commands"}`; + case "search": + return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; + case "other": + return `Used ${count} ${count === 1 ? "tool" : "tools"}`; + } +} + +/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */ +export function summarizeToolGroup(entries: ReadonlyArray): string { + const groupedEntries = new Map(); + for (const entry of entries) { + const action = toolGroupAction(entry); + const group = groupedEntries.get(action); + if (group) group.push(entry); + else groupedEntries.set(action, [entry]); + } + const labels = [...groupedEntries].map(([action, actionEntries]) => + toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), + ); + const sentenceLabels = labels.map((label, index) => + index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), + ); + if (sentenceLabels.length < 2) return sentenceLabels[0] ?? ""; + if (sentenceLabels.length === 2) return sentenceLabels.join(" and "); + return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; +} + +function toolGroupSummaryKind(entries: ReadonlyArray): ToolGroupAction | "mixed" { + const actions = new Set(entries.map(toolGroupAction)); + return actions.size === 1 ? actions.values().next().value! : "mixed"; +} + +function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { + return entry.toolCallId ? `tool:${entry.toolCallId}` : timelineEntryId; +} + +function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string { + return `work-group:${workGroupIdentity(timelineEntryId, entry)}`; +} + export function resolveAssistantMessageCopyState({ text, showCopyButton, @@ -310,17 +414,34 @@ function deriveUnsettledTurnId( return isSettled ? null : latestTurn.turnId; } +function lastUserMessageIndex(timelineEntries: ReadonlyArray): number { + return timelineEntries.findLastIndex( + (entry) => entry.kind === "message" && entry.message.role === "user", + ); +} + +function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { + if (entry.kind === "message") { + return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null; + } + if (entry.kind === "turn-plan") { + return entry.turnPlan.turnId; + } + return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; +} + /** * Settled turns fold their commentary and tool activity behind a - * "Worked for ..." row anchored at the turn's first foldable entry; the - * terminal assistant message stays visible below the fold. + * "Worked for ..." row placed immediately before the next terminal assistant + * response. A steer can split one visible response across turn ids, so tying + * the disclosure to the first hidden entry would strand it above the steer. */ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unsettledTurnId: TurnId | null; -}): ReadonlyMap { +}): ReadonlyMap> { interface TurnGroup { entries: Array; terminalEntry: Extract | null; @@ -375,7 +496,7 @@ function deriveTurnFolds(input: { } } - const foldsByAnchorEntryId = new Map(); + const foldsByAnchorEntryId = new Map(); for (const [turnId, group] of groupsByTurnId) { if (turnId === input.unsettledTurnId) { continue; @@ -405,6 +526,24 @@ function deriveTurnFolds(input: { if (!firstEntry || !lastEntry) { continue; } + const lastHiddenEntryIndex = input.timelineEntries.findLastIndex((entry) => + hiddenEntryIds.has(entry.id), + ); + if (lastHiddenEntryIndex < 0) { + continue; + } + const nextTerminalAssistantEntry = input.timelineEntries + .slice(lastHiddenEntryIndex + 1) + .find( + (entry) => + entry.kind === "message" && + entry.message.role === "assistant" && + input.terminalAssistantMessageIds.has(entry.message.id), + ); + const anchorEntry = nextTerminalAssistantEntry ?? input.timelineEntries[lastHiddenEntryIndex]; + if (!anchorEntry) { + continue; + } const isLatestInterruptedTurn = input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted"; @@ -431,13 +570,16 @@ function deriveTurnFolds(input: { ? `Worked for ${duration}` : "Worked"; - foldsByAnchorEntryId.set(firstEntry.id, { + const fold = { turnId, - anchorEntryId: firstEntry.id, - createdAt: firstEntry.createdAt, + anchorEntryId: anchorEntry.id, + createdAt: anchorEntry.createdAt, hiddenEntryIds, label, - }); + }; + const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id); + if (anchoredFolds) anchoredFolds.push(fold); + else foldsByAnchorEntryId.set(anchorEntry.id, [fold]); } return foldsByAnchorEntryId; } @@ -469,36 +611,184 @@ export function deriveMessagesTimelineRows(input: { unsettledTurnId, }); const collapsedEntryIds = new Set(); - for (const fold of foldsByAnchorEntryId.values()) { - if (!input.expandedTurnIds?.has(fold.turnId)) { - for (const entryId of fold.hiddenEntryIds) { - collapsedEntryIds.add(entryId); + for (const folds of foldsByAnchorEntryId.values()) { + for (const fold of folds) { + if (!input.expandedTurnIds?.has(fold.turnId)) { + for (const entryId of fold.hiddenEntryIds) { + collapsedEntryIds.add(entryId); + } } } } + let activeTurnHeaderIndex = input.timelineEntries.length; + if (input.isWorking) { + const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries); + const firstOwnedAfterUser = + unsettledTurnId === null + ? -1 + : input.timelineEntries.findIndex( + (entry, index) => + index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId, + ); + activeTurnHeaderIndex = + firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1; + } + const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) => + input.isWorking && + index >= activeTurnHeaderIndex && + (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId); + const isVisibleActiveToolEntry = (entry: WorkLogEntry) => + workLogEntryIsToolLike(entry) && + (entry.toolLifecycleStatus === "inProgress" || !workEntryIndicatesToolNeutralStatus(entry)); + const activeEntries = input.isWorking + ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index)) + : []; + const activeTurnHasVisibleContent = + activeEntries.some((entry) => { + if (entry.kind === "message") { + return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0; + } + if (entry.kind === "work") { + return entry.entry.agentSpawn === undefined && isVisibleActiveToolEntry(entry.entry); + } + if (entry.kind === "turn-plan") return true; + return false; + }) || + input.timelineEntries + .slice(activeTurnHeaderIndex) + .some((entry) => entry.kind === "proposed-plan" || entry.kind === "turn-plan"); + + const activeWorkEntryIds = new Set(); + const activeWorkRowsByAnchorId = new Map< + string, + Extract + >(); + const hasLaterTurnContent = Array.from({ length: input.timelineEntries.length + 1 }, () => false); + for (let index = input.timelineEntries.length - 1; index >= 0; index -= 1) { + const entry = input.timelineEntries[index]; + if (!entry) continue; + const isVisibleTurnContent = + (entry.kind === "message" && entry.message.role === "user") || + entry.kind === "proposed-plan" || + (entryBelongsToActiveTurn(entry, index) && + ((entry.kind === "message" && entry.message.role === "assistant") || + entry.kind === "turn-plan" || + (entry.kind === "work" && + entry.entry.agentSpawn === undefined && + isVisibleActiveToolEntry(entry.entry)))); + hasLaterTurnContent[index] = isVisibleTurnContent || hasLaterTurnContent[index + 1] === true; + } + + for (let index = 0; index < input.timelineEntries.length; index += 1) { + const entry = input.timelineEntries[index]; + if ( + !entry || + entry.kind !== "work" || + entry.entry.agentSpawn !== undefined || + !entryBelongsToActiveTurn(entry, index) + ) { + continue; + } + if (!isVisibleActiveToolEntry(entry.entry)) { + continue; + } + + const anchorEntry = entry; + let latestToolEntry = entry; + const batchEntryIds = [entry.id]; + const visibleBatchEntries = [entry.entry]; + let cursor = index + 1; + while (cursor < input.timelineEntries.length) { + const nextEntry = input.timelineEntries[cursor]; + if ( + !nextEntry || + nextEntry.kind !== "work" || + nextEntry.entry.agentSpawn !== undefined || + !entryBelongsToActiveTurn(nextEntry, cursor) + ) { + break; + } + batchEntryIds.push(nextEntry.id); + if (isVisibleActiveToolEntry(nextEntry.entry)) { + latestToolEntry = nextEntry; + visibleBatchEntries.push(nextEntry.entry); + } + cursor += 1; + } + + // Once newer commentary, a plan, or another tool batch exists, this batch + // is history. Let the regular work-group path turn it into an expandable + // summary so none of its calls disappear behind the live one-line view. + if (hasLaterTurnContent[cursor] !== true) { + for (const entryId of batchEntryIds) activeWorkEntryIds.add(entryId); + const groupId = workGroupId(anchorEntry.id, anchorEntry.entry); + activeWorkRowsByAnchorId.set(anchorEntry.id, { + kind: "work-live", + id: `work-live:${workGroupIdentity(anchorEntry.id, anchorEntry.entry)}`, + createdAt: anchorEntry.createdAt, + entry: latestToolEntry.entry, + groupedEntries: visibleBatchEntries, + groupId, + expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, + }); + } + index = cursor - 1; + } + for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { continue; } - const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); - if (turnFold) { + if (input.isWorking && index === activeTurnHeaderIndex) { nextRows.push({ - kind: "turn-fold", - id: `turn-fold:${turnFold.turnId}`, - createdAt: turnFold.createdAt, - turnId: turnFold.turnId, - label: turnFold.label, - expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, + kind: "working", + id: "working-indicator-row", + createdAt: input.activeTurnStartedAt, + showThinking: !activeTurnHasVisibleContent, }); } + const anchoredTurnFolds = foldsByAnchorEntryId.get(timelineEntry.id); + if (anchoredTurnFolds) { + for (const turnFold of anchoredTurnFolds) { + nextRows.push({ + kind: "turn-fold", + id: `turn-fold:${turnFold.turnId}`, + createdAt: turnFold.createdAt, + turnId: turnFold.turnId, + label: turnFold.label, + expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, + }); + } + } + if (collapsedEntryIds.has(timelineEntry.id)) { continue; } + if (activeWorkEntryIds.has(timelineEntry.id)) { + const activeWorkRow = activeWorkRowsByAnchorId.get(timelineEntry.id); + if (activeWorkRow) { + nextRows.push(activeWorkRow); + if (activeWorkRow.expanded) { + for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { + nextRows.push({ + kind: "work", + id: workEntry.id, + createdAt: workEntry.createdAt, + groupedEntries: [workEntry], + isExpandedToolGroupEntry: true, + isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1, + }); + } + } + } + continue; + } + if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; @@ -507,6 +797,7 @@ export function deriveMessagesTimelineRows(input: { if ( !nextEntry || nextEntry.kind !== "work" || + activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || foldsByAnchorEntryId.has(nextEntry.id) ) { @@ -519,15 +810,48 @@ export function deriveMessagesTimelineRows(input: { (entry) => !workEntryIndicatesToolNeutralStatus(entry), ); if (visibleGroupedEntries.length > 0) { - if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { + const onlyToolEntries = visibleGroupedEntries.every( + (entry) => workLogEntryIsToolLike(entry) && entry.agentSpawn === undefined, + ); + if (onlyToolEntries) { + const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); + const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; + const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); + nextRows.push({ + kind: "work-toggle", + id: `work-toggle:${timelineEntry.id}`, + createdAt: timelineEntry.createdAt, + groupId, + hiddenCount: visibleGroupedEntries.length, + expanded, + onlyToolEntries: true, + summary: summarizeToolGroup(visibleGroupedEntries), + summaryKind, + hasFailure: visibleGroupedEntries.some((entry) => workEntryIndicatesToolFailure(entry)), + }); + if (expanded) { + for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { + nextRows.push({ + kind: "work", + id: workEntry.id, + createdAt: workEntry.createdAt, + groupedEntries: [workEntry], + isExpandedToolGroupEntry: true, + isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, + }); + } + } + } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { nextRows.push({ kind: "work", id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries: visibleGroupedEntries, + isExpandedToolGroupEntry: false, + isLastExpandedToolGroupEntry: false, }); } else { - const groupId = `work-group:${timelineEntry.id}`; + const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; // Agent-spawn CTA rows are always visible: a running fleet must // never hide behind a "+N tool calls" toggle. Selection is by @@ -551,6 +875,8 @@ export function deriveMessagesTimelineRows(input: { id: workEntry.id, createdAt: workEntry.createdAt, groupedEntries: [workEntry], + isExpandedToolGroupEntry: false, + isLastExpandedToolGroupEntry: false, }); } @@ -562,8 +888,11 @@ export function deriveMessagesTimelineRows(input: { groupId, hiddenCount: hiddenEntries.length, expanded, - onlyToolEntries: visibleGroupedEntries.every((entry) => - workLogEntryIsToolLike(entry), + onlyToolEntries, + summary: null, + summaryKind: null, + hasFailure: visibleGroupedEntries.some((entry) => + workEntryIndicatesToolFailure(entry), ), }); } @@ -629,11 +958,12 @@ export function deriveMessagesTimelineRows(input: { }); } - if (input.isWorking) { + if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, + showThinking: !activeTurnHasVisibleContent, }); } @@ -666,7 +996,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return a.createdAt === (b as typeof a).createdAt; + return ( + a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking + ); case "turn-fold": { const bf = b as typeof a; @@ -683,8 +1015,25 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; } - case "work": - return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); + case "work": { + const bw = b as typeof a; + return ( + a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry && + a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry && + Equal.equals(a.groupedEntries, bw.groupedEntries) + ); + } + + case "work-live": { + const bw = b as typeof a; + return ( + a.createdAt === bw.createdAt && + a.groupId === bw.groupId && + a.expanded === bw.expanded && + Equal.equals(a.entry, bw.entry) && + Equal.equals(a.groupedEntries, bw.groupedEntries) + ); + } case "work-toggle": { const bw = b as typeof a; @@ -693,7 +1042,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.groupId === bw.groupId && a.hiddenCount === bw.hiddenCount && a.expanded === bw.expanded && - a.onlyToolEntries === bw.onlyToolEntries + a.onlyToolEntries === bw.onlyToolEntries && + a.summary === bw.summary && + a.summaryKind === bw.summaryKind && + a.hasFailure === bw.hasFailure ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 194edc0bd5b..3dcf6cf2a30 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -554,7 +554,49 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work Log"); }); - it("formats changed file paths from the workspace root", () => { + it("makes the whole live tool row expandable without adding a chevron", () => { + const turnId = TurnId.make("turn-live-tools"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain('aria-label="Expand current tool calls"'); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain("Running psql"); + expect(markup).not.toContain("lucide-chevron-right"); + expect(markup).not.toContain("hover:bg-accent/20"); + }); + + it("summarizes completed changed-file activity", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); + expect(markup).toContain("Changed 1 file"); expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e190f47569b..3ccd4808d06 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -35,7 +35,6 @@ import { deriveTimelineEntries, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, - workEntryIndicatesToolSuccess, workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; @@ -57,7 +56,6 @@ import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, - MinusIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -920,17 +918,34 @@ type TimelineWorkEntry = Extract["grouped type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { + const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry; + const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry; + const isExpandedToolGroupHeader = + (row.kind === "work-toggle" && row.onlyToolEntries && row.expanded) || + (row.kind === "work-live" && row.expanded); + return (
- {row.kind === "work" ? : null} + {row.kind === "work" ? ( + + ) : null} + {row.kind === "work-live" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} @@ -1083,7 +1104,6 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; return (
@@ -1092,10 +1112,12 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.turnId)} - className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" + className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" > {row.label} - +
); @@ -1278,16 +1300,10 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ }); function WorkingTimelineRow({ row }: { row: Extract }) { - const { workingStepLabel } = use(TimelineRowActivityCtx); return ( -
-
- - - - - - +
+
+
{row.createdAt ? ( <> Working for @@ -1295,11 +1311,13 @@ function WorkingTimelineRow({ row }: { row: Extract - {workingStepLabel ? ( - · {workingStepLabel} - ) : null} +
+ {row.showThinking ? ( +
+ +
+ ) : null}
); } @@ -1340,8 +1358,10 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, + isExpandedToolGroupEntry, }: { groupedEntries: Extract["groupedEntries"]; + isExpandedToolGroupEntry: boolean; }) { const { workspaceRoot } = use(TimelineRowCtx); const nonEmptyEntries = useMemo( @@ -1358,7 +1378,10 @@ const WorkGroupSection = memo(function WorkGroupSection({ if (nonEmptyEntries.length === 0) return null; return ( -
+
{!onlyToolEntries && (

{groupLabel}

)} @@ -1368,6 +1391,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ key={workEntry.id} workEntry={workEntry} workspaceRoot={workspaceRoot} + isExpandedToolGroupEntry={isExpandedToolGroupEntry} /> ))}
@@ -1375,12 +1399,128 @@ const WorkGroupSection = memo(function WorkGroupSection({ ); }); +function LiveActivityRow({ label, iconName }: { label: string; iconName?: WorkEntryIconName }) { + return ( +
+ +
+
+
+ +
+
+
+
+ ); +} + +function ThinkingActivityRow() { + return ; +} + +function LiveActivityContent({ + label, + iconName, + highlighted = false, +}: { + label: string; + iconName: WorkEntryIconName | undefined; + highlighted?: boolean; +}) { + return ( +
+ {iconName ? ( + + + + ) : null} + {label} +
+ ); +} + +function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + + return ( + + ); +} + +function toolGroupSummaryIconName( + kind: Extract["summaryKind"], +): WorkEntryIconName { + switch (kind) { + case "read": + return "eye"; + case "edit": + return "square-pen"; + case "command": + return "terminal"; + case "search": + return "globe"; + case "other": + return "wrench"; + case "mixed": + case null: + return "hammer"; + } +} + function WorkGroupToggleTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); + if (row.onlyToolEntries && row.summary) { + return ( + + ); + } const labelNoun = row.onlyToolEntries ? row.hiddenCount === 1 ? "tool call" @@ -2019,32 +2159,101 @@ function workEntryPreview( : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } -function workEntryRawCommand( - workEntry: Pick, -): string | null { - const rawCommand = workEntry.rawCommand?.trim(); - if (!rawCommand || !workEntry.command) { - return null; +type CommandWrapper = "env" | "sudo"; + +const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record> = { + env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), + sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), +}; + +const COMMAND_WRAPPER_FLAGS: Record> = { + env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug"]), + sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]), +}; + +function commandProgramName(command: string): string | null { + const tokens = command.trim().split(/\s+/); + let index = 0; + let wrapper: CommandWrapper | null = null; + + while (index < tokens.length) { + const token = tokens[index]?.replace(/^["']|["']$/g, ""); + if (!token) return null; + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { + index += 1; + continue; + } + if (token === "env" || token === "sudo") { + wrapper = token; + index += 1; + continue; + } + if (wrapper !== null && token === "--") { + wrapper = null; + index += 1; + continue; + } + if (wrapper !== null && token.startsWith("-")) { + if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) { + if (tokens[index + 1] === undefined) return null; + index += 2; + continue; + } + if (COMMAND_WRAPPER_FLAGS[wrapper].has(token) || /^--[^=]+=/.test(token)) { + index += 1; + continue; + } + if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) { + let consumesNextToken = false; + for (const [optionIndex, option] of token.slice(1).split("").entries()) { + const shortOption = `-${option}`; + if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) { + consumesNextToken = optionIndex === token.length - 2; + break; + } + if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null; + } + if (consumesNextToken && tokens[index + 1] === undefined) return null; + index += consumesNextToken ? 2 : 1; + continue; + } + return null; + } + return token.split(/[\\/]/).at(-1) || null; } - return rawCommand === workEntry.command.trim() ? null : rawCommand; + + return null; +} + +function liveWorkEntryLabel( + workEntry: TimelineWorkEntry, + workspaceRoot: string | undefined, +): string { + const command = workEntry.command?.trim(); + if (command) { + const program = commandProgramName(command); + if (program) return `Running ${program}`; + return "Running command"; + } + + return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); } function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, ): string | null { + const command = workEntry.rawCommand?.trim() || workEntry.command?.trim(); const blocks: string[] = []; + if (command) { + blocks.push(command); + } if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); } - const raw = workEntryRawCommand(workEntry); - if (raw?.trim()) { - blocks.push(raw.trim()); - } else if (workEntry.command?.trim()) { - blocks.push(workEntry.command.trim()); - } - if (workEntry.detail?.trim()) { - blocks.push(workEntry.detail.trim()); + const detail = workEntry.detail?.trim(); + if (detail && detail !== command) { + blocks.push(detail); } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { @@ -2180,71 +2389,88 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time : "working" : failed > 0 ? `${failed} failed` - : "✓ completed"; + : "Completed"; return ( - +
+
+
+ + + {lead} + {workflowName ? ( + + {workflowName} + + ) : null} + + {status} + {totalTokens > 0 ? ( + + Σ {formatSubagentTokenCount(totalTokens)} + + ) : null} + +
+ +
+
); }); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; + isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot } = props; + const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; // Before any hooks: spawn CTA rows render their own component. if (workEntry.agentSpawn) { return ; } - return ; + return ( + + ); }); const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; + isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot } = props; - const activity = use(TimelineRowActivityCtx); + const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; - const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); - const heading = toolWorkEntryHeading(workEntry); - const rawPreview = workEntryPreview(workEntry, workspaceRoot); - const preview = - rawPreview && - normalizeCompactToolLabel(rawPreview).toLowerCase() === - normalizeCompactToolLabel(heading).toLowerCase() - ? null - : rawPreview; - const displayText = preview ? `${heading} - ${preview}` : heading; + const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); + const entryIconName = + showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry); + const isCommandEntry = + workEntry.requestKind === "command" || + workEntry.itemType === "command_execution" || + Boolean(workEntry.command); + const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const canExpand = expandedBody !== null; - const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); const iconWrapperClass = cn( - "flex size-5 shrink-0 items-center justify-center", - showWarningIndicator + "flex size-6 shrink-0 items-center justify-center", + showWarningIndicator || showFailedIndicator ? "text-destructive" : showDestructiveRowStyle ? "text-destructive" @@ -2256,17 +2482,16 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : "font-medium text-foreground"; - const turnSettled = !activity.activeTurnInProgress; - const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); - const showSuccessIndicator = - workEntryIndicatesToolSuccess(workEntry) || - (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); + : workLogEntryIsToolLike(workEntry) + ? "text-secondary-label" + : "text-foreground/80"; + const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; const rowToggleProps = canExpand ? { role: "button" as const, tabIndex: 0 as const, "aria-label": displayText, + "aria-expanded": expanded, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { @@ -2280,94 +2505,50 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { return (
- - - + {showEntryIcon ? ( + + + + ) : null}
-

- {heading} - {preview && ( - {preview} +

-

-
- - {canExpand ? ( - - ) : null} - - - {showFailedIndicator ? ( - - - } - > - - - Failed - - ) : showSuccessIndicator ? ( - - } - > - - - - - Completed - - ) : showNeutralIndicator ? ( - - } - > - - - Empty - - ) : null} - + {displayText} +

{expanded && canExpand && expandedBody ? (
-
+          
             {expandedBody}
           
diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index 6f281558ff8..c2fa204ffbc 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -1,6 +1,7 @@ import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react"; import { memo } from "react"; +import { cn } from "../../lib/utils"; import { Toggle } from "../ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -12,6 +13,7 @@ interface PanelLayoutControlsProps { rightPanelAvailable: boolean; rightPanelOpen: boolean; rightPanelShortcutLabel: string | null; + rightPanelUnavailableLabel?: string; /** Running + waiting subagents in this thread; badges the right panel toggle. */ liveAgentCount: number; onToggleTerminal: () => void; @@ -26,6 +28,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ rightPanelAvailable, rightPanelOpen, rightPanelShortcutLabel, + rightPanelUnavailableLabel = "Right panel is unavailable", liveAgentCount, onToggleTerminal, onToggleRightPanel, @@ -40,7 +43,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ - + {liveAgentCount > 0 ? (
@@ -114,7 +122,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl svg]:block"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; -export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; +export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = + "block self-center truncate leading-none select-none"; -// The skill label is smaller than the surrounding prompt text; offset its -// glyphs without moving the pill box or changing the editor's line height. -export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`; +export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = - "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; + "inline-flex h-[1.41em] max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] font-medium text-[0.86em] leading-none text-fuchsia-700 align-middle dark:text-fuchsia-300"; export const SKILL_CHIP_ICON_SVG = ``; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 2f4e84dc3fd..7457d04d2c9 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -25,6 +25,7 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, HammerIcon, + LayersIcon, MessageCircleQuestionIcon, MessageSquareIcon, LinkIcon, @@ -50,6 +51,7 @@ import { import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; +import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { @@ -74,6 +77,7 @@ import { import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; import { Menu, MenuItem, @@ -116,6 +120,7 @@ import { } from "./pullRequestProjectAssignment.logic"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; import { + PullRequestActorAvatar, PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, @@ -349,7 +354,6 @@ export function PullRequestDetailPanel({ onClose, onStateChange, context = "page", - chromeVariant = "full", composerDraftTarget, }: { environmentId: EnvironmentId; @@ -381,12 +385,6 @@ export function PullRequestDetailPanel({ * again is at best a no-op and at worst git refusing a branch two checkouts. */ context?: "page" | "thread"; - /** - * How the metadata above the content behaves: `full` keeps every row pinned; `collapse` - * folds the whole of it into the top row once the active tab scrolls, and unfolds at the - * top — the chrome spends its height on what is being read. - */ - chromeVariant?: "full" | "collapse"; /** * The open thread's composer. Beside the thread whose own pull request this is, hand-offs * land here instead of opening a new thread — the branch is already under the reader's feet. @@ -423,26 +421,13 @@ export function PullRequestDetailPanel({ ); }, [tab]); const [chromeCondensed, setChromeCondensed] = useState(false); - // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll - // events, so the capture handler always writes the active tab's entry — and a tab switch - // reads the destination's memory instead of inheriting the tab being left. A tab too short - // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome - // it has no scrollbar to reopen. const chromeStateByTab = useRef>>({}); useEffect(() => { setChromeCondensed(chromeStateByTab.current[tab] ?? false); }, [tab]); - const condensed = chromeVariant === "collapse" && chromeCondensed; - // Collapsing removes the fold's height from the chrome, which would otherwise hand that - // height to the scrollport and leap the content up by it mid-scroll. The cure is exact - // compensation: collapse only once the reader has scrolled at least the fold's height, - // then give that height back to `scrollTop` before the next paint — the content under - // their eyes does not move, and the collapse itself is the only thing that changes. + const condensed = chromeCondensed; const scrollerRef = useRef(null); const foldRef = useRef(null); - // The condensed chrome's second row opens as the fold closes, so the height the scrollport - // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight` - // through a zero track reads its natural height in either state. const condensedRowRef = useRef(null); const compensationRef = useRef(null); useLayoutEffect(() => { @@ -463,7 +448,6 @@ export function PullRequestDetailPanel({ target: "branch name", timeout: 1600, }); - // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be // clicked, so a reader who does click it lands on a chunk already in the module cache. useEffect(() => { @@ -502,6 +486,30 @@ export function PullRequestDetailPanel({ }, [activity, coreDetail], ); + const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); + const baseBranchRefQuery = useEnvironmentQuery( + detail === null + ? null + : vcsEnvironment.listRefs({ + environmentId, + input: { + cwd: detail.workspaceRoot, + query: detail.baseBranch, + includeMatchingRemoteRefs: true, + limit: 20, + }, + }), + ); + const matchingBaseBranchRefs = + detail === null + ? [] + : (baseBranchRefQuery.data?.refs.filter( + (refName) => + refName.name === detail.baseBranch || refName.name.endsWith(`/${detail.baseBranch}`), + ) ?? []); + const isStackedPullRequest = + matchingBaseBranchRefs.length > 0 && + !matchingBaseBranchRefs.some((refName) => refName.isDefault); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { @@ -1019,54 +1027,62 @@ export function PullRequestDetailPanel({ const can = (action: PullRequestAction) => detail?.capabilities.actions.includes(action) === true && detail.viewerPermissions.actions.includes(action); - // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes - // to the thing that would help instead of a Merge button that only ever says no. + // One live action holds the slot. Conflicts take priority because every other completion action + // depends on resolving them first, even for a reader who cannot merge on the host themselves. const primaryAction = detail === null || detail.state !== "open" ? null - : detail.isDraft && can("ready") - ? "ready" - : !can("merge") - ? null - : conflicting - ? "resolve" + : conflicting + ? "resolve" + : detail.isDraft && can("ready") + ? "ready" + : !can("merge") + ? null : allowedMergeMethods.length > 0 ? "merge" : null; // The pull request number carries this state in the overview and the right-panel tab mirrors - // it. Conflicts keep their own row below: an open pull request remains green there. + // it. The conflict action is separate from this state: an open pull request remains green. const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; const checksState = detail ? pullRequestChecksState(detail.checks) : null; + if (detailQuery.isPending && !detail) { + return ; + } + return (
- {/* The top row's geometry never changes: both of its states occupy the same stacked - cell and crossfade, so the actions on the right have one home whatever the chrome - is doing below. The fold and this fade share one 200ms clock. */}
- {/* The fixed height lives on the two top-row cells — not the grid, whose later rows - are the fold — so the actions have one immovable home in both states. */} -
+
{detail && statePresentation ? ( <> - - {detail.repository} - + {repositoryUrl ? ( + + ) : ( + + {detail.repository} + + )} - +

{detail.title} - - {conflicting ? ( - - - Conflicts - - ) : checksSummary ? ( - - {detail && checksState !== null ? ( - - ) : null} - {checksSummary} - - ) : null} +

) : null}
-
+
{detail ? ( <> @@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({ render={ } /> @@ -1367,7 +1366,22 @@ export function PullRequestDetailPanel({ Auto-merge ) : null} - {primaryAction === "ready" ? ( + {primaryAction === "resolve" ? ( + + } + > + + {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} + + ) : primaryAction === "ready" ? ( @@ -1394,113 +1408,86 @@ export function PullRequestDetailPanel({ ) : null}
- {/* The condensed chrome's second row: the tabs that the closing fold takes with it, - and compact copies of the branch pair and diff stat so they stay in sight while - the full rows are folded away. Same zero-track mechanism as the fold, inverted. */} -
+
{detail ? ( -
- - - {detail.baseBranch} - {freshness ? ( - void perform("update-branch", undefined, method)} - iconClassName="size-3" + + {detail.changedFiles.toLocaleString()} + + - ) : null} - - {detail.headBranch} - - - - - {detail.changedFiles.toLocaleString()} - - +
) : null}
- {/* Folding is a grid track going to zero: the rows below stay mounted, the track - animates closed over them, and `inert` takes the hidden controls out of the tab - order for as long as the chrome is condensed. */} -
+
{detail ? ( -
+
{titleDraft === null ? (

@@ -1567,47 +1554,56 @@ export function PullRequestDetailPanel({
- - {detail.baseBranch} - - {freshness ? ( - void perform("update-branch", undefined, method)} + + {isStackedPullRequest ? ( + + ) : null} + {detail.baseBranch} + + {freshness ? ( + void perform("update-branch", undefined, method)} + /> + ) : null} + - ) : null} - - + + {detail.headBranch} + + + + @@ -1623,147 +1619,114 @@ export function PullRequestDetailPanel({

) : null} +
+
- {detail && conflicting ? ( -
- + + {visibleTabs.map((item) => ( + setTab(item.value)} > - - Merge conflicts - + {item.label} + + ))} + + {tab === "summary" ? ( + + {checksState !== null ? ( + + ) : ( + + )} + {checksSummary} + + ) : tab === "timeline" ? ( +
+ + + + {activityError + ? "—" + : activityPending + ? "…" + : detail.commentCount.toLocaleString()} + + + + {activityError + ? "—" + : activityPending + ? "…" + : detail.commits.length.toLocaleString()} + +
) : null} - - {detail ? ( - - ) : null} -
-
+ + ) : null}
{ - if (chromeVariant !== "collapse") return; const scroller = event.target as HTMLElement; scrollerRef.current = scroller; const top = scroller.scrollTop; setChromeCondensed((previous) => { let next = previous; - // `scrollHeight` reads the fold's natural height whichever state the track is in. const foldHeight = foldRef.current?.scrollHeight ?? 0; - // The chrome trades the fold for the condensed second row, so the height the - // scrollport actually gains is the difference between the two. const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); if (previous) { // The hard top reopens the chrome with no refund: the reader asked for the top, @@ -1781,17 +1744,7 @@ export function PullRequestDetailPanel({ }); }} > - {detailQuery.isPending && !detail ? ( - // The ghost wears the shape of the tab being waited on, so switching tabs mid-load - // does not flash a summary outline under a timeline heading. - tab === "timeline" ? ( - - ) : tab === "code" ? ( - - ) : ( - - ) - ) : detailQuery.error && !detail ? ( + {detailQuery.error && !detail ? ( ) : detail ? ( <> diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 09b79cf340e..38a3ab70d64 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -45,13 +45,11 @@ export function PullRequestListGhost({
- +
- +
))} @@ -59,32 +57,101 @@ export function PullRequestListGhost({ ); } -/** The summary's own shape: a title, a byline, the facts rows, the description. */ +/** + * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description + * boundaries in the ghost prevents the loaded pull request from replacing one layout with + * another a moment later. + */ export function PullRequestDetailGhost() { return (
-
- - +
+
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + + +
+ + +
+
+
+ +
+
+ + + +
+ +
-
- {Array.from({ length: 4 }, (_, index) => ( -
+ +
+
+
+
+ + +
+
+ + + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+
+ +
+
+ - -
- ))} -
-
- - - - +
+ + + + +
+
); @@ -113,7 +180,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
- +
))}
@@ -134,8 +201,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
- - + +
))} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 3066eafc38a..04fee465b50 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -25,6 +25,7 @@ import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { Button } from "../ui/button"; import { Menu, @@ -261,12 +262,14 @@ export function PullRequestFiltersMenu({ return ( + } > {filtered ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index a57f2a4d160..29566e048d1 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -196,12 +196,12 @@ function MetaRow({ children: ReactNode; }) { return ( -
- +
+ {icon} {label} - {children} + {children}
); } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index a472c6a8d3d..9c36d32ff51 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -992,7 +992,7 @@ export function DiagnosticsSettingsPanel() { : false; return ( - + +
- {!isElectron && ( -
- -
- )} - {isElectron && ( -
- -
- )} + + +
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 174c9e9fe97..1618f5045eb 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -9,7 +9,6 @@ import { } from "react"; import { ArchiveIcon, - ArrowLeftIcon, BotIcon, GitBranchIcon, KeyboardIcon, @@ -19,7 +18,7 @@ import { Settings2Icon, XIcon, } from "lucide-react"; -import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -34,6 +33,7 @@ import { useSidebar, } from "../ui/sidebar"; import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; +import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { searchSettings, @@ -72,7 +72,6 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); const currentHash = useLocation({ select: (location) => location.hash }); - const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); @@ -176,17 +175,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { }, [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results], ); - const handleBackClick = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - if (canGoBack) { - window.history.back(); - return; - } - void navigate({ to: "/" }); - }, [canGoBack, isMobile, navigate, setOpenMobile]); - return ( <> @@ -296,14 +284,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
- - - - - Back - - - +
diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 7e4b80d1951..5399d071be9 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -768,7 +768,7 @@ export function ThemeLibrary({
{STANDARD_THEME_CARDS.map((standardTheme) => ( location.hash }); @@ -247,12 +250,12 @@ export function SettingsPageContainer({ return (
-
+ {children} -
+
); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index f4a98dec86c..8fc6b835bf1 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -4,8 +4,9 @@ import { GitPullRequestIcon, SettingsIcon, } from "lucide-react"; +import type { ReactNode } from "react"; import { memo, useCallback } from "react"; -import { Link, useLocation, useNavigate } from "@tanstack/react-router"; +import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -117,16 +118,44 @@ function T3Wordmark() { ); } -export const SidebarChromeFooter = memo(function SidebarChromeFooter() { +function SidebarUtilityItem({ + icon, + label, + onClick, +}: { + icon: ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + + + {icon} + + } + /> + {label} + + + ); +} + +export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const navigate = useNavigate(); + const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile } = useSidebar(); const currentFooterPage = useLocation({ select: (location) => - location.pathname === "/usage" - ? "usage" - : location.pathname === "/pull-requests" - ? "pull-requests" - : null, + /^\/settings(?:\/|$)/.test(location.pathname) + ? "settings" + : location.pathname === "/usage" + ? "usage" + : location.pathname === "/pull-requests" + ? "pull-requests" + : null, }); const { environments } = useEnvironments(); // The page reads every connected server, so one of them offering pull requests is enough for @@ -157,73 +186,54 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { const handleBackClick = useCallback(() => { closeMobileSidebar(); + if (canGoBack) { + window.history.back(); + return; + } void navigate({ to: "/" }); - }, [closeMobileSidebar, navigate]); + }, [canGoBack, closeMobileSidebar, navigate]); + return ( + + {currentFooterPage ? ( + + + + Back + + + ) : ( + <> + } + label="Settings" + onClick={handleSettingsClick} + /> + {pullRequestsSupported ? ( + } + label="Pull Requests" + onClick={handlePullRequestsClick} + /> + ) : null} + } + label="Usage" + onClick={handleUsageClick} + /> + + )} + + + ); +}); + +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - - {currentFooterPage ? ( - - - - Back - - - ) : ( - <> - - - - - - } - /> - Settings - - - {pullRequestsSupported ? ( - - - - - - } - /> - Pull Requests - - - ) : null} - - - - - - } - /> - Usage - - - - )} - - + ); }); diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 93dc653e7c0..477bc9c0263 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -19,6 +19,12 @@ function ids(state: ThreadActionMenuState): string[] { return buildThreadActionMenuItems(state).map((item) => item.id); } +function allIds(state: ThreadActionMenuState): string[] { + const flatten = (items: ReturnType): string[] => + items.flatMap((item) => [item.id, ...(item.children ? flatten(item.children) : [])]); + return flatten(buildThreadActionMenuItems(state)); +} + describe("buildThreadActionMenuItems", () => { it("hides lifecycle items when the environment lacks the capabilities", () => { expect( @@ -26,15 +32,15 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); + ).toEqual(["rename", "mark-unread", "copy", "delete"]); }); it("includes branch items only for threads with a branch", () => { - const withBranch = ids({ ...baseState, branch: "feat/menu" }); + const withBranch = allIds({ ...baseState, branch: "feat/menu" }); expect(withBranch).toContain("new-thread-on-branch"); expect(withBranch).toContain("copy-branch"); - expect(ids(baseState)).not.toContain("new-thread-on-branch"); - expect(ids(baseState)).not.toContain("copy-branch"); + expect(allIds(baseState)).not.toContain("new-thread-on-branch"); + expect(allIds(baseState)).not.toContain("copy-branch"); }); it("flips lifecycle labels with thread state", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index ef4b38dcdac..1218e2dd58c 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -18,6 +18,7 @@ export type ThreadActionMenuId = | "rename" | "regenerate-title" | "mark-unread" + | "copy" | "copy-path" | "copy-branch" | "copy-thread-id" @@ -53,14 +54,15 @@ export function buildThreadActionMenuItems( { id: "new-thread-on-branch" as const, label: `New thread on ${state.branch}`, + icon: "message-square-plus", }, ] : []), ...(state.supports.pinning ? [ state.isPinned - ? { id: "unpin" as const, label: "Unpin thread" } - : { id: "pin" as const, label: "Pin thread" }, + ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" } + : { id: "pin" as const, label: "Pin thread", icon: "pin" }, ] : []), // Both lifecycle actions stay available on pinned threads: settling @@ -69,17 +71,18 @@ export function buildThreadActionMenuItems( ...(state.supports.settlement ? [ state.isSettled - ? { id: "unsettle" as const, label: "Un-settle thread" } - : { id: "settle" as const, label: "Settle thread" }, + ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" } + : { id: "settle" as const, label: "Settle thread", icon: "circle-check" }, ] : []), ...(state.supports.snooze ? [ state.isSnoozed - ? { id: "unsnooze" as const, label: "Wake thread" } + ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" } : { id: "snooze" as const, label: "Snooze", + icon: "clock", disabled: !state.canSnoozeNow, children: state.snoozePresets.map((preset) => ({ id: `snooze:${preset.id}` as const, @@ -88,20 +91,37 @@ export function buildThreadActionMenuItems( }, ] : []), - { id: "rename", label: "Rename thread" }, + { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, ...(state.supports.titleRegeneration ? [ { id: "regenerate-title" as const, label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", + icon: "refresh-cw", disabled: state.isRegeneratingTitle, }, ] : []), - { id: "mark-unread", label: "Mark unread" }, - { id: "copy-path", label: "Copy path", icon: "copy" }, - ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), - { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, - { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, + { + id: "copy", + label: "Copy", + icon: "copy", + separatorBefore: true, + children: [ + { id: "copy-path", label: "Path", icon: "folder" }, + ...(state.branch + ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] + : []), + { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, + ], + }, + { + id: "delete", + label: "Delete", + destructive: true, + icon: "trash", + separatorBefore: true, + }, ]; } diff --git a/apps/web/src/components/ui/segmented-tabs.tsx b/apps/web/src/components/ui/segmented-tabs.tsx new file mode 100644 index 00000000000..29b91e18bb4 --- /dev/null +++ b/apps/web/src/components/ui/segmented-tabs.tsx @@ -0,0 +1,40 @@ +import type { ComponentProps, HTMLAttributes } from "react"; + +import { cn } from "~/lib/utils"; +import { Toggle } from "~/components/ui/toggle"; + +function SegmentedTabList({ className, ...props }: HTMLAttributes) { + return ( +
+ ); +} + +function SegmentedTab({ + selected, + density = "default", + className, + ...props +}: { + selected: boolean; + density?: "default" | "compact"; +} & Omit, "aria-pressed" | "pressed" | "size" | "type" | "variant">) { + return ( + + ); +} + +export { SegmentedTab, SegmentedTabList }; diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 5bf04adf41a..7173eab140e 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -18,6 +18,10 @@ const toggleVariants = cva( "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", + segmented: + "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]", + "segmented-compact": + "h-5 min-w-0 rounded-md px-2 text-[11px] before:rounded-[calc(var(--radius-md)-1px)]", sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", }, @@ -27,6 +31,8 @@ const toggleVariants = cva( "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", outline: "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", + segmented: + "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-accent/45 hover:text-foreground data-pressed:bg-accent data-pressed:text-foreground data-pressed:shadow-xs/5", }, }, }, diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7a5cdd883db..b9bebadc00e 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -19,13 +19,22 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { ScrollArea } from "../ui/scroll-area"; import { Button } from "../ui/button"; +import { ScrollArea } from "../ui/scroll-area"; import { SidebarInset } from "../ui/sidebar"; -import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; -import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "../WorkspaceBreadcrumb"; +import { + WorkspacePageContainer, + WorkspacePageHeader, + WorkspacePageHeaderEdgeControl, +} from "../WorkspacePageContainer"; +import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, @@ -66,21 +75,6 @@ export function UsagePage() { [isPast24Hours, merged.daily, merged.hourly], ); - // Ranked by whatever the toggle is showing, so the bars always descend. - const orderedProviders = useMemo( - () => - merged.providers.toSorted((a, b) => - metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, - ), - [merged.providers, metric], - ); - - const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter( - (period) => period.totalTokens > 0, - ).length; - const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods; - const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; - const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; const selectWindow = (days: number) => { setWindowSelection({ days, @@ -100,78 +94,66 @@ export function UsagePage() { setWindowSelection({ days: windowDays, window: nextWindow }); } }; + const windowLabel = + isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined + ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` + : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; + const topbarContent = ( +
+ + +

Usage

+
+ + + {windowLabel} + +
+
+ + {(["cost", "tokens"] as const).map((option) => ( + setMetric(option)} + > + {option === "cost" ? "Cost" : "Tokens"} + + ))} + + + {WINDOW_OPTIONS.map((option) => ( + selectWindow(option.days)} + > + {option.label} + + ))} + + + + +
+
+ ); return (
- {!isElectron && ( -
- - Usage - -
- )} - - {isElectron && ( -
- - Usage - -
- )} + + {topbarContent} + -
-
-

- {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined - ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` - : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`} -

-
-
- {WINDOW_OPTIONS.map((option) => ( - - ))} -
- -
-
- + {settling ? ( <> {environments.length > 1 ? : null} - + ) : ( <> @@ -181,88 +163,62 @@ export function UsagePage() { staleEnvironments={merged.staleEnvironments} /> - {/* Cost first: the financial answer, then the provider split. */} -
- {/* The summary follows the chart toggle, so the headline and the - series are always reading the same units. */} -
+
+
- - {metric === "cost" ? "Raw token cost" : "Processed tokens"} - {metric === "cost" - ? `${formatUsd(merged.costUsd)}*` + ? formatUsd(merged.costUsd) : formatTokens(merged.totalTokens)} {metric === "cost" - ? "* if billed at full API rate" - : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`} + ? `${formatCount(merged.sessions)} sessions · API estimate` + : `${formatCount(merged.sessions)} sessions`}
- {orderedProviders.map((provider) => { - const share = metric === "cost" ? provider.costShare : provider.tokenShare; + {PROVIDER_ORDER.map((provider) => { + const totals = merged.providers.find((entry) => entry.provider === provider); + const share = + metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); + const providerSessions = totals?.sessions ?? 0; + const sessionLabel = `${formatCount(providerSessions)} ${ + providerSessions === 1 ? "session" : "sessions" + }`; return ( -
-
- - - {PROVIDER_LABEL[provider.provider]} +
+
+ + + + {PROVIDER_LABEL[provider]} + + {sessionLabel} + + - + {metric === "cost" - ? formatUsd(provider.costUsd) - : formatTokens(provider.totalTokens)} + ? formatUsd(totals?.costUsd ?? 0) + : formatTokens(totals?.totalTokens ?? 0)}
-
-
-
{metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`} + ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`}
); })}
-
-
-

- {isPast24Hours ? "Hourly" : "Daily"}{" "} - {metric === "tokens" ? "processed tokens" : "cost"} -

-
-
- {(["cost", "tokens"] as const).map((option) => ( - - ))} -
- -
-
+
+

+ {isPast24Hours ? "Hourly" : "Daily"}{" "} + {metric === "tokens" ? "processed tokens" : "cost"} +

-
- - - - - 0 - ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` - : "vs full input rates" - } - /> +
+

Totals

+
+ + + + + +

Breakdown

-
+ {( [ - { value: "model", label: "model" }, - { value: "time", label: isPast24Hours ? "hour" : "day" }, + { value: "model", label: "Model" }, + { value: "time", label: isPast24Hours ? "Hour" : "Day" }, ] as const ).map((option) => ( - + ))} -
+
{breakdown === "model" ? ( @@ -356,7 +291,7 @@ export function UsagePage() { merged.models.map((model) => ( @@ -403,7 +338,7 @@ export function UsagePage() { recentPeriods.map((period) => ( {"hourStart" in period @@ -433,7 +368,7 @@ export function UsagePage() {
)} -
+
@@ -452,20 +387,11 @@ function ProviderMark({ return ; } -function Metric({ - label, - value, - detail, -}: { - readonly label: string; - readonly value: string; - readonly detail: string; -}) { +function Metric({ label, value }: { readonly label: string; readonly value: string }) { return ( -
+
{label} - {value} - {detail} + {value}
); } @@ -569,70 +495,51 @@ function UsageDeviceStrip({ ); } -/** Deterministic bar heights (each unique: they double as keys). */ -const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67]; - /** - * Static stand-in with the loaded page's shape: headline, provider split, - * chart and metrics strip. No shimmer; blocks fill in exactly once when the - * last device answers. + * Static stand-in with the loaded page's shape. No shimmer; blocks fill in + * exactly once when the last device answers. */ -function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { +function UsageSkeleton() { return ( <> -
+
- - Raw token cost - -
-
+
+
- {PROVIDER_ORDER.map((provider) => ( -
-
- +
+
+ - {PROVIDER_LABEL[provider]} +
-
))}
-

- {resolution === "hour" ? "Hourly" : "Daily"} cost -

- {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a - relayout when the real chart swaps in. */} -
- {SKELETON_BAR_HEIGHTS.map((height) => ( -
- ))} -
+
+
-
- {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( - (label) => ( -
- {label} -
-
-
- ), - )} +
+

Totals

+
+ {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( + (label) => ( +
+ {label} +
+
+ ), + )} +
); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index f41945bfe28..963c28fe6a0 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,5 +1,5 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { @@ -68,13 +68,7 @@ function buildPeriodColumns( }); } -/** - * Monotone cubic tangents (Fritsch-Carlson). - * - * Plain cubic smoothing overshoots on spiky daily data and would dip the area - * below zero between points, which reads as negative spend. This variant is - * shape-preserving, so a smoothed series never leaves the range of its samples. - */ +/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */ function monotoneTangents(points: readonly Point[]): readonly number[] { const count = points.length; if (count < 2) return [0]; @@ -115,7 +109,6 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { return tangents; } -/** One cubic segment of a smoothed boundary. */ interface CurveSegment { readonly from: Point; readonly c1: Point; @@ -123,7 +116,6 @@ interface CurveSegment { readonly to: Point; } -/** Smoothed polyline through `points`, as explicit cubic control points. */ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { if (points.length < 2) return []; const tangents = monotoneTangents(points); @@ -144,10 +136,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { return segments; } -function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { +function curvePath(segments: readonly CurveSegment[]): string { const first = segments[0]; if (first === undefined) return ""; - let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; + let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; for (const segment of segments) { path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`; } @@ -179,10 +171,8 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re /** * Turns the merged daily totals into one column per day. * - * Values are absolute, not cumulative: the series are layered from a shared - * zero baseline rather than stacked. A stacked chart puts whichever provider is - * drawn last permanently above the other, which reads as "that one is bigger" - * even on days where it is not. + * Values are absolute, not cumulative: each provider is drawn from the same + * zero baseline so the chart never implies that one provider is always larger. * * The chart paths and the hover readout both consume this, so the number under * the cursor is by construction the number that was plotted rather than a @@ -216,42 +206,39 @@ export function UsageProviderChart({ ); const [hoverIndex, setHoverIndex] = useState(null); const plotRef = useRef(null); + const tooltipRef = useRef(null); + const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); - const { paths, ticks, stepX, toY, series } = useMemo(() => { + const { paths, series, stepX, ticks, toY } = useMemo(() => { if (periods.length === 0) { return { paths: [], - ticks: [0] as readonly number[], + series: [] as readonly DayColumn[], stepX: 0, + ticks: [0] as readonly number[], toY: () => VIEW_HEIGHT, - series: [] as readonly DayColumn[], }; } const columns = buildPeriodColumns(periods, byPeriod, metric); - - // The scale tops out at the largest single provider-day, not the largest - // sum: layered series each measure from zero, so a combined peak would - // leave the plot permanently half empty. const peak = columns.reduce( (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), 0, ); const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); - // Reserve a sliver above the top gridline so the series stroke, which is - // drawn at constant screen width, is not shaved off at a peak. const toY = (value: number) => max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); const built = PROVIDER_ORDER.map((provider, providerIndex) => { - const curve = smoothCurve( - columns.map((column, dayIndex) => ({ - x: dayIndex * step, - y: toY(column.bands[providerIndex]?.value ?? 0), - })), + const line = curvePath( + smoothCurve( + columns.map((column, periodIndex) => ({ + x: periodIndex * step, + y: toY(column.bands[providerIndex]?.value ?? 0), + })), + ), ); - const line = curvePath(curve, "M"); return { provider, total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), @@ -260,30 +247,65 @@ export function UsageProviderChart({ }; }); - // Paint the heavier series first so the lighter one is never buried under - // it. The fills are faint enough that the order barely shows, but the - // strokes are drawn in a second pass regardless, so neither can be hidden. - const ordered = [...built].sort((a, b) => b.total - a.total); - - return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; + return { + paths: built.toSorted((a, b) => b.total - a.total), + series: columns, + stepX: step, + ticks: tickValues, + toY, + }; }, [byPeriod, metric, periods]); const format = metric === "tokens" ? formatTokens : formatUsd; + const positionTooltip = useCallback(() => { + const plot = plotRef.current; + const tooltip = tooltipRef.current; + const hoverPosition = hoverPositionRef.current; + if (plot === null || tooltip === null || hoverPosition === null) return; + + const gap = 12; + const tooltipWidth = tooltip.offsetWidth; + const tooltipHeight = tooltip.offsetHeight; + const plotWidth = plot.clientWidth; + const plotHeight = plot.clientHeight; + const preferredLeft = + hoverPosition.x + gap + tooltipWidth <= plotWidth + ? hoverPosition.x + gap + : hoverPosition.x - gap - tooltipWidth; + const preferredTop = + hoverPosition.y + gap + tooltipHeight <= plotHeight + ? hoverPosition.y + gap + : hoverPosition.y - gap - tooltipHeight; + const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth)); + const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight)); + plot.style.setProperty("--usage-tooltip-left", `${left}px`); + plot.style.setProperty("--usage-tooltip-top", `${top}px`); + }, []); + + useLayoutEffect(() => { + if (hoverIndex !== null) positionTooltip(); + }, [hoverIndex, positionTooltip]); + const handleMove = useCallback( (event: React.MouseEvent) => { - const bounds = plotRef.current?.getBoundingClientRect(); - if (bounds === undefined || bounds.width === 0 || periods.length === 0) return; - const fraction = (event.clientX - bounds.left) / bounds.width; + const plot = plotRef.current; + if (plot === null || periods.length === 0) return; + const bounds = plot.getBoundingClientRect(); + if (bounds.width === 0) return; + const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); + const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); + const fraction = localX / bounds.width; const index = Math.round(fraction * (periods.length - 1)); + hoverPositionRef.current = { x: localX, y: localY }; + positionTooltip(); setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); }, - [periods.length], + [periods.length, positionTooltip], ); const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; - const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100; const formatPeriod = (period: string) => resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); const formatTooltipPeriod = (period: string) => @@ -311,7 +333,10 @@ export function UsageProviderChart({ ref={plotRef} className="relative h-56 flex-1" onMouseMove={handleMove} - onMouseLeave={() => setHoverIndex(null)} + onMouseLeave={() => { + hoverPositionRef.current = null; + setHoverIndex(null); + }} > ( ))} @@ -368,10 +392,11 @@ export function UsageProviderChart({ {hoveredPeriod === undefined ? null : (
60 ? "translateX(-100%)" : "translateX(0)", + left: "var(--usage-tooltip-left, 0px)", + top: "var(--usage-tooltip-top, 0px)", }} >
{formatTooltipPeriod(hoveredPeriod)}
@@ -418,21 +443,3 @@ export function UsageProviderChart({
); } - -export function UsageChartLegend() { - return ( -
- {PROVIDER_ORDER.map((provider) => { - // The marks carry the same fills as the bands, so they key the chart - // just as a colour swatch would. - const Mark = PROVIDER_MARK[provider]; - return ( - - - {PROVIDER_LABEL[provider]} - - ); - })} -
- ); -} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index f8b65877dcf..3ec17185902 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -3,9 +3,7 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, type Icon, OpenAI } from "../Icons"; /** - * Series and table order. The chart layers both providers from a shared zero - * baseline, so this only fixes the reading order of legends, tables and hover - * rows; it does not decide which series sits above the other. + * Stable provider reading order across summaries, tables, and hover rows. */ export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 769826e3999..4bc3237d2a6 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -4,6 +4,15 @@ const SVG_NS = "http://www.w3.org/2000/svg"; // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). const ICON_PATHS: Record }>> = { + "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }], + "circle-check": [ + { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, + { tag: "path", attrs: { d: "m9 12 2 2 4-4" } }, + ], + clock: [ + { tag: "path", attrs: { d: "M12 6v6l4 2" } }, + { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, + ], pencil: [ { tag: "path", @@ -17,6 +26,71 @@ const ICON_PATHS: Record( "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; for (const item of entries) { + if (item.separatorBefore === true && inner.childElementCount > 0) { + const separator = document.createElement("div"); + separator.className = "my-1 h-px bg-border/70"; + separator.style.cssText = + "height:1px;margin:0.25rem 0;background:var(--border);opacity:0.7;"; + separator.dataset.contextMenuSeparator = "true"; + separator.setAttribute("role", "separator"); + inner.appendChild(separator); + } + if (item.header === true) { const header = document.createElement("div"); header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; @@ -247,10 +331,12 @@ export function showContextMenuFallback( button.appendChild(label); if (hasChildren) { - const chevron = document.createElement("span"); - chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; - chevron.textContent = ">"; - button.appendChild(chevron); + const chevron = createIconElement("chevron-right", "neutral"); + if (chevron) { + chevron.setAttribute("class", "ms-auto size-4 shrink-0 text-muted-foreground/80"); + chevron.dataset.contextMenuChevron = "true"; + button.appendChild(chevron); + } } if (!isDisabled) { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4e636eb4ff0..bd49f53702c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -241,6 +241,22 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil opacity: 1; } } + @keyframes live-activity-focus { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(100%); + } + } + @keyframes live-activity-focus-counter { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-100%); + } + } @keyframes status-ping { /* Burst first (immediate feedback for click ripples), then hold invisible for the rest of the cycle. Mirrors animate-ping's @@ -431,6 +447,62 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +@utility live-activity-focus { + --live-activity-focus-width: 4.5rem; + + right: auto; + left: calc(-1 * var(--live-activity-focus-width)); + width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width)); + -webkit-mask-image: linear-gradient( + to right, + transparent 0, + rgb(0 0 0 / 12%) 0.675rem, + rgb(0 0 0 / 55%) 1.575rem, + black 2.25rem, + rgb(0 0 0 / 55%) 2.925rem, + rgb(0 0 0 / 12%) 3.825rem, + transparent var(--live-activity-focus-width), + transparent 100% + ); + -webkit-mask-repeat: no-repeat; + mask-image: linear-gradient( + to right, + transparent 0, + rgb(0 0 0 / 12%) 0.675rem, + rgb(0 0 0 / 55%) 1.575rem, + black 2.25rem, + rgb(0 0 0 / 55%) 2.925rem, + rgb(0 0 0 / 12%) 3.825rem, + transparent var(--live-activity-focus-width), + transparent 100% + ); + mask-repeat: no-repeat; + animation: live-activity-focus 2.2s linear infinite; + will-change: transform; + + @media (prefers-reduced-motion: reduce) { + animation: none; + opacity: 0; + will-change: auto; + } +} + +@utility live-activity-focus-counter { + width: 100%; + animation: live-activity-focus-counter 2.2s linear infinite; + will-change: transform; + + @media (prefers-reduced-motion: reduce) { + animation: none; + will-change: auto; + } +} + +@utility live-activity-focus-aligned { + width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width)); + margin-left: var(--live-activity-focus-width); +} + @layer base { :root { /* Keep the original T3 Code artwork palettes as the defaults. Built-in @@ -1349,15 +1421,16 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] { /* The panel layout toggles stay ghost: they render both inside the header and in the titlebar strip, so filling them would make them change appearance as - the panel opens. They only take the themed foreground; hover and pressed - keep the base ghost accent. The tooltip trigger's data-slot wins over the - toggle's when the trigger renders the toggle, so match both. */ + the panel opens. Their icons use the same themed foreground as the toolbar + action text; hover and pressed keep the base ghost accent. The tooltip + trigger's data-slot wins over the toggle's when it renders the toggle, so + match both. */ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { - --control-icon-color: var(--toolbar-foreground); - color: var(--toolbar-foreground); + --control-icon-color: var(--toolbar-control-foreground); + color: var(--toolbar-control-foreground); } html[data-theme-id] [data-chat-header] [data-slot="button"]:hover, diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 0b7e6bf0f97..5bfb80bfec3 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -118,6 +118,17 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu return null; } +/** The repository root behind a recognised change-request URL, without PR-specific state. */ +export function changeRequestRepositoryUrl(targetUrl: string): string | null { + const changeRequest = parseChangeRequestUrl(targetUrl); + if (changeRequest === null) return null; + const url = new URL(targetUrl); + url.pathname = `/${changeRequest.repository}`; + url.search = ""; + url.hash = ""; + return url.toString(); +} + function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { const repository = match?.[1]; const number = Number(match?.[2]); diff --git a/apps/web/src/routes/-chatIndexTitlebar.test.ts b/apps/web/src/routes/-chatIndexTitlebar.test.ts index 803ba787116..0e1fdc9f884 100644 --- a/apps/web/src/routes/-chatIndexTitlebar.test.ts +++ b/apps/web/src/routes/-chatIndexTitlebar.test.ts @@ -15,9 +15,7 @@ describe("hosted static onboarding header", () => { const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); - expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]"); - expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]"); - expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS"); + expect(onboardingHeader).toContain(''); expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); }); }); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 4f4da0c751e..271715be3ca 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -8,6 +8,7 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; +import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useAllEnvironmentShellsBootstrapped, @@ -17,8 +18,6 @@ import { import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; -import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -143,18 +142,13 @@ function HostedStaticOnboardingState() { return (
-
+
{APP_DISPLAY_NAME}
-
+
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 66d9f0caa5d..7d73a225d5a 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -74,6 +74,12 @@ import { WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../components/WorkspaceBreadcrumb"; +import { + WorkspacePageContainer, + WorkspacePageHeader, + WorkspacePageHeaderEdgeControl, +} from "../components/WorkspacePageContainer"; +import { isElectron } from "../env"; import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; import { Button } from "../components/ui/button"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; @@ -99,7 +105,6 @@ import { import { useAtomCommand } from "../state/use-atom-command"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export interface PullRequestsSearch { readonly involvement: PullRequestInvolvement; @@ -1181,6 +1186,17 @@ function PullRequestsRouteView() { : null, [search.number, search.repository, selectedProject], ); + const linkedSelectionMatchesSurface = + linkedSelection !== null && + selectedPullRequestSurface !== null && + linkedSelection.environmentId === selectedPullRequestSurface.environmentId && + linkedSelection.projectId === selectedPullRequestSurface.projectId && + linkedSelection.repository === selectedPullRequestSurface.repository && + linkedSelection.number === selectedPullRequestSurface.number; + // A closed panel keeps its tabs so reopening does not discard work. Those retained tabs are + // history, though, not a current selection: without this check they leave the toggle looking + // available after the selected pull request has been cleared. + const rightPanelAvailable = activePullRequestSurface !== null || linkedSelectionMatchesSurface; useEffect(() => { if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); @@ -1294,9 +1310,10 @@ function PullRequestsRouteView() { terminalAvailable={false} terminalOpen={false} terminalShortcutLabel={null} - rightPanelAvailable={rightPanelState.surfaces.length > 0} + rightPanelAvailable={rightPanelAvailable} rightPanelOpen={rightPanelState.isOpen} rightPanelShortcutLabel={null} + rightPanelUnavailableLabel="Select a pull request first" liveAgentCount={0} onToggleTerminal={() => undefined} onToggleRightPanel={toggleRightPanel} @@ -1603,7 +1620,6 @@ function PullRequestsRouteView() { reviewingQuery.refresh(); }} onStateChange={handlePullRequestTabStatusChange} - chromeVariant="collapse" /> ) : null} @@ -1827,18 +1843,10 @@ function PullRequestsColumn({ // Painted flat like the chat column: the inset underneath carries the chrome grain, and a // content surface that lets it show reads as a different background than every thread.
-
+ {/* A closed right panel leaves this column full-width, so the shared header + reserves native window controls. While the panel is open, the column ends + at the panel and the absolute controls strip owns the top-right corner. */} + {condensed ? ( {/* The page name remains the foreground anchor in both states; the live filters are @@ -1880,27 +1888,24 @@ function PullRequestsColumn({ )}
{condensed ? ( - { - topbarSearchFocusedRef.current = focused; - }} - /> +
+ { + topbarSearchFocusedRef.current = focused; + }} + /> + +
+ ) : null} + {rightPanelControl ? ( + {rightPanelControl} ) : null} - - {rightPanelControl} -
+
+
{searchInput} {filtersMenu} + {!condensed ? ( + + ) : null}
{/* Scrolled past this marker, the controls are gone and the title takes over. */}
{listBody} -
+
); } + +function PullRequestRefreshControl({ + compact = false, + refreshing, + onRefresh, +}: { + compact?: boolean; + refreshing: boolean; + onRefresh: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index a4b248c84ed..431e196de8b 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -13,9 +13,8 @@ import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; import { Button } from "../components/ui/button"; import { SidebarInset } from "../components/ui/sidebar"; +import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; import { isElectron } from "../env"; -import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -72,41 +71,16 @@ function SettingsContentLayout() { return (
- {!isElectron && ( -
-
- - {showRestoreDefaults ? ( -
- -
- ) : null} -
-
- )} - - {isElectron && ( -
-
- - {showRestoreDefaults ? ( -
- -
- ) : null} -
+ +
+ + {showRestoreDefaults ? ( +
+ +
+ ) : null}
- )} +
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f5effff6602..2eadd1fc5fb 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -722,24 +722,144 @@ describe("workEntryIndicatesToolFailure", () => { }); describe("deriveWorkLogEntries", () => { - it("omits tool started entries and keeps completed entries", () => { + it("shows a command from its start event while it is still running", () => { const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-start", + createdAt: "2026-02-23T00:00:02.000Z", + summary: "Command run started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "inProgress", + title: "Command run", + detail: "Bash: vp test run", + data: { + toolName: "Bash", + input: { command: "vp test run" }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry).toMatchObject({ + id: "tool-start", + command: "vp test run", + toolCallId: "call-1", + toolLifecycleStatus: "inProgress", + sourceActivityKind: "tool.started", + }); + }); + + it("retains the start command when the matching completion omits it", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-start", + createdAt: "2026-02-23T00:00:02.000Z", + summary: "Command run started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "inProgress", + title: "Command run", + data: { input: { command: "vp test run" } }, + }, + }), + makeActivity({ + id: "other-tool-start", + createdAt: "2026-02-23T00:00:02.500Z", + summary: "Other command started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-2", + status: "inProgress", + title: "Other command", + data: { input: { command: "vp lint" } }, + }, + }), makeActivity({ id: "tool-complete", createdAt: "2026-02-23T00:00:03.000Z", - summary: "Tool call complete", + summary: "Command run", kind: "tool.completed", + payload: { + itemType: "command_execution", + toolCallId: "call-1", + status: "completed", + title: "Command run", + }, }), makeActivity({ - id: "tool-start", + id: "other-tool-complete", + createdAt: "2026-02-23T00:00:04.000Z", + summary: "Other command", + kind: "tool.completed", + payload: { + itemType: "command_execution", + toolCallId: "call-2", + status: "completed", + title: "Other command", + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + id: "tool-complete", + command: "vp test run", + toolCallId: "call-1", + toolLifecycleStatus: "completed", + sourceActivityKind: "tool.completed", + }); + expect(entries[1]).toMatchObject({ + id: "other-tool-complete", + command: "vp lint", + toolCallId: "call-2", + toolLifecycleStatus: "completed", + sourceActivityKind: "tool.completed", + }); + }); + + it("does not merge non-adjacent tool starts without stable call ids", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "unkeyed-start-1", + createdAt: "2026-02-23T00:00:01.000Z", + summary: "Search started", + kind: "tool.started", + payload: { itemType: "search", title: "Search", status: "inProgress" }, + }), + makeActivity({ + id: "keyed-start", createdAt: "2026-02-23T00:00:02.000Z", - summary: "Tool call", + summary: "Command started", + kind: "tool.started", + payload: { + itemType: "command_execution", + toolCallId: "call-between", + title: "Command", + status: "inProgress", + }, + }), + makeActivity({ + id: "unkeyed-start-2", + createdAt: "2026-02-23T00:00:03.000Z", + summary: "Search started", kind: "tool.started", + payload: { itemType: "search", title: "Search", status: "inProgress" }, }), ]; - const entries = deriveWorkLogEntries(activities); - expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); + expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ + "unkeyed-start-1", + "keyed-start", + "unkeyed-start-2", + ]); }); it("omits task.started but shows task.progress and task.completed", () => { @@ -1239,6 +1359,7 @@ describe("deriveWorkLogEntries", () => { expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ id: "grep-complete", + toolCallId: "tool-grep-1", toolTitle: "grep", detail: "19 files", itemType: "web_search", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133..efe1876dfc1 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -65,6 +65,8 @@ export interface WorkLogEntry { id: string; createdAt: string; turnId?: TurnId | null; + /** Stable provider identity across in-progress and completed lifecycle updates. */ + toolCallId?: string; label: string; detail?: string; command?: string; @@ -748,7 +750,6 @@ export function deriveWorkLogEntries( const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { - if (activity.kind === "tool.started") continue; // Agent task.started rows are CTA seeds: they carry the true spawn turn, // which is the batch key (completions of background subagents arrive // under later synthetic turns and must not start new batches). They @@ -757,8 +758,13 @@ export function deriveWorkLogEntries( if (activity.kind === "task.updated") continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; + // Plan updates have a dedicated task row. Keeping the raw activity here + // duplicates it as a legacy "Work Log / Plan updated" row when history + // is expanded. + if (activity.kind === "turn.plan.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; + if (isCodexTerminalInteractionActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } @@ -769,7 +775,11 @@ export function deriveWorkLogEntries( } function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { - if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { + if ( + activity.kind !== "tool.started" && + activity.kind !== "tool.updated" && + activity.kind !== "tool.completed" + ) { return false; } @@ -780,6 +790,28 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); } +/** + * Codex terminal interactions report bytes written to an already-running PTY. + * Some thread histories contain them as generic tool.updated rows, so filter + * their exact wire shape from the presentation model. This repairs existing + * history without deleting or rewriting persisted activities. + */ +function isCodexTerminalInteractionActivity(activity: OrchestrationThreadActivity): boolean { + if (activity.kind !== "tool.updated") { + return false; + } + const payload = asRecord(activity.payload); + const data = asRecord(payload?.data); + return ( + payload?.itemType === "command_execution" && + typeof data?.itemId === "string" && + typeof data.processId === "string" && + typeof data.stdin === "string" && + typeof data.threadId === "string" && + typeof data.turnId === "string" + ); +} + function extractWorkLogToolLifecycleStatus( payload: Record | null, ): WorkLogToolLifecycleStatus | undefined { @@ -878,6 +910,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.toolCallId = toolCallId; } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); + if (!toolLifecycleStatus && activity.kind === "tool.started") { + toolLifecycleStatus = "inProgress"; + } if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; } @@ -933,6 +968,17 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; } +function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined { + if ( + entry.activityKind !== "tool.started" && + entry.activityKind !== "tool.updated" && + entry.activityKind !== "tool.completed" + ) { + return undefined; + } + return entry.toolCallId ? `tool:${entry.toolCallId}` : undefined; +} + function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -949,6 +995,7 @@ function collapseDerivedWorkLogEntries( // own turn splintered one batch into a stream of "Kicked off N subagents" // rows (live-test finding, thread 7ac7ef05). const groupKeyByTaskId = new Map(); + const toolLifecycleRowIndex = new Map(); for (const entry of entries) { const isTaskRow = entry.taskId !== undefined && @@ -993,12 +1040,40 @@ function collapseDerivedWorkLogEntries( }); continue; } + const lifecycleKey = toolLifecycleCollapseMapKey(entry); + if (lifecycleKey !== undefined) { + const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey); + if (matchingLifecycleIndex !== undefined) { + const matchingEntry = collapsed[matchingLifecycleIndex]; + if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) { + toolLifecycleRowIndex.delete(lifecycleKey); + const merged = mergeDerivedWorkLogEntries(matchingEntry, entry); + collapsed[matchingLifecycleIndex] = merged; + if (merged.activityKind !== "tool.completed") { + toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex); + } + continue; + } + toolLifecycleRowIndex.delete(lifecycleKey); + } + } const previous = collapsed.at(-1); if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { - collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); + const previousIndex = collapsed.length - 1; + const previousKey = toolLifecycleCollapseMapKey(previous); + if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey); + const merged = mergeDerivedWorkLogEntries(previous, entry); + collapsed[previousIndex] = merged; + const mergedKey = toolLifecycleCollapseMapKey(merged); + if (mergedKey !== undefined && merged.activityKind !== "tool.completed") { + toolLifecycleRowIndex.set(mergedKey, previousIndex); + } continue; } collapsed.push(entry); + if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") { + toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); + } } return collapsed; } @@ -1007,10 +1082,18 @@ function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { + if ( + previous.activityKind !== "tool.started" && + previous.activityKind !== "tool.updated" && + previous.activityKind !== "tool.completed" + ) { return false; } - if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { + if ( + next.activityKind !== "tool.started" && + next.activityKind !== "tool.updated" && + next.activityKind !== "tool.completed" + ) { return false; } if (previous.activityKind === "tool.completed") { @@ -1080,7 +1163,11 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un ) { return `task${entry.taskId}`; } - if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { + if ( + entry.activityKind !== "tool.started" && + entry.activityKind !== "tool.updated" && + entry.activityKind !== "tool.completed" + ) { return undefined; } if (entry.toolCallId) { @@ -1283,6 +1370,8 @@ function extractToolCommand(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const itemInput = asRecord(item?.input); + const dataInput = asRecord(data?.input); + const stateInput = asRecord(asRecord(data?.state)?.input); const itemType = asTrimmedString(payload?.itemType); const detail = asTrimmedString(payload?.detail); const candidates: unknown[] = [ @@ -1290,6 +1379,8 @@ function extractToolCommand(payload: Record | null): { itemInput?.command, itemResult?.command, data?.command, + dataInput?.command, + stateInput?.command, itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, ]; @@ -1316,7 +1407,7 @@ function extractToolTitle(payload: Record | null): string | nul function extractToolCallId(payload: Record | null): string | null { const data = asRecord(payload?.data); - return asTrimmedString(data?.toolCallId); + return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId); } function normalizeInlinePreview(value: string): string { diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index b0b1df96e1f..f7a6412d51d 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -18,6 +18,7 @@ describe("terminalUiStateStore actions", () => { useTerminalUiStateStore.persist.clearStorage(); useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, + terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, }); }); @@ -248,6 +249,8 @@ describe("terminalUiStateStore actions", () => { it("reconciles terminal ids from an external ordered list", () => { const store = useTerminalUiStateStore.getState(); store.setTerminalOpen(THREAD_REF, true); + store.setTerminalCustomLabel(THREAD_REF, "term-a", "API server"); + store.setTerminalCustomLabel(THREAD_REF, "stale-term", "Old task"); store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]); const terminalUiState = selectThreadTerminalUiState( @@ -260,6 +263,11 @@ describe("terminalUiStateStore actions", () => { { id: "group-term-a", terminalIds: ["term-a"] }, { id: "group-term-b", terminalIds: ["term-b"] }, ]); + expect( + useTerminalUiStateStore.getState().terminalCustomLabelsByThreadKey[ + scopedThreadKey(THREAD_REF) + ], + ).toEqual({ "term-a": "API server" }); }); it("does not import a closed panel terminal from stale metadata", () => { diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 290ca8e5954..545e195a128 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -32,8 +32,11 @@ const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; interface PersistedTerminalUiStateStoreState { terminalUiStateByThreadKey?: Record; terminalStateByThreadKey?: Record; + terminalCustomLabelsByThreadKey?: Record>; } +const EMPTY_TERMINAL_CUSTOM_LABELS: Readonly> = Object.freeze({}); + export function migratePersistedTerminalUiStateStoreState( persistedState: unknown, _version: number, @@ -50,8 +53,32 @@ export function migratePersistedTerminalUiStateStoreState( parseScopedThreadKey(threadKey), ), ); + const terminalCustomLabelsByThreadKey = Object.fromEntries( + Object.entries(candidate.terminalCustomLabelsByThreadKey ?? {}).flatMap( + ([threadKey, labels]) => { + if (!parseScopedThreadKey(threadKey) || !labels || typeof labels !== "object") return []; + const normalizedLabels = Object.fromEntries( + Object.entries(labels).flatMap(([terminalId, label]) => { + const normalizedTerminalId = terminalId.trim(); + const normalizedLabel = typeof label === "string" ? label.trim().slice(0, 80) : ""; + return normalizedTerminalId && normalizedLabel + ? [[normalizedTerminalId, normalizedLabel] as const] + : []; + }), + ); + return Object.keys(normalizedLabels).length > 0 + ? [[threadKey, normalizedLabels] as const] + : []; + }, + ), + ); - return { terminalUiStateByThreadKey }; + return { + terminalUiStateByThreadKey, + ...(Object.keys(terminalCustomLabelsByThreadKey).length > 0 + ? { terminalCustomLabelsByThreadKey } + : {}), + }; } function createTerminalUiStateStorage() { @@ -489,6 +516,18 @@ export function selectThreadTerminalUiState( ); } +export function selectThreadTerminalCustomLabels( + terminalCustomLabelsByThreadKey: Record>, + threadRef: ScopedThreadRef | null | undefined, +): Readonly> { + if (!threadRef || threadRef.threadId.length === 0) { + return EMPTY_TERMINAL_CUSTOM_LABELS; + } + return ( + terminalCustomLabelsByThreadKey[terminalThreadKey(threadRef)] ?? EMPTY_TERMINAL_CUSTOM_LABELS + ); +} + function updateTerminalUiStateByThreadKey( terminalUiStateByThreadKey: Record, threadRef: ScopedThreadRef, @@ -562,6 +601,7 @@ function removeRecordEntry(record: Record, key: string): Record; + terminalCustomLabelsByThreadKey: Record>; /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ suppressedTerminalIdsByThreadKey: Record; setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; @@ -575,6 +615,11 @@ interface TerminalUiStateStoreState { options?: { open?: boolean; active?: boolean }, ) => void; setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; + setTerminalCustomLabel: ( + threadRef: ScopedThreadRef, + terminalId: string, + label: string | null, + ) => void; closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; clearTerminalUiState: (threadRef: ScopedThreadRef) => void; @@ -591,7 +636,12 @@ export const useTerminalUiStateStore = create()( state: ThreadTerminalUiState, suppressedTerminalIds: readonly string[], ) => ThreadTerminalUiState, - suppression?: { terminalId: string; suppressed: boolean }, + suppression?: { + terminalId: string; + suppressed: boolean; + clearCustomLabel?: boolean; + }, + pruneCustomLabels = false, ) => { set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -609,21 +659,57 @@ export const useTerminalUiStateStore = create()( suppression.suppressed, ) : state.suppressedTerminalIdsByThreadKey; + const terminalIdToClear = suppression?.clearCustomLabel + ? suppression.terminalId.trim() + : ""; + const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; + let nextTerminalCustomLabelsByThreadKey = + terminalIdToClear.length > 0 && currentLabels[terminalIdToClear] !== undefined + ? Object.keys(currentLabels).length === 1 + ? removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey) + : { + ...state.terminalCustomLabelsByThreadKey, + [threadKey]: removeRecordEntry(currentLabels, terminalIdToClear), + } + : state.terminalCustomLabelsByThreadKey; + if (pruneCustomLabels) { + const survivingIds = new Set( + selectThreadTerminalUiState(nextTerminalUiStateByThreadKey, threadRef).terminalIds, + ); + const labelsForThread = nextTerminalCustomLabelsByThreadKey[threadKey] ?? {}; + const survivingLabels = Object.fromEntries( + Object.entries(labelsForThread).filter(([terminalId]) => + survivingIds.has(terminalId), + ), + ); + if (Object.keys(survivingLabels).length !== Object.keys(labelsForThread).length) { + nextTerminalCustomLabelsByThreadKey = + Object.keys(survivingLabels).length > 0 + ? { + ...nextTerminalCustomLabelsByThreadKey, + [threadKey]: survivingLabels, + } + : removeRecordEntry(nextTerminalCustomLabelsByThreadKey, threadKey); + } + } if ( nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && - nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey + nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey && + nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, + terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, }; }); }; return { terminalUiStateByThreadKey: {}, + terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, setTerminalOpen: (threadRef, open) => { const terminalState = selectThreadTerminalUiState( @@ -682,22 +768,56 @@ export const useTerminalUiStateStore = create()( ), setActiveTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), + setTerminalCustomLabel: (threadRef, terminalId, label) => + set((state) => { + const normalizedTerminalId = terminalId.trim(); + if (normalizedTerminalId.length === 0) return state; + const threadKey = terminalThreadKey(threadRef); + const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; + const normalizedLabel = label?.trim().slice(0, 80) ?? ""; + if (normalizedLabel.length > 0) { + if (currentLabels[normalizedTerminalId] === normalizedLabel) return state; + return { + terminalCustomLabelsByThreadKey: { + ...state.terminalCustomLabelsByThreadKey, + [threadKey]: { ...currentLabels, [normalizedTerminalId]: normalizedLabel }, + }, + }; + } + if (currentLabels[normalizedTerminalId] === undefined) return state; + const { [normalizedTerminalId]: _removed, ...remainingLabels } = currentLabels; + return { + terminalCustomLabelsByThreadKey: + Object.keys(remainingLabels).length > 0 + ? { + ...state.terminalCustomLabelsByThreadKey, + [threadKey]: remainingLabels, + } + : removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey), + }; + }), closeTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { terminalId, suppressed: true, + clearCustomLabel: true, }), reconcileTerminalIds: (threadRef, nextIds) => - updateTerminal(threadRef, (state, suppressedTerminalIds) => { - if (suppressedTerminalIds.length === 0) { - return reconcileThreadTerminalSessionIds(state, nextIds); - } - const suppressedIds = new Set(suppressedTerminalIds); - return reconcileThreadTerminalSessionIds( - state, - nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), - ); - }), + updateTerminal( + threadRef, + (state, suppressedTerminalIds) => { + if (suppressedTerminalIds.length === 0) { + return reconcileThreadTerminalSessionIds(state, nextIds); + } + const suppressedIds = new Set(suppressedTerminalIds); + return reconcileThreadTerminalSessionIds( + state, + nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), + ); + }, + undefined, + true, + ), clearTerminalUiState: (threadRef) => set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -708,14 +828,20 @@ export const useTerminalUiStateStore = create()( ); const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; + const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; if ( nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && - !hadSuppressedTerminalIds + !hadSuppressedTerminalIds && + !hadCustomLabels ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + terminalCustomLabelsByThreadKey: removeRecordEntry( + state.terminalCustomLabelsByThreadKey, + threadKey, + ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -728,7 +854,8 @@ export const useTerminalUiStateStore = create()( const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; - if (!hadTerminalUiState && !hadSuppressedTerminalIds) { + const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; + if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) { return state; } return { @@ -736,6 +863,10 @@ export const useTerminalUiStateStore = create()( state.terminalUiStateByThreadKey, threadKey, ), + terminalCustomLabelsByThreadKey: removeRecordEntry( + state.terminalCustomLabelsByThreadKey, + threadKey, + ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -747,6 +878,7 @@ export const useTerminalUiStateStore = create()( const orphanedIds = new Set( [ ...Object.keys(state.terminalUiStateByThreadKey), + ...Object.keys(state.terminalCustomLabelsByThreadKey), ...Object.keys(state.suppressedTerminalIdsByThreadKey), ].filter((key) => !activeThreadKeys.has(key)), ); @@ -757,12 +889,17 @@ export const useTerminalUiStateStore = create()( const nextSuppressedTerminalIdsByThreadKey = { ...state.suppressedTerminalIdsByThreadKey, }; + const nextTerminalCustomLabelsByThreadKey = { + ...state.terminalCustomLabelsByThreadKey, + }; for (const id of orphanedIds) { delete nextTerminalUiStateByThreadKey[id]; + delete nextTerminalCustomLabelsByThreadKey[id]; delete nextSuppressedTerminalIdsByThreadKey[id]; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, + terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, }; }), @@ -770,11 +907,12 @@ export const useTerminalUiStateStore = create()( }, { name: TERMINAL_UI_STATE_STORAGE_KEY, - version: 4, + version: 5, storage: createJSONStorage(createTerminalUiStateStorage), migrate: migratePersistedTerminalUiStateStoreState, partialize: (state) => ({ terminalUiStateByThreadKey: state.terminalUiStateByThreadKey, + terminalCustomLabelsByThreadKey: state.terminalCustomLabelsByThreadKey, }), }, ), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 09d7d7a4602..03451cc7b2e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -111,6 +111,8 @@ export interface ContextMenuItem { header?: boolean; /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ icon?: string; + /** Inserts a visual section divider immediately before this item. */ + separatorBefore?: boolean; children?: readonly ContextMenuItem[]; } @@ -121,6 +123,7 @@ export interface ContextMenuItemSchemaType { readonly disabled?: boolean; readonly header?: boolean; readonly icon?: string; + readonly separatorBefore?: boolean; readonly children?: readonly ContextMenuItemSchemaType[]; } @@ -131,6 +134,7 @@ export const ContextMenuItemSchema: Schema.Codec = Sc disabled: Schema.optionalKey(Schema.Boolean), header: Schema.optionalKey(Schema.Boolean), icon: Schema.optionalKey(Schema.String), + separatorBefore: Schema.optionalKey(Schema.Boolean), children: Schema.optionalKey( Schema.Array( Schema.suspend((): Schema.Codec => ContextMenuItemSchema), diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index c2fa9e2a86a..81270ad320c 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -248,6 +248,7 @@ describe("mergeUsage", () => { ); expect(merged.sessions).toBe(1); + expect(merged.providers[0]?.sessions).toBe(1); }); it("returns empty totals with no environments", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 886b214183b..f5e54434fd9 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -25,6 +25,7 @@ export interface ProviderTotals { readonly costUsd: number; readonly totalTokens: number; readonly records: number; + readonly sessions: number; readonly costShare: number; readonly tokenShare: number; } @@ -135,22 +136,29 @@ function claimSources(environments: readonly EnvironmentUsage[]): { function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, -): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { +): { + readonly buckets: readonly UsageBucket[]; + readonly sessionsByProvider: ReadonlyMap; +} { const ownedProviders = new Set(); - let sessions = 0; + const sessionsByProvider = new Map(); for (const source of environment.summary.sources) { if (source.status === "missing") continue; const key = fingerprintKey(source.fingerprint); if (ownerByFingerprint.get(key) === environment.environmentId) { - ownedProviders.add(source.fingerprint.provider); + const provider = source.fingerprint.provider; + ownedProviders.add(provider); // Distinct within a directory. Summing per-bucket session counts instead // would count a session once per day and model it spans. - sessions += source.distinctSessions; + sessionsByProvider.set( + provider, + (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, + ); } } return { buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), - sessions, + sessionsByProvider, }; } @@ -228,7 +236,7 @@ export function mergeUsage( const providerAccumulator = new Map< UsageProviderKind, - { costUsd: number; totalTokens: number; records: number } + { costUsd: number; totalTokens: number; records: number; sessions: number } >(); const modelAccumulator = new Map< string, @@ -255,12 +263,20 @@ export function mergeUsage( const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { - const { buckets, sessions: environmentSessions } = ownedContribution( - environment, - ownerByFingerprint, - ); + const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); - sessions += environmentSessions; + + for (const [providerKind, providerSessions] of sessionsByProvider) { + sessions += providerSessions; + const provider = providerAccumulator.get(providerKind) ?? { + costUsd: 0, + totalTokens: 0, + records: 0, + sessions: 0, + }; + provider.sessions += providerSessions; + providerAccumulator.set(providerKind, provider); + } for (const bucket of buckets) { const tokens = bucketTokens(bucket); @@ -280,6 +296,7 @@ export function mergeUsage( costUsd: 0, totalTokens: 0, records: 0, + sessions: 0, }; provider.costUsd += bucket.costUsd; provider.totalTokens += tokens; @@ -341,6 +358,7 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, + sessions: totals.sessions, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, })) From 804cba4305b15f929937833c93e85db0835d8903 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 14 Aug 2026 22:00:02 -0400 Subject: [PATCH 011/113] revert: refresh workspace layouts and tool activity (#6657) --- .../desktop/src/electron/ElectronMenu.test.ts | 11 +- apps/desktop/src/electron/ElectronMenu.ts | 10 +- .../ActivityPayloadProjection.test.ts | 41 +- .../ActivityPayloadProjection.ts | 34 +- .../Layers/ProviderRuntimeIngestion.test.ts | 30 +- .../Layers/ProviderRuntimeIngestion.ts | 6 - apps/web/src/components/ChatView.tsx | 58 +- .../src/components/NoActiveThreadState.tsx | 19 +- apps/web/src/components/Sidebar.logic.ts | 3 +- apps/web/src/components/Sidebar.tsx | 316 +++++---- .../src/components/ThreadTerminalDrawer.tsx | 664 ++++++++---------- .../src/components/WorkspacePageContainer.tsx | 62 -- .../components/chat/ChangedFilesTree.test.tsx | 23 +- .../src/components/chat/ChangedFilesTree.tsx | 62 +- .../chat/MessagesTimeline.logic.test.ts | 216 +----- .../components/chat/MessagesTimeline.logic.ts | 412 +---------- .../components/chat/MessagesTimeline.test.tsx | 46 +- .../src/components/chat/MessagesTimeline.tsx | 491 ++++--------- .../components/chat/PanelLayoutControls.tsx | 18 +- apps/web/src/components/composerInlineChip.ts | 14 +- .../pullRequest/PullRequestDetailPanel.tsx | 617 ++++++++-------- .../pullRequest/PullRequestGhosts.tsx | 115 +-- .../pullRequest/PullRequestListFilters.tsx | 15 +- .../pullRequest/PullRequestSummaryTab.tsx | 6 +- .../settings/DiagnosticsSettings.tsx | 2 +- .../settings/KeybindingsSettings.tsx | 2 +- .../settings/ProjectSettingsPanel.tsx | 26 +- .../settings/SettingsSidebarNav.tsx | 25 +- .../src/components/settings/ThemeSettings.tsx | 2 +- .../components/settings/settingsLayout.tsx | 9 +- .../src/components/sidebar/SidebarChrome.tsx | 146 ++-- .../components/threadActionMenu.logic.test.ts | 14 +- .../src/components/threadActionMenu.logic.ts | 42 +- apps/web/src/components/ui/segmented-tabs.tsx | 40 -- apps/web/src/components/ui/toggle.tsx | 6 - apps/web/src/components/usage/UsagePage.tsx | 393 +++++++---- .../components/usage/UsageProviderChart.tsx | 137 ++-- .../src/components/usage/usageProviders.ts | 4 +- apps/web/src/contextMenuFallback.ts | 98 +-- apps/web/src/index.css | 83 +-- apps/web/src/lib/openPullRequestLink.ts | 11 - .../web/src/routes/-chatIndexTitlebar.test.ts | 4 +- apps/web/src/routes/_chat.index.tsx | 12 +- apps/web/src/routes/_chat.pull-requests.tsx | 103 +-- apps/web/src/routes/settings.tsx | 46 +- apps/web/src/session-logic.test.ts | 133 +--- apps/web/src/session-logic.ts | 105 +-- apps/web/src/terminalUiStateStore.test.ts | 8 - apps/web/src/terminalUiStateStore.ts | 170 +---- packages/contracts/src/ipc.ts | 4 - packages/shared/src/usageMerge.test.ts | 1 - packages/shared/src/usageMerge.ts | 40 +- 52 files changed, 1727 insertions(+), 3228 deletions(-) delete mode 100644 apps/web/src/components/WorkspacePageContainer.tsx delete mode 100644 apps/web/src/components/ui/segmented-tabs.tsx diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index e3c5d5dd643..58870bbab1d 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -98,10 +98,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(2), - items: [ - { id: "copy", label: "Copy" }, - { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, - ], + items: [{ id: "copy", label: "Copy" }], position: Option.some({ x: 10.8, y: 20.2 }), }); @@ -113,12 +110,6 @@ describe("ElectronMenu", () => { enabled: true, click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, }); - assert.deepEqual( - buildFromTemplateMock.mock.calls[0]?.[0].map( - (item: Electron.MenuItemConstructorOptions) => item.type ?? item.label, - ), - ["Copy", "separator", "Delete"], - ); }).pipe(Effect.provide(TestLayer)), ); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index ca8cc246e89..4d3e5a1c241 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -78,7 +78,6 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM label: sourceItem.label, destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, - ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), }; if (sourceItem.children) { @@ -142,17 +141,10 @@ export const make = Effect.gen(function* () { ): Electron.MenuItemConstructorOptions[] => { const template: Electron.MenuItemConstructorOptions[] = []; let hasInsertedDestructiveSeparator = false; - const appendSeparator = () => { - if (template.length === 0 || template.at(-1)?.type === "separator") return; - template.push({ type: "separator" }); - }; for (const item of entries) { - if (item.separatorBefore) { - appendSeparator(); - } if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - appendSeparator(); + template.push({ type: "separator" }); hasInsertedDestructiveSeparator = true; } diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 047e40ccf49..fc9ea4b6226 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -20,7 +20,7 @@ function activity(payload: Record): OrchestrationThreadActivity * If slimming ever moves to an allowlist over the whole payload, these * assertions are the tripwire. */ -describe("projectActivityPayload", () => { +describe("projectActivityPayload agent-field survival", () => { it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { const projected = projectActivityPayload( activity({ @@ -44,45 +44,6 @@ describe("projectActivityPayload", () => { expect(data.somethingClientNeverReads).toBeUndefined(); }); - it("normalizes Claude and OpenCode command inputs before slimming provider data", () => { - const claude = projectActivityPayload( - activity({ - itemType: "command_execution", - toolCallId: "claude-call-1", - data: { - toolName: "Bash", - input: { command: "vp test run" }, - result: { content: "x".repeat(5_000) }, - }, - }), - ); - const openCode = projectActivityPayload( - activity({ - itemType: "command_execution", - toolCallId: "opencode-call-1", - data: { - tool: "bash", - state: { - status: "running", - input: { command: "vp lint" }, - output: "x".repeat(5_000), - }, - }, - }), - ); - - expect(claude.payload).toMatchObject({ - toolCallId: "claude-call-1", - data: { command: "vp test run" }, - }); - expect(openCode.payload).toMatchObject({ - toolCallId: "opencode-call-1", - data: { command: "vp lint" }, - }); - expect(JSON.stringify(claude.payload).length).toBeLessThan(200); - expect(JSON.stringify(openCode.payload).length).toBeLessThan(200); - }); - it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 659760c049a..f68a3ee96e9 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -104,24 +104,6 @@ function projectCommandData(data: Record): Record 0 ? projectedItem : undefined; } -function projectCommandValue(data: Record): unknown { - if (data.command !== undefined) { - return data.command; - } - - const input = asRecord(data.input); - if (input?.command !== undefined) { - return input.command; - } - - const stateInput = asRecord(asRecord(data.state)?.input); - if (stateInput?.command !== undefined) { - return stateInput.command; - } - - return undefined; -} - function summarizeToolTextOutput(value: string): string | null { const lines: string[] = []; for (const rawLine of value.split(/\r?\n/u)) { @@ -305,9 +287,8 @@ export function projectActivityPayload( if (item) { projectedData.item = item; } - const command = projectCommandValue(data); - if (command !== undefined) { - projectedData.command = command; + if ("command" in data) { + projectedData.command = data.command; } const changedFiles: string[] = []; @@ -387,10 +368,10 @@ function dropStaleContextWindowActivities( /** * Identity both clients use to fold a tool lifecycle row into the call it * belongs to (`deriveToolLifecycleCollapseKey` in web's `session-logic` and - * mobile's `threadActivity`): the runtime item id ingestion stamps as - * `toolCallId`, a legacy `data.toolCallId`, or the itemType/title/detail triple. - * Returns null for rows with no identity at all — those never collapse on the - * client either, so they must not be dropped here. + * mobile's `threadActivity`): an explicit `data.toolCallId` when the adapter + * emits one, otherwise the itemType/title/detail triple. Returns null for rows + * with no identity at all — those never collapse on the client either, so they + * must not be dropped here. */ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | null { const payload = asRecord(activity.payload); @@ -398,8 +379,7 @@ function toolLifecycleIdentity(activity: OrchestrationThreadActivity): string | return null; } - const toolCallId = - asTrimmedString(payload.toolCallId) ?? asTrimmedString(asRecord(payload.data)?.toolCallId); + const toolCallId = asTrimmedString(asRecord(payload.data)?.toolCallId); if (toolCallId) { return `id:${toolCallId}`; } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b5feda5052d..258aa010e3e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2811,16 +2811,11 @@ describe("ProviderRuntimeIngestion", () => { createdAt: now, threadId: asThreadId("thread-1"), turnId: asTurnId("turn-9"), - itemId: asItemId("tool-call-9"), payload: { itemType: "command_execution", - status: "inProgress", - title: "Command run", - detail: "Bash: vp test run", - data: { - toolName: "Bash", - input: { command: "vp test run" }, - }, + status: "in_progress", + title: "Read file", + detail: "/tmp/file.ts", }, }); @@ -2835,20 +2830,11 @@ describe("ProviderRuntimeIngestion", () => { ); expect(thread.session?.status).toBe("ready"); - const activity = thread.activities.find( - (entry: ProviderRuntimeTestActivity) => entry.kind === "tool.started", - ); - const payload = activity?.payload as Record | undefined; - expect(payload).toMatchObject({ - itemType: "command_execution", - toolCallId: "tool-call-9", - status: "inProgress", - detail: "Bash: vp test run", - data: { - toolName: "Bash", - input: { command: "vp test run" }, - }, - }); + expect( + thread.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.kind === "tool.started", + ), + ).toBe(true); }); it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 1eb7e54b3b3..03253797242 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -794,7 +794,6 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool updated", payload: { itemType: event.payload.itemType, - ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), @@ -822,8 +821,6 @@ export function runtimeEventToActivities( summary: event.payload.title ?? "Tool", payload: { itemType: event.payload.itemType, - ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), - ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), @@ -850,10 +847,7 @@ export function runtimeEventToActivities( summary: `${event.payload.title ?? "Tool"} started`, payload: { itemType: event.payload.itemType, - ...(event.itemId !== undefined ? { toolCallId: event.itemId } : {}), - ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), - ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}), ...(event.payload.parentToolUseId ? { parentToolUseId: event.payload.parentToolUseId } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e7193a7d0ff..6eab33aec1c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -167,6 +167,7 @@ import { WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -219,11 +220,7 @@ import { import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; -import { - selectThreadTerminalCustomLabels, - selectThreadTerminalUiState, - useTerminalUiStateStore, -} from "../terminalUiStateStore"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; @@ -259,7 +256,6 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { WorkspacePageHeader } from "./WorkspacePageContainer"; import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, @@ -657,7 +653,6 @@ interface PersistentThreadTerminalDrawerProps { newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; - onHide: () => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; } @@ -672,7 +667,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra newShortcutLabel, closeShortcutLabel, keybindings, - onHide, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); @@ -996,7 +990,6 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra onSplitTerminal={splitTerminal} onSplitTerminalVertical={splitTerminalVertical} onNewTerminal={createNewTerminal} - onHide={onHide} splitShortcutLabel={visible ? splitShortcutLabel : undefined} splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined} newShortcutLabel={visible ? newShortcutLabel : undefined} @@ -1547,16 +1540,6 @@ function ChatViewContent(props: ChatViewProps) { const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; const activeThreadEnvironmentId = activeThread?.environmentId ?? null; - const activeThreadRef = useMemo( - () => - activeThreadEnvironmentId && activeThreadId - ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) - : null, - [activeThreadEnvironmentId, activeThreadId], - ); - const activeTerminalCustomLabels = useTerminalUiStateStore((state) => - selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, activeThreadRef), - ); const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, @@ -1586,15 +1569,18 @@ function ChatViewContent(props: ChatViewProps) { for (const session of activeThreadKnownSessions) { labels.set( session.target.terminalId, - activeTerminalCustomLabels[session.target.terminalId] ?? - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } - for (const [terminalId, label] of Object.entries(activeTerminalCustomLabels)) { - if (!labels.has(terminalId)) labels.set(terminalId, label); - } return labels; - }, [activeTerminalCustomLabels, activeThreadKnownSessions]); + }, [activeThreadKnownSessions]); + const activeThreadRef = useMemo( + () => + activeThreadEnvironmentId && activeThreadId + ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) + : null, + [activeThreadEnvironmentId, activeThreadId], + ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -2822,7 +2808,6 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, storeSetTerminalOpen], ); - const hideTerminal = useCallback(() => setTerminalOpen(false), [setTerminalOpen]); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; @@ -6129,6 +6114,7 @@ function ChatViewContent(props: ChatViewProps) { ? "thread" : "page" } + chromeVariant="collapse" composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} /> @@ -6174,11 +6160,20 @@ function ChatViewContent(props: ChatViewProps) { data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} > {/* Top bar */} - {!rightPanelOpen ? panelLayoutControls : null} - +
))} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index cfc40f93638..82dddd8f41e 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,15 +1,26 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset } from "./ui/sidebar"; import { isElectron } from "../env"; -import { WorkspacePageHeader } from "./WorkspacePageContainer"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export function NoActiveThreadState() { return (
- +
{isElectron ? ( - No active thread + + No active thread + ) : (
@@ -17,7 +28,7 @@ export function NoActiveThreadState() {
)} - +
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f43bd5ea629..9cb09219df0 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -299,9 +299,8 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, projectGroupCount: number, - hasProjectScope = false, ): boolean { - return hasProjectScope || shiftKey || projectGroupCount <= 1; + return shiftKey || projectGroupCount <= 1; } export function orderItemsByPreferredIds(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 010571b915d..2f0c5a22140 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -184,7 +184,6 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Keep the v2 key so existing preferences survive the v2-to-default rename. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; -const SIDEBAR_LIFECYCLE_ICON_CLASS = "size-3 shrink-0"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -367,20 +366,26 @@ function SnoozePopoverButton(props: { ); return ( - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - /> - } - > - - + + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + /> + } + /> + } + > + + + Snooze thread + {presets.map((preset) => ( ) : ( @@ -1218,7 +1223,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( @@ -1231,7 +1236,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isWoke && "group-hover/sidebar-row:static", )} > - + ) : ( )} @@ -1312,128 +1317,130 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} - - {props.isPinned ? ( - props.pinningSupported ? ( - - ) : ( - - - - ) - ) : null} - {/* Only the visible state owns this slot's width: the pin stays - directly beside the idle status and beside the first action - when the hover controls replace it. */} + + + Unpin thread + + ) : ( + + ) + ) : null} + {/* The visible state owns this slot's width: status at rest, + actions on hover/keyboard focus or while the popover is open. Keeping + the hidden state out of flow lets the project label reclaim + space without either state overlapping it. */} + + {/* Read-only status labels yield to the hover actions. Woke is + itself an action, so it stays pointer-enabled and visible + while the other controls appear beside it. */} - {/* Read-only status labels yield to the hover actions. Woke is - itself an action, so it stays pointer-enabled and visible - while the other controls appear beside it. */} + {topStatus ? ( + isWokeStatus ? ( + + ) : ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} + + ) + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported || showSnoozeButton ? ( - {topStatus ? ( - isWokeStatus ? ( - - ) : ( - - {topStatus.icon === "working" ? ( - + ) : null} + {props.settlementSupported ? ( + + - ) : topStatus.icon === "done" ? ( - - ) : null} - {/* The label alone is the live region: a role="status" - wrapper around the ticking duration would make - screen readers announce every second. */} - {topStatus.label} - {status === "working" ? ( - - - - ) : null} - - ) - ) : ( - threadTimeLabel(thread) - )} - - {props.settlementSupported || showSnoozeButton ? ( - - {showSnoozeButton ? ( - - ) : null} - {props.settlementSupported ? ( - - ) : null} - - ) : null} - + + Settle thread + + ) : null} + + ) : null}
@@ -3211,25 +3218,17 @@ export default function Sidebar() { autoAnimate(node, { duration: 150, easing: "ease-out" }); }, []); - // A selected project scope owns creation: users should not have to choose - // the same project twice. "All projects" keeps the picker in multi-project - // setups, while Shift+click retains the direct-create shortcut. + // New thread defaults to the project you're in (active thread's project, + // falling back to the top project) — same resolution the command palette + // uses. The command palette already offers a "New thread in..." submenu + // for multi-project setups. const handleNewThreadClick = useCallback( (event?: ReactMouseEvent) => { - if ( - shouldCreateNewThreadInCurrentProject( - event?.shiftKey ?? false, - projectGroups.length, - scopedProjectGroup !== null, - ) - ) { + // One project: nothing to pick, create immediately. Shift+click creates + // directly in the current project even with several projects, skipping + // the palette picker. + if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { if (isMobile) setOpenMobile(false); - if (scopedProjectGroup) { - void newThreadContext.handleNewThread( - scopeProjectRef(scopedProjectGroup.environmentId, scopedProjectGroup.id), - ); - return; - } void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, @@ -3241,19 +3240,20 @@ export default function Sidebar() { if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); }, - [isMobile, newThreadContext, projectGroups.length, scopedProjectGroup, setOpenMobile], + [isMobile, newThreadContext, projectGroups.length, setOpenMobile], ); - // With no explicit scope the button mirrors chat.new. A scoped button has - // intentionally more specific behavior, so it does not advertise the - // broader command's shortcut. + // The button mirrors chat.new: in multi-project setups both route through + // the command palette's "New thread in..." picker, and in single-project + // setups both create immediately. In multi-project setups the label is only + // the picker's shortcut: falling back to chat.newLocal would advertise the + // same shortcut for both the picker and direct create. In single-project + // setups both commands create directly, so chat.newLocal is a valid + // fallback. The second tooltip line (multi-project only) advertises + // shift+click and its keyboard twin chat.newLocal for direct create. const newThreadShortcutLabel = - scopedProjectGroup === null - ? (shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 - ? shortcutLabelForCommand(keybindings, "chat.newLocal") - : undefined)) - : undefined; + shortcutLabelForCommand(keybindings, "chat.new") ?? + (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -3332,9 +3332,7 @@ export default function Sidebar() { /> - {scopedProjectGroup ? ( - `New thread in ${scopedProjectGroup.displayName}` - ) : projectGroups.length > 1 ? ( + {projectGroups.length > 1 ? ( {newThreadShortcutLabel diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index deec13ec3bd..1266e5ed7e9 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -5,11 +5,11 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; import { - PanelBottomCloseIcon, Plus, SquareSplitHorizontal, SquareSplitVertical, TerminalSquare, + Trash2, XIcon, } from "lucide-react"; import { @@ -21,6 +21,7 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, + type ReactNode, type SetStateAction, useCallback, useEffect, @@ -29,9 +30,9 @@ import { useRef, useState, } from "react"; +import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { useResizableWidth } from "~/hooks/useResizableWidth"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { @@ -59,7 +60,6 @@ import { import { readLocalApi } from "~/localApi"; import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; -import { selectThreadTerminalCustomLabels, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; @@ -72,15 +72,10 @@ import { resolveTerminalFontSizePreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../appearanceFonts"; -import { RightPanelResizeHandle } from "./preview/RightPanelResizeHandle"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; -const TERMINAL_SIDEBAR_DEFAULT_WIDTH = 144; -const TERMINAL_SIDEBAR_MIN_WIDTH = 144; -const TERMINAL_SIDEBAR_MAX_WIDTH = 320; -const TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY = "t3code:terminal-sidebar-width"; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; @@ -249,10 +244,6 @@ export function shouldHandleTerminalSelectionMouseUp( return selectionGestureActive && button === 0; } -export function shouldShowTerminalSidebar(terminalCount: number): boolean { - return terminalCount > 1; -} - export function terminalSelectionLineRange(position: { start: { y: number }; end: { y: number }; @@ -885,7 +876,6 @@ interface ThreadTerminalDrawerProps { onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; - onHide?: () => void; splitShortcutLabel?: string | undefined; splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; @@ -901,6 +891,35 @@ interface ThreadTerminalDrawerProps { terminalLaunchLocationsById?: ReadonlyMap; } +interface TerminalActionButtonProps { + label: string; + className: string; + onClick: () => void; + children: ReactNode; +} + +function TerminalActionButton({ label, className, onClick, children }: TerminalActionButtonProps) { + return ( + + } + > + {children} + + + {label} + + + ); +} + export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, @@ -918,7 +937,6 @@ export default function ThreadTerminalDrawer({ onSplitTerminal, onSplitTerminalVertical, onNewTerminal, - onHide, splitShortcutLabel, splitVerticalShortcutLabel, newShortcutLabel, @@ -932,21 +950,6 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; - const { width: terminalSidebarWidth, handlers: terminalSidebarResizeHandlers } = - useResizableWidth({ - storageKey: TERMINAL_SIDEBAR_WIDTH_STORAGE_KEY, - defaultWidth: TERMINAL_SIDEBAR_DEFAULT_WIDTH, - minWidth: TERMINAL_SIDEBAR_MIN_WIDTH, - maxWidth: TERMINAL_SIDEBAR_MAX_WIDTH, - edge: "left", - }); - const terminalCustomLabels = useTerminalUiStateStore((state) => - selectThreadTerminalCustomLabels(state.terminalCustomLabelsByThreadKey, threadRef), - ); - const setTerminalCustomLabel = useTerminalUiStateStore((state) => state.setTerminalCustomLabel); - const [renamingTerminalId, setRenamingTerminalId] = useState(null); - const [terminalRenameDraft, setTerminalRenameDraft] = useState(""); - const cancelTerminalRenameRef = useRef(false); const [advancedTypography] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, false, @@ -1095,28 +1098,19 @@ export default function ThreadTerminalDrawer({ (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; - const hasTerminalSidebar = shouldShowTerminalSidebar(normalizedTerminalIds.length); + const hasTerminalSidebar = normalizedTerminalIds.length > 1; const isSplitView = visibleTerminalIds.length > 1; + const showGroupHeaders = + resolvedTerminalGroups.length > 1 || + resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; - const automaticTerminalLabelById = useMemo(() => { + const terminalLabelById = useMemo(() => { const next = new Map(); for (const terminalId of normalizedTerminalIds) { next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); } return next; }, [normalizedTerminalIds, terminalLabelsById]); - const terminalLabelById = useMemo(() => { - const next = new Map(); - for (const terminalId of normalizedTerminalIds) { - next.set( - terminalId, - terminalCustomLabels[terminalId]?.trim() || - automaticTerminalLabelById.get(terminalId) || - getTerminalLabel(terminalId), - ); - } - return next; - }, [automaticTerminalLabelById, normalizedTerminalIds, terminalCustomLabels]); const resolveTerminalLaunchLocation = useCallback( (terminalId: string): TerminalLaunchLocation => { return ( @@ -1129,9 +1123,6 @@ export default function ThreadTerminalDrawer({ }, [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], ); - const newTerminalActionLabel = newShortcutLabel - ? `New Terminal (${newShortcutLabel})` - : "New Terminal"; const splitTerminalActionLabel = hasReachedSplitLimit ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel @@ -1142,6 +1133,9 @@ export default function ThreadTerminalDrawer({ : splitVerticalShortcutLabel ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` : "Split Terminal Vertically"; + const newTerminalActionLabel = newShortcutLabel + ? `New Terminal (${newShortcutLabel})` + : "New Terminal"; const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; @@ -1153,43 +1147,9 @@ export default function ThreadTerminalDrawer({ if (hasReachedSplitLimit) return; onSplitTerminalVertical(); }, [hasReachedSplitLimit, onSplitTerminalVertical]); - const startTerminalRename = useCallback( - (terminalId: string) => { - cancelTerminalRenameRef.current = false; - setRenamingTerminalId(terminalId); - setTerminalRenameDraft( - terminalCustomLabels[terminalId] ?? terminalLabelById.get(terminalId) ?? "", - ); - }, - [terminalCustomLabels, terminalLabelById], - ); - const finishTerminalRename = useCallback(() => { - if (!renamingTerminalId) return; - const nextLabel = terminalRenameDraft.trim(); - const automaticLabel = automaticTerminalLabelById.get(renamingTerminalId) ?? ""; - setTerminalCustomLabel( - threadRef, - renamingTerminalId, - nextLabel.length === 0 || nextLabel === automaticLabel ? null : nextLabel, - ); - setRenamingTerminalId(null); - }, [ - automaticTerminalLabelById, - renamingTerminalId, - setTerminalCustomLabel, - terminalRenameDraft, - threadRef, - ]); - const cancelTerminalRename = useCallback(() => { - cancelTerminalRenameRef.current = true; - setRenamingTerminalId(null); - }, []); - - useEffect(() => { - cancelTerminalRenameRef.current = false; - setRenamingTerminalId(null); - setTerminalRenameDraft(""); - }, [threadRef.environmentId, threadRef.threadId]); + const onNewTerminalAction = useCallback(() => { + onNewTerminal(); + }, [onNewTerminal]); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -1314,7 +1274,7 @@ export default function ThreadTerminalDrawer({ ) : null}

No terminal sessions for this thread yet.

-
@@ -1323,72 +1283,7 @@ export default function ThreadTerminalDrawer({ } const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); - const compactTerminalToolbar = ( - <> - - - - - {!isPanel && onHide ? ( - <> - - - - ) : null} - - ); + return ( + {showGroupHeaders && ( + + )} + + {normalizedTerminalIds.length > 1 && ( + + onCloseTerminal(terminalId)} + aria-label={closeTerminalLabel} + /> + } + > + + + + {closeTerminalLabel} + + + )} +
+ ); + })} +
+
+ ); + })} +
+ + )} +
); diff --git a/apps/web/src/components/WorkspacePageContainer.tsx b/apps/web/src/components/WorkspacePageContainer.tsx deleted file mode 100644 index 4613dd465b1..00000000000 --- a/apps/web/src/components/WorkspacePageContainer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { ComponentPropsWithoutRef } from "react"; - -import { cn } from "../lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; - -export type WorkspacePageWidth = "readable" | "wide" | "expanded"; - -const WIDTH_CLASS: Record = { - readable: "max-w-4xl", - wide: "max-w-5xl", - expanded: "max-w-6xl", -}; - -/** Shared full-page frame for workspace routes beneath their top bar. */ -export function WorkspacePageContainer({ - width = "readable", - className, - ...props -}: ComponentPropsWithoutRef<"div"> & { readonly width?: WorkspacePageWidth }) { - return ( -
- ); -} - -/** Shared top-bar geometry for every full-width workspace surface. */ -export function WorkspacePageHeader({ - electron = false, - reserveNativeControls = electron, - className, - ...props -}: ComponentPropsWithoutRef<"header"> & { - readonly electron?: boolean; - readonly reserveNativeControls?: boolean; -}) { - return ( -
- ); -} - -/** Keeps an icon glyph on the content edge while its larger hit target extends outward. */ -export function WorkspacePageHeaderEdgeControl({ - className, - ...props -}: ComponentPropsWithoutRef<"div">) { - return
; -} diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index bc3c4fa80df..e9fa1895bf9 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -23,9 +23,13 @@ describe("ChangedFilesCard", () => { expect(markup).toContain('data-changed-files-state="expanded"'); expect(markup).toContain('aria-expanded="true"'); expect(markup).toContain("whitespace-nowrap"); - expect(markup).toContain('class="flex min-w-0 items-center gap-1.5 rounded-md px-1 py-1'); + expect(markup).toContain( + 'class="group flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden', + ); expect(markup).toContain('class="flex shrink-0 items-center gap-1 whitespace-nowrap'); - expect(markup).toContain('class="hidden @[24rem]/changed-files:inline">Open diff'); + expect(markup).toContain('class="ml-1 hidden min-w-0 flex-1 truncate'); + expect(markup).toContain("@[24rem]/changed-files:inline"); + expect(markup).not.toContain("sm:inline"); expect(markup).toContain('class="flex shrink-0 items-center gap-1.5"'); expect(markup).toContain("!size-[22px]"); expect(markup).toContain("size-3"); @@ -34,11 +38,9 @@ describe("ChangedFilesCard", () => { expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); expect(markup).toContain("1 changed file"); expect(markup).not.toContain("1 changed files"); - expect(markup).not.toContain("Hide files"); - expect(markup).not.toContain("ml-auto"); }); - it("renders a clean representative-file preview for a large latest change", () => { + it("renders a scope and representative-file preview for a large latest change", () => { const markup = renderToStaticMarkup( { expect(markup).toContain('data-changed-files-state="preview"'); expect(markup).toContain('aria-expanded="false"'); - expect(markup).toContain("apps/web/src/"); - expect(markup).toContain("packages/shared/src/"); + expect(markup).toContain("apps"); + expect(markup).toContain("2 files"); + expect(markup).toContain("packages"); + expect(markup).toContain("root"); expect(markup).toContain("App.tsx"); expect(markup).toContain("git.ts"); expect(markup).toContain("README.md"); - expect(markup).not.toContain("basis-0"); - expect(markup).not.toContain("+1 more"); - expect(markup).not.toContain("Show files"); - expect(markup).toContain('aria-label="120 additions, 20 deletions"'); + expect(markup).toContain("Show all 4 files"); expect(markup).not.toContain("App.test.tsx"); }); diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index a8bb461c0e1..d29d8b7f2f4 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,7 +19,11 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { changedFileName, selectChangedFilePreview } from "./changedFilesPresentation"; +import { + changedFileName, + selectChangedFilePreview, + summarizeChangedFileScopes, +} from "./changedFilesPresentation"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; @@ -46,12 +50,13 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff, } = props; const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); + const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); const compactPreviewVisible = showCompactPreview && !expanded; return (
onExpandedChange(!expanded)} > )} + + {expanded ? "Hide files" : "Show files"} +
{expanded ? ( @@ -150,35 +158,43 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { onOpenTurnDiff={onOpenTurnDiff} /> ) : compactPreviewVisible ? ( -
-
+
+

+ {scopeSummary.map((scope, index) => ( + + {index > 0 ? : null} + {scope.label} + + {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} + + + ))} +

+
{previewFiles.map((file) => ( ))} +
) : null} @@ -254,11 +270,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { ) : ( )} - + {node.name} {hasNonZeroStat(node.stat) && ( - + )} @@ -289,11 +305,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { theme={resolvedTheme} className="size-3.5 text-muted-foreground/70" /> - + {node.name} {node.stat && ( - + )} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 82338dec2a8..6d74204bc1c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -531,9 +531,9 @@ describe("deriveMessagesTimelineRows", () => { expect(expandedRows.map((row) => row.id)).toEqual([ "user-entry", - "assistant-thought-entry", - "work-toggle:work-entry-1", "turn-fold:turn-1", + "assistant-thought-entry", + "work-entry-1", "assistant-final-entry", ]); expect( @@ -638,84 +638,6 @@ describe("deriveMessagesTimelineRows", () => { expect(foldRow?.label).toBe("Worked for 12s"); }); - it("keeps a superseded turn fold beside the final response after a steer", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "initial-user-entry", - kind: "message", - createdAt: "2026-01-01T00:00:00Z", - message: { - id: "initial-user" as never, - role: "user", - text: "Start the work", - turnId: null, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - streaming: false, - }, - }, - { - id: "superseded-work-entry", - kind: "work", - createdAt: "2026-01-01T00:00:10Z", - entry: { - id: "superseded-work", - createdAt: "2026-01-01T00:00:10Z", - turnId: "turn-1" as never, - label: "Ran command", - tone: "tool", - }, - }, - { - id: "steer-user-entry", - kind: "message", - createdAt: "2026-01-01T00:00:12Z", - message: { - id: "steer-user" as never, - role: "user", - text: "Change the approach", - turnId: null, - createdAt: "2026-01-01T00:00:12Z", - updatedAt: "2026-01-01T00:00:12Z", - streaming: false, - }, - }, - { - id: "assistant-final-entry", - kind: "message", - createdAt: "2026-01-01T00:00:20Z", - message: { - id: "assistant-final" as never, - role: "assistant", - text: "Implemented locally, uncommitted.", - turnId: "turn-2" as never, - createdAt: "2026-01-01T00:00:20Z", - updatedAt: "2026-01-01T00:00:21Z", - streaming: false, - }, - }, - ], - latestTurn: { - turnId: "turn-2" as never, - state: "completed", - startedAt: "2026-01-01T00:00:12Z", - completedAt: "2026-01-01T00:00:21Z", - }, - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); - - expect(rows.map((row) => row.id)).toEqual([ - "initial-user-entry", - "steer-user-entry", - "turn-fold:turn-1", - "assistant-final-entry", - ]); - }); - it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ @@ -849,7 +771,6 @@ describe("deriveMessagesTimelineRows", () => { turnId: "turn-1" as never, label: "Ran command", tone: "tool" as const, - toolLifecycleStatus: "inProgress" as const, }, }, ], @@ -867,133 +788,10 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); expect(rows.map((row) => row.id)).toEqual([ - "working-indicator-row", "assistant-thought-entry", - "work-live:work-entry-1", - ]); - }); - - it("keeps the current tool batch expandable while live entries append", () => { - const timelineEntries = [ - { - id: "work-entry-1", - kind: "work" as const, - createdAt: "2026-01-01T00:00:01Z", - entry: { - id: "work-1", - createdAt: "2026-01-01T00:00:01Z", - turnId: "turn-1" as never, - toolCallId: "call-1", - label: "Read file", - tone: "tool" as const, - }, - }, - { - id: "work-entry-2", - kind: "work" as const, - createdAt: "2026-01-01T00:00:02Z", - entry: { - id: "work-2", - createdAt: "2026-01-01T00:00:02Z", - turnId: "turn-1" as never, - toolCallId: "call-2", - label: "Run command", - command: "vp test run", - tone: "tool" as const, - }, - }, - ]; - const baseInput = { - timelineEntries, - latestTurn: { - turnId: "turn-1" as never, - state: "running" as const, - startedAt: "2026-01-01T00:00:00Z", - completedAt: null, - }, - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }; - - const collapsedRows = deriveMessagesTimelineRows(baseInput); - const expandedRows = deriveMessagesTimelineRows({ - ...baseInput, - expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), - }); - - expect(collapsedRows.map((row) => row.id)).toEqual([ + "work-entry-1", "working-indicator-row", - "work-live:tool:call-1", ]); - expect(collapsedRows.find((row) => row.kind === "work-live")).toMatchObject({ - groupId: "work-group:tool:call-1", - expanded: false, - groupedEntries: [{ id: "work-1" }, { id: "work-2" }], - }); - expect(expandedRows.map((row) => row.id)).toEqual([ - "working-indicator-row", - "work-live:tool:call-1", - "work-1", - "work-2", - ]); - expect(expandedRows.find((row) => row.kind === "work-live")).toMatchObject({ - groupId: "work-group:tool:call-1", - expanded: true, - }); - - const appendedRows = deriveMessagesTimelineRows({ - ...baseInput, - timelineEntries: [ - ...timelineEntries, - { - id: "work-entry-3", - kind: "work" as const, - createdAt: "2026-01-01T00:00:03Z", - entry: { - id: "work-3", - createdAt: "2026-01-01T00:00:03Z", - turnId: "turn-1" as never, - toolCallId: "call-3", - label: "Changed file", - tone: "tool" as const, - }, - }, - ], - expandedWorkGroupIds: new Set(["work-group:tool:call-1"]), - }); - - expect(appendedRows.map((row) => row.id)).toEqual([ - "working-indicator-row", - "work-live:tool:call-1", - "work-1", - "work-2", - "work-3", - ]); - - const rowsWithLaterPlan = deriveMessagesTimelineRows({ - ...baseInput, - timelineEntries: [ - ...timelineEntries, - { - id: "plan:thread-1:turn:turn-1", - kind: "proposed-plan" as const, - createdAt: "2026-01-01T00:00:03Z", - proposedPlan: { - id: "plan:thread-1:turn:turn-1", - turnId: "turn-1" as never, - planMarkdown: "# Next steps", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-01-01T00:00:03Z", - updatedAt: "2026-01-01T00:00:03Z", - }, - }, - ], - }); - expect(rowsWithLaterPlan.some((row) => row.kind === "work-live")).toBe(false); - expect(rowsWithLaterPlan.some((row) => row.kind === "proposed-plan")).toBe(true); }); it("does not fold the session's running turn when latestTurn regresses", () => { @@ -1054,7 +852,7 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ "turn-1", ]); - expect(rows.map((row) => row.id)).toContain("work-live:running-work-entry"); + expect(rows.map((row) => row.id)).toContain("running-work-entry"); }); it("only shows assistant metadata on the terminal assistant message", () => { @@ -1196,18 +994,18 @@ describe("deriveMessagesTimelineRows", () => { expandedWorkGroupIds: new Set(["work-group:work-entry-1"]), }); - expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]); + expect(collapsedRows.map((row) => row.id)).toEqual(["work-3", "work-toggle:work-entry-1"]); expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ groupId: "work-group:work-entry-1", - hiddenCount: 3, + hiddenCount: 2, expanded: false, onlyToolEntries: true, }); expect(expandedRows.map((row) => row.id)).toEqual([ - "work-toggle:work-entry-1", "work-1", "work-2", "work-3", + "work-toggle:work-entry-1", ]); expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ expanded: true, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8d7fc52fdca..6bc0a2a6203 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,7 +1,6 @@ import * as Equal from "effect/Equal"; import { formatDuration, - workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, @@ -167,17 +166,6 @@ export type MessagesTimelineRow = id: string; createdAt: string; groupedEntries: WorkLogEntry[]; - isExpandedToolGroupEntry: boolean; - isLastExpandedToolGroupEntry: boolean; - } - | { - kind: "work-live"; - id: string; - createdAt: string; - entry: WorkLogEntry; - groupedEntries: WorkLogEntry[]; - groupId: string; - expanded: boolean; } | { kind: "work-toggle"; @@ -187,9 +175,6 @@ export type MessagesTimelineRow = hiddenCount: number; expanded: boolean; onlyToolEntries: boolean; - summary: string | null; - summaryKind: ToolGroupAction | "mixed" | null; - hasFailure: boolean; } | { kind: "turn-fold"; @@ -223,12 +208,7 @@ export type MessagesTimelineRow = createdAt: string; turnPlan: TurnPlanEntry; } - | { - kind: "working"; - id: string; - createdAt: string | null; - showThinking: boolean; - }; + | { kind: "working"; id: string; createdAt: string | null }; export interface StableMessagesTimelineRowsState { byId: Map; @@ -258,90 +238,6 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } -type ToolGroupAction = "read" | "edit" | "command" | "search" | "other"; - -function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { - if (entry.requestKind === "file-read" || entry.itemType === "image_view") return "read"; - if ( - entry.requestKind === "file-change" || - entry.itemType === "file_change" || - (entry.changedFiles?.length ?? 0) > 0 - ) { - return "edit"; - } - if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) { - return "command"; - } - if (entry.itemType === "web_search") return "search"; - return "other"; -} - -function toolGroupActionCount( - action: ToolGroupAction, - entries: ReadonlyArray, -): number { - if (action !== "edit") return entries.length; - - const changedFiles = new Set(); - let editsWithoutFileDetails = 0; - for (const entry of entries) { - if (!entry.changedFiles || entry.changedFiles.length === 0) { - editsWithoutFileDetails += 1; - continue; - } - for (const file of entry.changedFiles) changedFiles.add(file); - } - return changedFiles.size + editsWithoutFileDetails; -} - -function toolGroupActionLabel(action: ToolGroupAction, count: number): string { - switch (action) { - case "read": - return `Read ${count} ${count === 1 ? "file" : "files"}`; - case "edit": - return `Changed ${count} ${count === 1 ? "file" : "files"}`; - case "command": - return `Ran ${count} ${count === 1 ? "command" : "commands"}`; - case "search": - return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; - case "other": - return `Used ${count} ${count === 1 ? "tool" : "tools"}`; - } -} - -/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */ -export function summarizeToolGroup(entries: ReadonlyArray): string { - const groupedEntries = new Map(); - for (const entry of entries) { - const action = toolGroupAction(entry); - const group = groupedEntries.get(action); - if (group) group.push(entry); - else groupedEntries.set(action, [entry]); - } - const labels = [...groupedEntries].map(([action, actionEntries]) => - toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)), - ); - const sentenceLabels = labels.map((label, index) => - index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), - ); - if (sentenceLabels.length < 2) return sentenceLabels[0] ?? ""; - if (sentenceLabels.length === 2) return sentenceLabels.join(" and "); - return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; -} - -function toolGroupSummaryKind(entries: ReadonlyArray): ToolGroupAction | "mixed" { - const actions = new Set(entries.map(toolGroupAction)); - return actions.size === 1 ? actions.values().next().value! : "mixed"; -} - -function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string { - return entry.toolCallId ? `tool:${entry.toolCallId}` : timelineEntryId; -} - -function workGroupId(timelineEntryId: string, entry: WorkLogEntry): string { - return `work-group:${workGroupIdentity(timelineEntryId, entry)}`; -} - export function resolveAssistantMessageCopyState({ text, showCopyButton, @@ -414,34 +310,17 @@ function deriveUnsettledTurnId( return isSettled ? null : latestTurn.turnId; } -function lastUserMessageIndex(timelineEntries: ReadonlyArray): number { - return timelineEntries.findLastIndex( - (entry) => entry.kind === "message" && entry.message.role === "user", - ); -} - -function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { - if (entry.kind === "message") { - return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null; - } - if (entry.kind === "turn-plan") { - return entry.turnPlan.turnId; - } - return entry.kind === "work" ? (entry.entry.turnId ?? null) : null; -} - /** * Settled turns fold their commentary and tool activity behind a - * "Worked for ..." row placed immediately before the next terminal assistant - * response. A steer can split one visible response across turn ids, so tying - * the disclosure to the first hidden entry would strand it above the steer. + * "Worked for ..." row anchored at the turn's first foldable entry; the + * terminal assistant message stays visible below the fold. */ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; terminalAssistantMessageIds: ReadonlySet; latestTurn: TimelineLatestTurn | null; unsettledTurnId: TurnId | null; -}): ReadonlyMap> { +}): ReadonlyMap { interface TurnGroup { entries: Array; terminalEntry: Extract | null; @@ -496,7 +375,7 @@ function deriveTurnFolds(input: { } } - const foldsByAnchorEntryId = new Map(); + const foldsByAnchorEntryId = new Map(); for (const [turnId, group] of groupsByTurnId) { if (turnId === input.unsettledTurnId) { continue; @@ -526,24 +405,6 @@ function deriveTurnFolds(input: { if (!firstEntry || !lastEntry) { continue; } - const lastHiddenEntryIndex = input.timelineEntries.findLastIndex((entry) => - hiddenEntryIds.has(entry.id), - ); - if (lastHiddenEntryIndex < 0) { - continue; - } - const nextTerminalAssistantEntry = input.timelineEntries - .slice(lastHiddenEntryIndex + 1) - .find( - (entry) => - entry.kind === "message" && - entry.message.role === "assistant" && - input.terminalAssistantMessageIds.has(entry.message.id), - ); - const anchorEntry = nextTerminalAssistantEntry ?? input.timelineEntries[lastHiddenEntryIndex]; - if (!anchorEntry) { - continue; - } const isLatestInterruptedTurn = input.latestTurn?.turnId === turnId && input.latestTurn.state === "interrupted"; @@ -570,16 +431,13 @@ function deriveTurnFolds(input: { ? `Worked for ${duration}` : "Worked"; - const fold = { + foldsByAnchorEntryId.set(firstEntry.id, { turnId, - anchorEntryId: anchorEntry.id, - createdAt: anchorEntry.createdAt, + anchorEntryId: firstEntry.id, + createdAt: firstEntry.createdAt, hiddenEntryIds, label, - }; - const anchoredFolds = foldsByAnchorEntryId.get(anchorEntry.id); - if (anchoredFolds) anchoredFolds.push(fold); - else foldsByAnchorEntryId.set(anchorEntry.id, [fold]); + }); } return foldsByAnchorEntryId; } @@ -611,184 +469,36 @@ export function deriveMessagesTimelineRows(input: { unsettledTurnId, }); const collapsedEntryIds = new Set(); - for (const folds of foldsByAnchorEntryId.values()) { - for (const fold of folds) { - if (!input.expandedTurnIds?.has(fold.turnId)) { - for (const entryId of fold.hiddenEntryIds) { - collapsedEntryIds.add(entryId); - } + for (const fold of foldsByAnchorEntryId.values()) { + if (!input.expandedTurnIds?.has(fold.turnId)) { + for (const entryId of fold.hiddenEntryIds) { + collapsedEntryIds.add(entryId); } } } - let activeTurnHeaderIndex = input.timelineEntries.length; - if (input.isWorking) { - const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries); - const firstOwnedAfterUser = - unsettledTurnId === null - ? -1 - : input.timelineEntries.findIndex( - (entry, index) => - index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId, - ); - activeTurnHeaderIndex = - firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1; - } - const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) => - input.isWorking && - index >= activeTurnHeaderIndex && - (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId); - const isVisibleActiveToolEntry = (entry: WorkLogEntry) => - workLogEntryIsToolLike(entry) && - (entry.toolLifecycleStatus === "inProgress" || !workEntryIndicatesToolNeutralStatus(entry)); - const activeEntries = input.isWorking - ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index)) - : []; - const activeTurnHasVisibleContent = - activeEntries.some((entry) => { - if (entry.kind === "message") { - return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0; - } - if (entry.kind === "work") { - return entry.entry.agentSpawn === undefined && isVisibleActiveToolEntry(entry.entry); - } - if (entry.kind === "turn-plan") return true; - return false; - }) || - input.timelineEntries - .slice(activeTurnHeaderIndex) - .some((entry) => entry.kind === "proposed-plan" || entry.kind === "turn-plan"); - - const activeWorkEntryIds = new Set(); - const activeWorkRowsByAnchorId = new Map< - string, - Extract - >(); - const hasLaterTurnContent = Array.from({ length: input.timelineEntries.length + 1 }, () => false); - for (let index = input.timelineEntries.length - 1; index >= 0; index -= 1) { - const entry = input.timelineEntries[index]; - if (!entry) continue; - const isVisibleTurnContent = - (entry.kind === "message" && entry.message.role === "user") || - entry.kind === "proposed-plan" || - (entryBelongsToActiveTurn(entry, index) && - ((entry.kind === "message" && entry.message.role === "assistant") || - entry.kind === "turn-plan" || - (entry.kind === "work" && - entry.entry.agentSpawn === undefined && - isVisibleActiveToolEntry(entry.entry)))); - hasLaterTurnContent[index] = isVisibleTurnContent || hasLaterTurnContent[index + 1] === true; - } - - for (let index = 0; index < input.timelineEntries.length; index += 1) { - const entry = input.timelineEntries[index]; - if ( - !entry || - entry.kind !== "work" || - entry.entry.agentSpawn !== undefined || - !entryBelongsToActiveTurn(entry, index) - ) { - continue; - } - if (!isVisibleActiveToolEntry(entry.entry)) { - continue; - } - - const anchorEntry = entry; - let latestToolEntry = entry; - const batchEntryIds = [entry.id]; - const visibleBatchEntries = [entry.entry]; - let cursor = index + 1; - while (cursor < input.timelineEntries.length) { - const nextEntry = input.timelineEntries[cursor]; - if ( - !nextEntry || - nextEntry.kind !== "work" || - nextEntry.entry.agentSpawn !== undefined || - !entryBelongsToActiveTurn(nextEntry, cursor) - ) { - break; - } - batchEntryIds.push(nextEntry.id); - if (isVisibleActiveToolEntry(nextEntry.entry)) { - latestToolEntry = nextEntry; - visibleBatchEntries.push(nextEntry.entry); - } - cursor += 1; - } - - // Once newer commentary, a plan, or another tool batch exists, this batch - // is history. Let the regular work-group path turn it into an expandable - // summary so none of its calls disappear behind the live one-line view. - if (hasLaterTurnContent[cursor] !== true) { - for (const entryId of batchEntryIds) activeWorkEntryIds.add(entryId); - const groupId = workGroupId(anchorEntry.id, anchorEntry.entry); - activeWorkRowsByAnchorId.set(anchorEntry.id, { - kind: "work-live", - id: `work-live:${workGroupIdentity(anchorEntry.id, anchorEntry.entry)}`, - createdAt: anchorEntry.createdAt, - entry: latestToolEntry.entry, - groupedEntries: visibleBatchEntries, - groupId, - expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, - }); - } - index = cursor - 1; - } - for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; if (!timelineEntry) { continue; } - if (input.isWorking && index === activeTurnHeaderIndex) { + const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); + if (turnFold) { nextRows.push({ - kind: "working", - id: "working-indicator-row", - createdAt: input.activeTurnStartedAt, - showThinking: !activeTurnHasVisibleContent, + kind: "turn-fold", + id: `turn-fold:${turnFold.turnId}`, + createdAt: turnFold.createdAt, + turnId: turnFold.turnId, + label: turnFold.label, + expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, }); } - const anchoredTurnFolds = foldsByAnchorEntryId.get(timelineEntry.id); - if (anchoredTurnFolds) { - for (const turnFold of anchoredTurnFolds) { - nextRows.push({ - kind: "turn-fold", - id: `turn-fold:${turnFold.turnId}`, - createdAt: turnFold.createdAt, - turnId: turnFold.turnId, - label: turnFold.label, - expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false, - }); - } - } - if (collapsedEntryIds.has(timelineEntry.id)) { continue; } - if (activeWorkEntryIds.has(timelineEntry.id)) { - const activeWorkRow = activeWorkRowsByAnchorId.get(timelineEntry.id); - if (activeWorkRow) { - nextRows.push(activeWorkRow); - if (activeWorkRow.expanded) { - for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { - nextRows.push({ - kind: "work", - id: workEntry.id, - createdAt: workEntry.createdAt, - groupedEntries: [workEntry], - isExpandedToolGroupEntry: true, - isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1, - }); - } - } - } - continue; - } - if (timelineEntry.kind === "work") { const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; @@ -797,7 +507,6 @@ export function deriveMessagesTimelineRows(input: { if ( !nextEntry || nextEntry.kind !== "work" || - activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || foldsByAnchorEntryId.has(nextEntry.id) ) { @@ -810,48 +519,15 @@ export function deriveMessagesTimelineRows(input: { (entry) => !workEntryIndicatesToolNeutralStatus(entry), ); if (visibleGroupedEntries.length > 0) { - const onlyToolEntries = visibleGroupedEntries.every( - (entry) => workLogEntryIsToolLike(entry) && entry.agentSpawn === undefined, - ); - if (onlyToolEntries) { - const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); - const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; - const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); - nextRows.push({ - kind: "work-toggle", - id: `work-toggle:${timelineEntry.id}`, - createdAt: timelineEntry.createdAt, - groupId, - hiddenCount: visibleGroupedEntries.length, - expanded, - onlyToolEntries: true, - summary: summarizeToolGroup(visibleGroupedEntries), - summaryKind, - hasFailure: visibleGroupedEntries.some((entry) => workEntryIndicatesToolFailure(entry)), - }); - if (expanded) { - for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { - nextRows.push({ - kind: "work", - id: workEntry.id, - createdAt: workEntry.createdAt, - groupedEntries: [workEntry], - isExpandedToolGroupEntry: true, - isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, - }); - } - } - } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { + if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) { nextRows.push({ kind: "work", id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries: visibleGroupedEntries, - isExpandedToolGroupEntry: false, - isLastExpandedToolGroupEntry: false, }); } else { - const groupId = workGroupId(timelineEntry.id, timelineEntry.entry); + const groupId = `work-group:${timelineEntry.id}`; const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; // Agent-spawn CTA rows are always visible: a running fleet must // never hide behind a "+N tool calls" toggle. Selection is by @@ -875,8 +551,6 @@ export function deriveMessagesTimelineRows(input: { id: workEntry.id, createdAt: workEntry.createdAt, groupedEntries: [workEntry], - isExpandedToolGroupEntry: false, - isLastExpandedToolGroupEntry: false, }); } @@ -888,11 +562,8 @@ export function deriveMessagesTimelineRows(input: { groupId, hiddenCount: hiddenEntries.length, expanded, - onlyToolEntries, - summary: null, - summaryKind: null, - hasFailure: visibleGroupedEntries.some((entry) => - workEntryIndicatesToolFailure(entry), + onlyToolEntries: visibleGroupedEntries.every((entry) => + workLogEntryIsToolLike(entry), ), }); } @@ -958,12 +629,11 @@ export function deriveMessagesTimelineRows(input: { }); } - if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { + if (input.isWorking) { nextRows.push({ kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt, - showThinking: !activeTurnHasVisibleContent, }); } @@ -996,9 +666,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return ( - a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking - ); + return a.createdAt === (b as typeof a).createdAt; case "turn-fold": { const bf = b as typeof a; @@ -1015,25 +683,8 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; } - case "work": { - const bw = b as typeof a; - return ( - a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry && - a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry && - Equal.equals(a.groupedEntries, bw.groupedEntries) - ); - } - - case "work-live": { - const bw = b as typeof a; - return ( - a.createdAt === bw.createdAt && - a.groupId === bw.groupId && - a.expanded === bw.expanded && - Equal.equals(a.entry, bw.entry) && - Equal.equals(a.groupedEntries, bw.groupedEntries) - ); - } + case "work": + return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); case "work-toggle": { const bw = b as typeof a; @@ -1042,10 +693,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.groupId === bw.groupId && a.hiddenCount === bw.hiddenCount && a.expanded === bw.expanded && - a.onlyToolEntries === bw.onlyToolEntries && - a.summary === bw.summary && - a.summaryKind === bw.summaryKind && - a.hasFailure === bw.hasFailure + a.onlyToolEntries === bw.onlyToolEntries ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 3dcf6cf2a30..194edc0bd5b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -554,49 +554,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work Log"); }); - it("makes the whole live tool row expandable without adding a chevron", () => { - const turnId = TurnId.make("turn-live-tools"); - const markup = renderToStaticMarkup( - , - ); - - expect(markup).not.toContain('aria-label="Expand current tool calls"'); - expect(markup).toContain('aria-expanded="false"'); - expect(markup).toContain("Running psql"); - expect(markup).not.toContain("lucide-chevron-right"); - expect(markup).not.toContain("hover:bg-accent/20"); - }); - - it("summarizes completed changed-file activity", () => { + it("formats changed file paths from the workspace root", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain("Changed 1 file"); + expect(markup).toContain("t3code/apps/web/src/session-logic.ts"); expect(markup).not.toContain("C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3ccd4808d06..e190f47569b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -35,6 +35,7 @@ import { deriveTimelineEntries, workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, + workEntryIndicatesToolSuccess, workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; @@ -56,6 +57,7 @@ import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon, + MinusIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -918,34 +920,17 @@ type TimelineWorkEntry = Extract["grouped type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { - const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry; - const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry; - const isExpandedToolGroupHeader = - (row.kind === "work-toggle" && row.onlyToolEntries && row.expanded) || - (row.kind === "work-live" && row.expanded); - return (
- {row.kind === "work" ? ( - - ) : null} - {row.kind === "work-live" ? : null} + {row.kind === "work" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} @@ -1104,6 +1083,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const Icon = row.expanded ? ChevronDownIcon : ChevronRightIcon; return (
@@ -1112,12 +1092,10 @@ function TurnFoldTimelineRow({ row }: { row: Extract ctx.onToggleTurnFold(row.turnId)} - className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" + className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" > {row.label} - +
); @@ -1300,10 +1278,16 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ }); function WorkingTimelineRow({ row }: { row: Extract }) { + const { workingStepLabel } = use(TimelineRowActivityCtx); return ( -
-
-
+
+
+ + + + + + {row.createdAt ? ( <> Working for @@ -1311,13 +1295,11 @@ function WorkingTimelineRow({ row }: { row: Extract + + {workingStepLabel ? ( + · {workingStepLabel} + ) : null}
- {row.showThinking ? ( -
- -
- ) : null}
); } @@ -1358,10 +1340,8 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { /** Renders one or more already-derived work log rows. Overflow expansion is modeled as LegendList data. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, - isExpandedToolGroupEntry, }: { groupedEntries: Extract["groupedEntries"]; - isExpandedToolGroupEntry: boolean; }) { const { workspaceRoot } = use(TimelineRowCtx); const nonEmptyEntries = useMemo( @@ -1378,10 +1358,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ if (nonEmptyEntries.length === 0) return null; return ( -
+
{!onlyToolEntries && (

{groupLabel}

)} @@ -1391,7 +1368,6 @@ const WorkGroupSection = memo(function WorkGroupSection({ key={workEntry.id} workEntry={workEntry} workspaceRoot={workspaceRoot} - isExpandedToolGroupEntry={isExpandedToolGroupEntry} /> ))}
@@ -1399,128 +1375,12 @@ const WorkGroupSection = memo(function WorkGroupSection({ ); }); -function LiveActivityRow({ label, iconName }: { label: string; iconName?: WorkEntryIconName }) { - return ( -
- -
-
-
- -
-
-
-
- ); -} - -function ThinkingActivityRow() { - return ; -} - -function LiveActivityContent({ - label, - iconName, - highlighted = false, -}: { - label: string; - iconName: WorkEntryIconName | undefined; - highlighted?: boolean; -}) { - return ( -
- {iconName ? ( - - - - ) : null} - {label} -
- ); -} - -function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { - const ctx = use(TimelineRowCtx); - - return ( - - ); -} - -function toolGroupSummaryIconName( - kind: Extract["summaryKind"], -): WorkEntryIconName { - switch (kind) { - case "read": - return "eye"; - case "edit": - return "square-pen"; - case "command": - return "terminal"; - case "search": - return "globe"; - case "other": - return "wrench"; - case "mixed": - case null: - return "hammer"; - } -} - function WorkGroupToggleTimelineRow({ row, }: { row: Extract; }) { const ctx = use(TimelineRowCtx); - if (row.onlyToolEntries && row.summary) { - return ( - - ); - } const labelNoun = row.onlyToolEntries ? row.hiddenCount === 1 ? "tool call" @@ -2159,101 +2019,32 @@ function workEntryPreview( : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; } -type CommandWrapper = "env" | "sudo"; - -const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record> = { - env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), - sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]), -}; - -const COMMAND_WRAPPER_FLAGS: Record> = { - env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug"]), - sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]), -}; - -function commandProgramName(command: string): string | null { - const tokens = command.trim().split(/\s+/); - let index = 0; - let wrapper: CommandWrapper | null = null; - - while (index < tokens.length) { - const token = tokens[index]?.replace(/^["']|["']$/g, ""); - if (!token) return null; - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { - index += 1; - continue; - } - if (token === "env" || token === "sudo") { - wrapper = token; - index += 1; - continue; - } - if (wrapper !== null && token === "--") { - wrapper = null; - index += 1; - continue; - } - if (wrapper !== null && token.startsWith("-")) { - if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) { - if (tokens[index + 1] === undefined) return null; - index += 2; - continue; - } - if (COMMAND_WRAPPER_FLAGS[wrapper].has(token) || /^--[^=]+=/.test(token)) { - index += 1; - continue; - } - if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) { - let consumesNextToken = false; - for (const [optionIndex, option] of token.slice(1).split("").entries()) { - const shortOption = `-${option}`; - if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) { - consumesNextToken = optionIndex === token.length - 2; - break; - } - if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null; - } - if (consumesNextToken && tokens[index + 1] === undefined) return null; - index += consumesNextToken ? 2 : 1; - continue; - } - return null; - } - return token.split(/[\\/]/).at(-1) || null; - } - - return null; -} - -function liveWorkEntryLabel( - workEntry: TimelineWorkEntry, - workspaceRoot: string | undefined, -): string { - const command = workEntry.command?.trim(); - if (command) { - const program = commandProgramName(command); - if (program) return `Running ${program}`; - return "Running command"; +function workEntryRawCommand( + workEntry: Pick, +): string | null { + const rawCommand = workEntry.rawCommand?.trim(); + if (!rawCommand || !workEntry.command) { + return null; } - - return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); + return rawCommand === workEntry.command.trim() ? null : rawCommand; } function buildToolCallExpandedBody( workEntry: TimelineWorkEntry, workspaceRoot: string | undefined, ): string | null { - const command = workEntry.rawCommand?.trim() || workEntry.command?.trim(); const blocks: string[] = []; - if (command) { - blocks.push(command); - } if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); } - const detail = workEntry.detail?.trim(); - if (detail && detail !== command) { - blocks.push(detail); + const raw = workEntryRawCommand(workEntry); + if (raw?.trim()) { + blocks.push(raw.trim()); + } else if (workEntry.command?.trim()) { + blocks.push(workEntry.command.trim()); + } + if (workEntry.detail?.trim()) { + blocks.push(workEntry.detail.trim()); } const changedFiles = workEntry.changedFiles ?? []; if (changedFiles.length > 0) { @@ -2389,88 +2180,71 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time : "working" : failed > 0 ? `${failed} failed` - : "Completed"; + : "✓ completed"; return ( -
-
-
- - - {lead} - {workflowName ? ( - - {workflowName} - - ) : null} - - {status} - {totalTokens > 0 ? ( - - Σ {formatSubagentTokenCount(totalTokens)} - - ) : null} - -
- -
-
+ ); }); const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; - isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; + const { workEntry, workspaceRoot } = props; // Before any hooks: spawn CTA rows render their own component. if (workEntry.agentSpawn) { return ; } - return ( - - ); + return ; }); const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; - isExpandedToolGroupEntry: boolean; }) { - const { workEntry, workspaceRoot, isExpandedToolGroupEntry } = props; + const { workEntry, workspaceRoot } = props; + const activity = use(TimelineRowActivityCtx); const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; - const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); - const entryIconName = - showWarningIndicator || showFailedIndicator ? "x" : workEntryIconName(workEntry); - const isCommandEntry = - workEntry.requestKind === "command" || - workEntry.itemType === "command_execution" || - Boolean(workEntry.command); - const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); + const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); + const heading = toolWorkEntryHeading(workEntry); + const rawPreview = workEntryPreview(workEntry, workspaceRoot); + const preview = + rawPreview && + normalizeCompactToolLabel(rawPreview).toLowerCase() === + normalizeCompactToolLabel(heading).toLowerCase() + ? null + : rawPreview; + const displayText = preview ? `${heading} - ${preview}` : heading; const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const canExpand = expandedBody !== null; + const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); const iconWrapperClass = cn( - "flex size-6 shrink-0 items-center justify-center", - showWarningIndicator || showFailedIndicator + "flex size-5 shrink-0 items-center justify-center", + showWarningIndicator ? "text-destructive" : showDestructiveRowStyle ? "text-destructive" @@ -2482,16 +2256,17 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : workLogEntryIsToolLike(workEntry) - ? "text-secondary-label" - : "text-foreground/80"; - const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; + : "font-medium text-foreground"; + const turnSettled = !activity.activeTurnInProgress; + const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); + const showSuccessIndicator = + workEntryIndicatesToolSuccess(workEntry) || + (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); const rowToggleProps = canExpand ? { role: "button" as const, tabIndex: 0 as const, "aria-label": displayText, - "aria-expanded": expanded, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { @@ -2505,50 +2280,94 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { return (
- {showEntryIcon ? ( - - - - ) : null} + + +
-

+ {heading} + {preview && ( + {preview} )} - > - {displayText}

+
+ + {canExpand ? ( + + ) : null} + + + {showFailedIndicator ? ( + + + } + > + + + Failed + + ) : showSuccessIndicator ? ( + + } + > + + + + + Completed + + ) : showNeutralIndicator ? ( + + } + > + + + Empty + + ) : null} + +
{expanded && canExpand && expandedBody ? (
-
+          
             {expandedBody}
           
diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index c2fa204ffbc..6f281558ff8 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -1,7 +1,6 @@ import { Maximize2Icon, Minimize2Icon, PanelBottomIcon, PanelRightIcon } from "lucide-react"; import { memo } from "react"; -import { cn } from "../../lib/utils"; import { Toggle } from "../ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -13,7 +12,6 @@ interface PanelLayoutControlsProps { rightPanelAvailable: boolean; rightPanelOpen: boolean; rightPanelShortcutLabel: string | null; - rightPanelUnavailableLabel?: string; /** Running + waiting subagents in this thread; badges the right panel toggle. */ liveAgentCount: number; onToggleTerminal: () => void; @@ -28,7 +26,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ rightPanelAvailable, rightPanelOpen, rightPanelShortcutLabel, - rightPanelUnavailableLabel = "Right panel is unavailable", liveAgentCount, onToggleTerminal, onToggleRightPanel, @@ -43,7 +40,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ - + {liveAgentCount > 0 ? (
@@ -122,7 +114,7 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl svg]:block"; +export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; -export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = - "block self-center truncate leading-none select-none"; +export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; -export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; +// The skill label is smaller than the surrounding prompt text; offset its +// glyphs without moving the pill box or changing the editor's line height. +export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`; export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = - "inline-flex h-[1.41em] max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] font-medium text-[0.86em] leading-none text-fuchsia-700 align-middle dark:text-fuchsia-300"; + "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; export const SKILL_CHIP_ICON_SVG = ``; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 7457d04d2c9..2f4e84dc3fd 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -25,7 +25,6 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, HammerIcon, - LayersIcon, MessageCircleQuestionIcon, MessageSquareIcon, LinkIcon, @@ -51,7 +50,6 @@ import { import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -62,7 +60,6 @@ import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; -import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { @@ -77,7 +74,6 @@ import { import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; import { Menu, MenuItem, @@ -120,7 +116,6 @@ import { } from "./pullRequestProjectAssignment.logic"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; import { - PullRequestActorAvatar, PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, @@ -354,6 +349,7 @@ export function PullRequestDetailPanel({ onClose, onStateChange, context = "page", + chromeVariant = "full", composerDraftTarget, }: { environmentId: EnvironmentId; @@ -385,6 +381,12 @@ export function PullRequestDetailPanel({ * again is at best a no-op and at worst git refusing a branch two checkouts. */ context?: "page" | "thread"; + /** + * How the metadata above the content behaves: `full` keeps every row pinned; `collapse` + * folds the whole of it into the top row once the active tab scrolls, and unfolds at the + * top — the chrome spends its height on what is being read. + */ + chromeVariant?: "full" | "collapse"; /** * The open thread's composer. Beside the thread whose own pull request this is, hand-offs * land here instead of opening a new thread — the branch is already under the reader's feet. @@ -421,13 +423,26 @@ export function PullRequestDetailPanel({ ); }, [tab]); const [chromeCondensed, setChromeCondensed] = useState(false); + // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll + // events, so the capture handler always writes the active tab's entry — and a tab switch + // reads the destination's memory instead of inheriting the tab being left. A tab too short + // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome + // it has no scrollbar to reopen. const chromeStateByTab = useRef>>({}); useEffect(() => { setChromeCondensed(chromeStateByTab.current[tab] ?? false); }, [tab]); - const condensed = chromeCondensed; + const condensed = chromeVariant === "collapse" && chromeCondensed; + // Collapsing removes the fold's height from the chrome, which would otherwise hand that + // height to the scrollport and leap the content up by it mid-scroll. The cure is exact + // compensation: collapse only once the reader has scrolled at least the fold's height, + // then give that height back to `scrollTop` before the next paint — the content under + // their eyes does not move, and the collapse itself is the only thing that changes. const scrollerRef = useRef(null); const foldRef = useRef(null); + // The condensed chrome's second row opens as the fold closes, so the height the scrollport + // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight` + // through a zero track reads its natural height in either state. const condensedRowRef = useRef(null); const compensationRef = useRef(null); useLayoutEffect(() => { @@ -448,6 +463,7 @@ export function PullRequestDetailPanel({ target: "branch name", timeout: 1600, }); + // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be // clicked, so a reader who does click it lands on a chunk already in the module cache. useEffect(() => { @@ -486,30 +502,6 @@ export function PullRequestDetailPanel({ }, [activity, coreDetail], ); - const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); - const baseBranchRefQuery = useEnvironmentQuery( - detail === null - ? null - : vcsEnvironment.listRefs({ - environmentId, - input: { - cwd: detail.workspaceRoot, - query: detail.baseBranch, - includeMatchingRemoteRefs: true, - limit: 20, - }, - }), - ); - const matchingBaseBranchRefs = - detail === null - ? [] - : (baseBranchRefQuery.data?.refs.filter( - (refName) => - refName.name === detail.baseBranch || refName.name.endsWith(`/${detail.baseBranch}`), - ) ?? []); - const isStackedPullRequest = - matchingBaseBranchRefs.length > 0 && - !matchingBaseBranchRefs.some((refName) => refName.isDefault); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { @@ -1027,62 +1019,54 @@ export function PullRequestDetailPanel({ const can = (action: PullRequestAction) => detail?.capabilities.actions.includes(action) === true && detail.viewerPermissions.actions.includes(action); - // One live action holds the slot. Conflicts take priority because every other completion action - // depends on resolving them first, even for a reader who cannot merge on the host themselves. + // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes + // to the thing that would help instead of a Merge button that only ever says no. const primaryAction = detail === null || detail.state !== "open" ? null - : conflicting - ? "resolve" - : detail.isDraft && can("ready") - ? "ready" - : !can("merge") - ? null + : detail.isDraft && can("ready") + ? "ready" + : !can("merge") + ? null + : conflicting + ? "resolve" : allowedMergeMethods.length > 0 ? "merge" : null; // The pull request number carries this state in the overview and the right-panel tab mirrors - // it. The conflict action is separate from this state: an open pull request remains green. + // it. Conflicts keep their own row below: an open pull request remains green there. const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; const checksState = detail ? pullRequestChecksState(detail.checks) : null; - if (detailQuery.isPending && !detail) { - return ; - } - return (
+ {/* The top row's geometry never changes: both of its states occupy the same stacked + cell and crossfade, so the actions on the right have one home whatever the chrome + is doing below. The fold and this fade share one 200ms clock. */}
-
+ {/* The fixed height lives on the two top-row cells — not the grid, whose later rows + are the fold — so the actions have one immovable home in both states. */} +
{detail && statePresentation ? ( <> - {repositoryUrl ? ( - - ) : ( - - {detail.repository} - - )} + + {detail.repository} + -

+ {detail.title} -

+ + {conflicting ? ( + + + Conflicts + + ) : checksSummary ? ( + + {detail && checksState !== null ? ( + + ) : null} + {checksSummary} + + ) : null} ) : null}
-
+
{detail ? ( <> @@ -1140,7 +1140,7 @@ export function PullRequestDetailPanel({ render={ } /> @@ -1366,22 +1367,7 @@ export function PullRequestDetailPanel({ Auto-merge ) : null} - {primaryAction === "resolve" ? ( - - } - > - - {handoff === "conflicts" ? "Preparing..." : "Resolve conflicts"} - - ) : primaryAction === "ready" ? ( + {primaryAction === "ready" ? ( @@ -1408,86 +1394,113 @@ export function PullRequestDetailPanel({ ) : null}
-
+ {/* The condensed chrome's second row: the tabs that the closing fold takes with it, + and compact copies of the branch pair and diff stat so they stay in sight while + the full rows are folded away. Same zero-track mechanism as the fold, inverted. */} +
{detail ? ( -
-
- - - {detail.author?.login ?? "ghost"} - {formatRelativeTimeLabel(detail.updatedAt)} - - - - - {isStackedPullRequest ? ( - - ) : null} - {detail.baseBranch} - - {freshness ? ( - void perform("update-branch", undefined, method)} - iconClassName="size-3" - /> - ) : null} - - {detail.headBranch} - - - + + + {detail.baseBranch} + {freshness ? ( + void perform("update-branch", undefined, method)} + iconClassName="size-3" /> + ) : null} + + {detail.headBranch} + + + + + {detail.changedFiles.toLocaleString()} -
+ +
) : null}
-
+ {/* Folding is a grid track going to zero: the rows below stay mounted, the track + animates closed over them, and `inert` takes the hidden controls out of the tab + order for as long as the chrome is condensed. */} +
{detail ? ( -
+
{titleDraft === null ? (

@@ -1554,56 +1567,47 @@ export function PullRequestDetailPanel({
- - - {isStackedPullRequest ? ( - - ) : null} - {detail.baseBranch} - - {freshness ? ( - void perform("update-branch", undefined, method)} - /> - ) : null} - + {freshness ? ( + void perform("update-branch", undefined, method)} /> - - + {detail.headBranch} + + + @@ -1619,114 +1623,147 @@ export function PullRequestDetailPanel({

) : null} -
-
- {detail ? ( - - ) : null} + + {detail ? ( + + ) : null} +
+
{ + if (chromeVariant !== "collapse") return; const scroller = event.target as HTMLElement; scrollerRef.current = scroller; const top = scroller.scrollTop; setChromeCondensed((previous) => { let next = previous; + // `scrollHeight` reads the fold's natural height whichever state the track is in. const foldHeight = foldRef.current?.scrollHeight ?? 0; + // The chrome trades the fold for the condensed second row, so the height the + // scrollport actually gains is the difference between the two. const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); if (previous) { // The hard top reopens the chrome with no refund: the reader asked for the top, @@ -1744,7 +1781,17 @@ export function PullRequestDetailPanel({ }); }} > - {detailQuery.error && !detail ? ( + {detailQuery.isPending && !detail ? ( + // The ghost wears the shape of the tab being waited on, so switching tabs mid-load + // does not flash a summary outline under a timeline heading. + tab === "timeline" ? ( + + ) : tab === "code" ? ( + + ) : ( + + ) + ) : detailQuery.error && !detail ? ( ) : detail ? ( <> diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 38a3ab70d64..09b79cf340e 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -45,11 +45,13 @@ export function PullRequestListGhost({
- +
- +
))} @@ -57,101 +59,32 @@ export function PullRequestListGhost({ ); } -/** - * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description - * boundaries in the ghost prevents the loaded pull request from replacing one layout with - * another a moment later. - */ +/** The summary's own shape: a title, a byline, the facts rows, the description. */ export function PullRequestDetailGhost() { return (
-
-
-
- - -
-
- - -
-
- -
- -
- - -
-
- - - -
- - -
-
-
- -
-
- - - -
- -
+
+ +
- -
-
-
-
- - -
-
- - - -
-
-
-
- - -
-
- - -
-
-
-
- - -
- -
-
- -
-
- +
+ {Array.from({ length: 4 }, (_, index) => ( +
+ +
-
- - - - -
-
+ ))} +
+
+ + + +
); @@ -180,7 +113,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
- +
))}
@@ -201,8 +134,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
- - + +
))} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 04fee465b50..3066eafc38a 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -25,7 +25,6 @@ import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; -import { Button } from "../ui/button"; import { Menu, @@ -262,14 +261,12 @@ export function PullRequestFiltersMenu({ return ( - } + className={cn( + // The icon-button size that pairs with a full-height input, so the two read as one strip. + "relative inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-input text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground sm:size-8", + filtered && "text-foreground", + )} + aria-label="Filter pull requests" > {filtered ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 29566e048d1..a57f2a4d160 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -196,12 +196,12 @@ function MetaRow({ children: ReactNode; }) { return ( -
- +
+ {icon} {label} - {children} + {children}
); } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 9c36d32ff51..a472c6a8d3d 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -992,7 +992,7 @@ export function DiagnosticsSettingsPanel() { : false; return ( - + +
- - - + {!isElectron && ( +
+ +
+ )} + {isElectron && ( +
+ +
+ )}
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 1618f5045eb..174c9e9fe97 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { ArchiveIcon, + ArrowLeftIcon, BotIcon, GitBranchIcon, KeyboardIcon, @@ -18,7 +19,7 @@ import { Settings2Icon, XIcon, } from "lucide-react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -33,7 +34,6 @@ import { useSidebar, } from "../ui/sidebar"; import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; -import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { searchSettings, @@ -72,6 +72,7 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); const currentHash = useLocation({ select: (location) => location.hash }); + const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); @@ -175,6 +176,17 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { }, [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results], ); + const handleBackClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + if (canGoBack) { + window.history.back(); + return; + } + void navigate({ to: "/" }); + }, [canGoBack, isMobile, navigate, setOpenMobile]); + return ( <> @@ -284,7 +296,14 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
- + + + + + Back + + +
diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 5399d071be9..7e4b80d1951 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -768,7 +768,7 @@ export function ThemeLibrary({
{STANDARD_THEME_CARDS.map((standardTheme) => ( location.hash }); @@ -250,12 +247,12 @@ export function SettingsPageContainer({ return (
- +
{children} - +
); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 8fc6b835bf1..f4a98dec86c 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -4,9 +4,8 @@ import { GitPullRequestIcon, SettingsIcon, } from "lucide-react"; -import type { ReactNode } from "react"; import { memo, useCallback } from "react"; -import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -118,44 +117,16 @@ function T3Wordmark() { ); } -function SidebarUtilityItem({ - icon, - label, - onClick, -}: { - icon: ReactNode; - label: string; - onClick: () => void; -}) { - return ( - - - - {icon} - - } - /> - {label} - - - ); -} - -export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); - const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile } = useSidebar(); const currentFooterPage = useLocation({ select: (location) => - /^\/settings(?:\/|$)/.test(location.pathname) - ? "settings" - : location.pathname === "/usage" - ? "usage" - : location.pathname === "/pull-requests" - ? "pull-requests" - : null, + location.pathname === "/usage" + ? "usage" + : location.pathname === "/pull-requests" + ? "pull-requests" + : null, }); const { environments } = useEnvironments(); // The page reads every connected server, so one of them offering pull requests is enough for @@ -186,54 +157,73 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const handleBackClick = useCallback(() => { closeMobileSidebar(); - if (canGoBack) { - window.history.back(); - return; - } void navigate({ to: "/" }); - }, [canGoBack, closeMobileSidebar, navigate]); - - return ( - - {currentFooterPage ? ( - - - - Back - - - ) : ( - <> - } - label="Settings" - onClick={handleSettingsClick} - /> - {pullRequestsSupported ? ( - } - label="Pull Requests" - onClick={handlePullRequestsClick} - /> - ) : null} - } - label="Usage" - onClick={handleUsageClick} - /> - - )} - - - ); -}); + }, [closeMobileSidebar, navigate]); -export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - + + {currentFooterPage ? ( + + + + Back + + + ) : ( + <> + + + + + + } + /> + Settings + + + {pullRequestsSupported ? ( + + + + + + } + /> + Pull Requests + + + ) : null} + + + + + + } + /> + Usage + + + + )} + + ); }); diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 477bc9c0263..93dc653e7c0 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -19,12 +19,6 @@ function ids(state: ThreadActionMenuState): string[] { return buildThreadActionMenuItems(state).map((item) => item.id); } -function allIds(state: ThreadActionMenuState): string[] { - const flatten = (items: ReturnType): string[] => - items.flatMap((item) => [item.id, ...(item.children ? flatten(item.children) : [])]); - return flatten(buildThreadActionMenuItems(state)); -} - describe("buildThreadActionMenuItems", () => { it("hides lifecycle items when the environment lacks the capabilities", () => { expect( @@ -32,15 +26,15 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy", "delete"]); + ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); }); it("includes branch items only for threads with a branch", () => { - const withBranch = allIds({ ...baseState, branch: "feat/menu" }); + const withBranch = ids({ ...baseState, branch: "feat/menu" }); expect(withBranch).toContain("new-thread-on-branch"); expect(withBranch).toContain("copy-branch"); - expect(allIds(baseState)).not.toContain("new-thread-on-branch"); - expect(allIds(baseState)).not.toContain("copy-branch"); + expect(ids(baseState)).not.toContain("new-thread-on-branch"); + expect(ids(baseState)).not.toContain("copy-branch"); }); it("flips lifecycle labels with thread state", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 1218e2dd58c..ef4b38dcdac 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -18,7 +18,6 @@ export type ThreadActionMenuId = | "rename" | "regenerate-title" | "mark-unread" - | "copy" | "copy-path" | "copy-branch" | "copy-thread-id" @@ -54,15 +53,14 @@ export function buildThreadActionMenuItems( { id: "new-thread-on-branch" as const, label: `New thread on ${state.branch}`, - icon: "message-square-plus", }, ] : []), ...(state.supports.pinning ? [ state.isPinned - ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" } - : { id: "pin" as const, label: "Pin thread", icon: "pin" }, + ? { id: "unpin" as const, label: "Unpin thread" } + : { id: "pin" as const, label: "Pin thread" }, ] : []), // Both lifecycle actions stay available on pinned threads: settling @@ -71,18 +69,17 @@ export function buildThreadActionMenuItems( ...(state.supports.settlement ? [ state.isSettled - ? { id: "unsettle" as const, label: "Un-settle thread", icon: "circle-check" } - : { id: "settle" as const, label: "Settle thread", icon: "circle-check" }, + ? { id: "unsettle" as const, label: "Un-settle thread" } + : { id: "settle" as const, label: "Settle thread" }, ] : []), ...(state.supports.snooze ? [ state.isSnoozed - ? { id: "unsnooze" as const, label: "Wake thread", icon: "clock" } + ? { id: "unsnooze" as const, label: "Wake thread" } : { id: "snooze" as const, label: "Snooze", - icon: "clock", disabled: !state.canSnoozeNow, children: state.snoozePresets.map((preset) => ({ id: `snooze:${preset.id}` as const, @@ -91,37 +88,20 @@ export function buildThreadActionMenuItems( }, ] : []), - { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, + { id: "rename", label: "Rename thread" }, ...(state.supports.titleRegeneration ? [ { id: "regenerate-title" as const, label: state.isRegeneratingTitle ? "Regenerating…" : "Regenerate title", - icon: "refresh-cw", disabled: state.isRegeneratingTitle, }, ] : []), - { id: "mark-unread", label: "Mark unread", icon: "mail-open" }, - { - id: "copy", - label: "Copy", - icon: "copy", - separatorBefore: true, - children: [ - { id: "copy-path", label: "Path", icon: "folder" }, - ...(state.branch - ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] - : []), - { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, - ], - }, - { - id: "delete", - label: "Delete", - destructive: true, - icon: "trash", - separatorBefore: true, - }, + { id: "mark-unread", label: "Mark unread" }, + { id: "copy-path", label: "Copy path", icon: "copy" }, + ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), + { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/components/ui/segmented-tabs.tsx b/apps/web/src/components/ui/segmented-tabs.tsx deleted file mode 100644 index 29b91e18bb4..00000000000 --- a/apps/web/src/components/ui/segmented-tabs.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { ComponentProps, HTMLAttributes } from "react"; - -import { cn } from "~/lib/utils"; -import { Toggle } from "~/components/ui/toggle"; - -function SegmentedTabList({ className, ...props }: HTMLAttributes) { - return ( -
- ); -} - -function SegmentedTab({ - selected, - density = "default", - className, - ...props -}: { - selected: boolean; - density?: "default" | "compact"; -} & Omit, "aria-pressed" | "pressed" | "size" | "type" | "variant">) { - return ( - - ); -} - -export { SegmentedTab, SegmentedTabList }; diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 7173eab140e..5bf04adf41a 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -18,10 +18,6 @@ const toggleVariants = cva( "h-7 min-w-7 rounded-md px-[calc(--spacing(1)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", - segmented: - "h-6 min-w-0 rounded-md px-2.5 text-xs before:rounded-[calc(var(--radius-md)-1px)]", - "segmented-compact": - "h-5 min-w-0 rounded-md px-2 text-[11px] before:rounded-[calc(var(--radius-md)-1px)]", sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", }, @@ -31,8 +27,6 @@ const toggleVariants = cva( "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100", outline: "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", - segmented: - "border-transparent text-muted-foreground shadow-none transition-colors before:shadow-none hover:bg-accent/45 hover:text-foreground data-pressed:bg-accent data-pressed:text-foreground data-pressed:shadow-xs/5", }, }, }, diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index b9bebadc00e..7a5cdd883db 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -19,22 +19,13 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; +import { Button } from "../ui/button"; import { SidebarInset } from "../ui/sidebar"; -import { SegmentedTab, SegmentedTabList } from "../ui/segmented-tabs"; -import { - WorkspaceBreadcrumb, - WorkspaceBreadcrumbItem, - WorkspaceBreadcrumbSeparator, -} from "../WorkspaceBreadcrumb"; -import { - WorkspacePageContainer, - WorkspacePageHeader, - WorkspacePageHeaderEdgeControl, -} from "../WorkspacePageContainer"; -import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; -import { PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; +import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; +import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, @@ -75,6 +66,21 @@ export function UsagePage() { [isPast24Hours, merged.daily, merged.hourly], ); + // Ranked by whatever the toggle is showing, so the bars always descend. + const orderedProviders = useMemo( + () => + merged.providers.toSorted((a, b) => + metric === "cost" ? b.costUsd - a.costUsd : b.totalTokens - a.totalTokens, + ), + [merged.providers, metric], + ); + + const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter( + (period) => period.totalTokens > 0, + ).length; + const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods; + const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; + const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; const selectWindow = (days: number) => { setWindowSelection({ days, @@ -94,66 +100,78 @@ export function UsagePage() { setWindowSelection({ days: windowDays, window: nextWindow }); } }; - const windowLabel = - isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined - ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` - : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`; - const topbarContent = ( -
- - -

Usage

-
- - - {windowLabel} - -
-
- - {(["cost", "tokens"] as const).map((option) => ( - setMetric(option)} - > - {option === "cost" ? "Cost" : "Tokens"} - - ))} - - - {WINDOW_OPTIONS.map((option) => ( - selectWindow(option.days)} - > - {option.label} - - ))} - - - - -
-
- ); return (
- - {topbarContent} - + {!isElectron && ( +
+ + Usage + +
+ )} + + {isElectron && ( +
+ + Usage + +
+ )} - +
+
+

+ {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined + ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` + : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`} +

+
+
+ {WINDOW_OPTIONS.map((option) => ( + + ))} +
+ +
+
+ {settling ? ( <> {environments.length > 1 ? : null} - + ) : ( <> @@ -163,62 +181,88 @@ export function UsagePage() { staleEnvironments={merged.staleEnvironments} /> -
-
+ {/* Cost first: the financial answer, then the provider split. */} +
+ {/* The summary follows the chart toggle, so the headline and the + series are always reading the same units. */} +
+ + {metric === "cost" ? "Raw token cost" : "Processed tokens"} + {metric === "cost" - ? formatUsd(merged.costUsd) + ? `${formatUsd(merged.costUsd)}*` : formatTokens(merged.totalTokens)} {metric === "cost" - ? `${formatCount(merged.sessions)} sessions · API estimate` - : `${formatCount(merged.sessions)} sessions`} + ? "* if billed at full API rate" + : `Input, cache reads and output across ${formatCount(merged.sessions)} sessions.`}
- {PROVIDER_ORDER.map((provider) => { - const totals = merged.providers.find((entry) => entry.provider === provider); - const share = - metric === "cost" ? (totals?.costShare ?? 0) : (totals?.tokenShare ?? 0); - const providerSessions = totals?.sessions ?? 0; - const sessionLabel = `${formatCount(providerSessions)} ${ - providerSessions === 1 ? "session" : "sessions" - }`; + {orderedProviders.map((provider) => { + const share = metric === "cost" ? provider.costShare : provider.tokenShare; return ( -
-
- - - - {PROVIDER_LABEL[provider]} - - {sessionLabel} - - +
+
+ + + {PROVIDER_LABEL[provider.provider]} - + {metric === "cost" - ? formatUsd(totals?.costUsd ?? 0) - : formatTokens(totals?.totalTokens ?? 0)} + ? formatUsd(provider.costUsd) + : formatTokens(provider.totalTokens)}
+
+
+
{metric === "cost" - ? `${formatPercent(share)} of cost · ${formatTokens(totals?.totalTokens ?? 0)} tokens` - : `${formatPercent(share)} of tokens · ${formatUsd(totals?.costUsd ?? 0)}`} + ? `${formatPercent(share)} of cost · ${formatTokens(provider.totalTokens)} tokens` + : `${formatPercent(share)} of tokens · ${formatUsd(provider.costUsd)}`}
); })}
-
-

- {isPast24Hours ? "Hourly" : "Daily"}{" "} - {metric === "tokens" ? "processed tokens" : "cost"} -

+
+
+

+ {isPast24Hours ? "Hourly" : "Daily"}{" "} + {metric === "tokens" ? "processed tokens" : "cost"} +

+
+
+ {(["cost", "tokens"] as const).map((option) => ( + + ))} +
+ +
+
-
-

Totals

-
- - - - - -
+
+ + + + + 0 + ? `${(merged.costQuality.cacheSavingsUsd / merged.costUsd).toFixed(1)}x the raw token cost` + : "vs full input rates" + } + />

Breakdown

- +
{( [ - { value: "model", label: "Model" }, - { value: "time", label: isPast24Hours ? "Hour" : "Day" }, + { value: "model", label: "model" }, + { value: "time", label: isPast24Hours ? "hour" : "day" }, ] as const ).map((option) => ( - setBreakdown(option.value)} + className={cn( + "cursor-pointer px-2.5 py-1 text-[10px] tracking-wide uppercase", + option.value === breakdown + ? "bg-muted text-foreground" + : "text-muted-foreground hover:text-foreground", + )} > {option.label} - + ))} - +
{breakdown === "model" ? ( @@ -291,7 +356,7 @@ export function UsagePage() { merged.models.map((model) => ( @@ -338,7 +403,7 @@ export function UsagePage() { recentPeriods.map((period) => ( {"hourStart" in period @@ -368,7 +433,7 @@ export function UsagePage() {
)} - +
@@ -387,11 +452,20 @@ function ProviderMark({ return ; } -function Metric({ label, value }: { readonly label: string; readonly value: string }) { +function Metric({ + label, + value, + detail, +}: { + readonly label: string; + readonly value: string; + readonly detail: string; +}) { return ( -
+
{label} - {value} + {value} + {detail}
); } @@ -495,51 +569,70 @@ function UsageDeviceStrip({ ); } +/** Deterministic bar heights (each unique: they double as keys). */ +const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44, 67]; + /** - * Static stand-in with the loaded page's shape. No shimmer; blocks fill in - * exactly once when the last device answers. + * Static stand-in with the loaded page's shape: headline, provider split, + * chart and metrics strip. No shimmer; blocks fill in exactly once when the + * last device answers. */ -function UsageSkeleton() { +function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { return ( <> -
+
-
-
+ + Raw token cost + +
+
+ {PROVIDER_ORDER.map((provider) => ( -
-
- +
+
+ -
+ {PROVIDER_LABEL[provider]}
+
))}
-
-
+

+ {resolution === "hour" ? "Hourly" : "Daily"} cost +

+ {/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a + relayout when the real chart swaps in. */} +
+ {SKELETON_BAR_HEIGHTS.map((height) => ( +
+ ))} +
-
-

Totals

-
- {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( - (label) => ( -
- {label} -
-
- ), - )} -
+
+ {["Processed tokens", "Cached input", "Uncached input", "Output", "Cache savings"].map( + (label) => ( +
+ {label} +
+
+
+ ), + )}
); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 963c28fe6a0..f41945bfe28 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,5 +1,5 @@ import type { UsageProviderKind } from "@t3tools/contracts"; -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; import { @@ -68,7 +68,13 @@ function buildPeriodColumns( }); } -/** Shape-preserving cubic tangents that cannot overshoot spiky usage data. */ +/** + * Monotone cubic tangents (Fritsch-Carlson). + * + * Plain cubic smoothing overshoots on spiky daily data and would dip the area + * below zero between points, which reads as negative spend. This variant is + * shape-preserving, so a smoothed series never leaves the range of its samples. + */ function monotoneTangents(points: readonly Point[]): readonly number[] { const count = points.length; if (count < 2) return [0]; @@ -109,6 +115,7 @@ function monotoneTangents(points: readonly Point[]): readonly number[] { return tangents; } +/** One cubic segment of a smoothed boundary. */ interface CurveSegment { readonly from: Point; readonly c1: Point; @@ -116,6 +123,7 @@ interface CurveSegment { readonly to: Point; } +/** Smoothed polyline through `points`, as explicit cubic control points. */ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { if (points.length < 2) return []; const tangents = monotoneTangents(points); @@ -136,10 +144,10 @@ function smoothCurve(points: readonly Point[]): readonly CurveSegment[] { return segments; } -function curvePath(segments: readonly CurveSegment[]): string { +function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { const first = segments[0]; if (first === undefined) return ""; - let path = `M${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; + let path = `${startCommand}${first.from.x.toFixed(2)},${first.from.y.toFixed(2)}`; for (const segment of segments) { path += ` C${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.to.x.toFixed(2)},${segment.to.y.toFixed(2)}`; } @@ -171,8 +179,10 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re /** * Turns the merged daily totals into one column per day. * - * Values are absolute, not cumulative: each provider is drawn from the same - * zero baseline so the chart never implies that one provider is always larger. + * Values are absolute, not cumulative: the series are layered from a shared + * zero baseline rather than stacked. A stacked chart puts whichever provider is + * drawn last permanently above the other, which reads as "that one is bigger" + * even on days where it is not. * * The chart paths and the hover readout both consume this, so the number under * the cursor is by construction the number that was plotted rather than a @@ -206,39 +216,42 @@ export function UsageProviderChart({ ); const [hoverIndex, setHoverIndex] = useState(null); const plotRef = useRef(null); - const tooltipRef = useRef(null); - const hoverPositionRef = useRef<{ x: number; y: number } | null>(null); - const { paths, series, stepX, ticks, toY } = useMemo(() => { + const { paths, ticks, stepX, toY, series } = useMemo(() => { if (periods.length === 0) { return { paths: [], - series: [] as readonly DayColumn[], - stepX: 0, ticks: [0] as readonly number[], + stepX: 0, toY: () => VIEW_HEIGHT, + series: [] as readonly DayColumn[], }; } const columns = buildPeriodColumns(periods, byPeriod, metric); + + // The scale tops out at the largest single provider-day, not the largest + // sum: layered series each measure from zero, so a combined peak would + // leave the plot permanently half empty. const peak = columns.reduce( (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), 0, ); const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); + // Reserve a sliver above the top gridline so the series stroke, which is + // drawn at constant screen width, is not shaved off at a peak. const toY = (value: number) => max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); const built = PROVIDER_ORDER.map((provider, providerIndex) => { - const line = curvePath( - smoothCurve( - columns.map((column, periodIndex) => ({ - x: periodIndex * step, - y: toY(column.bands[providerIndex]?.value ?? 0), - })), - ), + const curve = smoothCurve( + columns.map((column, dayIndex) => ({ + x: dayIndex * step, + y: toY(column.bands[providerIndex]?.value ?? 0), + })), ); + const line = curvePath(curve, "M"); return { provider, total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), @@ -247,65 +260,30 @@ export function UsageProviderChart({ }; }); - return { - paths: built.toSorted((a, b) => b.total - a.total), - series: columns, - stepX: step, - ticks: tickValues, - toY, - }; + // Paint the heavier series first so the lighter one is never buried under + // it. The fills are faint enough that the order barely shows, but the + // strokes are drawn in a second pass regardless, so neither can be hidden. + const ordered = [...built].sort((a, b) => b.total - a.total); + + return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; }, [byPeriod, metric, periods]); const format = metric === "tokens" ? formatTokens : formatUsd; - const positionTooltip = useCallback(() => { - const plot = plotRef.current; - const tooltip = tooltipRef.current; - const hoverPosition = hoverPositionRef.current; - if (plot === null || tooltip === null || hoverPosition === null) return; - - const gap = 12; - const tooltipWidth = tooltip.offsetWidth; - const tooltipHeight = tooltip.offsetHeight; - const plotWidth = plot.clientWidth; - const plotHeight = plot.clientHeight; - const preferredLeft = - hoverPosition.x + gap + tooltipWidth <= plotWidth - ? hoverPosition.x + gap - : hoverPosition.x - gap - tooltipWidth; - const preferredTop = - hoverPosition.y + gap + tooltipHeight <= plotHeight - ? hoverPosition.y + gap - : hoverPosition.y - gap - tooltipHeight; - const left = Math.min(Math.max(0, preferredLeft), Math.max(0, plotWidth - tooltipWidth)); - const top = Math.min(Math.max(0, preferredTop), Math.max(0, plotHeight - tooltipHeight)); - plot.style.setProperty("--usage-tooltip-left", `${left}px`); - plot.style.setProperty("--usage-tooltip-top", `${top}px`); - }, []); - - useLayoutEffect(() => { - if (hoverIndex !== null) positionTooltip(); - }, [hoverIndex, positionTooltip]); - const handleMove = useCallback( (event: React.MouseEvent) => { - const plot = plotRef.current; - if (plot === null || periods.length === 0) return; - const bounds = plot.getBoundingClientRect(); - if (bounds.width === 0) return; - const localX = Math.min(bounds.width, Math.max(0, event.clientX - bounds.left)); - const localY = Math.min(bounds.height, Math.max(0, event.clientY - bounds.top)); - const fraction = localX / bounds.width; + const bounds = plotRef.current?.getBoundingClientRect(); + if (bounds === undefined || bounds.width === 0 || periods.length === 0) return; + const fraction = (event.clientX - bounds.left) / bounds.width; const index = Math.round(fraction * (periods.length - 1)); - hoverPositionRef.current = { x: localX, y: localY }; - positionTooltip(); setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); }, - [periods.length, positionTooltip], + [periods.length], ); const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; + const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100; const formatPeriod = (period: string) => resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); const formatTooltipPeriod = (period: string) => @@ -333,10 +311,7 @@ export function UsageProviderChart({ ref={plotRef} className="relative h-56 flex-1" onMouseMove={handleMove} - onMouseLeave={() => { - hoverPositionRef.current = null; - setHoverIndex(null); - }} + onMouseLeave={() => setHoverIndex(null)} > ( ))} @@ -392,11 +368,10 @@ export function UsageProviderChart({ {hoveredPeriod === undefined ? null : (
60 ? "translateX(-100%)" : "translateX(0)", }} >
{formatTooltipPeriod(hoveredPeriod)}
@@ -443,3 +418,21 @@ export function UsageProviderChart({
); } + +export function UsageChartLegend() { + return ( +
+ {PROVIDER_ORDER.map((provider) => { + // The marks carry the same fills as the bands, so they key the chart + // just as a colour swatch would. + const Mark = PROVIDER_MARK[provider]; + return ( + + + {PROVIDER_LABEL[provider]} + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 3ec17185902..f8b65877dcf 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -3,7 +3,9 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, type Icon, OpenAI } from "../Icons"; /** - * Stable provider reading order across summaries, tables, and hover rows. + * Series and table order. The chart layers both providers from a shared zero + * baseline, so this only fixes the reading order of legends, tables and hover + * rows; it does not decide which series sits above the other. */ export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 4bc3237d2a6..769826e3999 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -4,15 +4,6 @@ const SVG_NS = "http://www.w3.org/2000/svg"; // Inline Lucide-style icon paths (stroke-based, viewBox 0 0 24 24, strokeWidth 2). const ICON_PATHS: Record }>> = { - "chevron-right": [{ tag: "path", attrs: { d: "m9 19 7-7-7-7" } }], - "circle-check": [ - { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, - { tag: "path", attrs: { d: "m9 12 2 2 4-4" } }, - ], - clock: [ - { tag: "path", attrs: { d: "M12 6v6l4 2" } }, - { tag: "circle", attrs: { cx: "12", cy: "12", r: "10" } }, - ], pencil: [ { tag: "path", @@ -26,71 +17,6 @@ const ICON_PATHS: Record( "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; for (const item of entries) { - if (item.separatorBefore === true && inner.childElementCount > 0) { - const separator = document.createElement("div"); - separator.className = "my-1 h-px bg-border/70"; - separator.style.cssText = - "height:1px;margin:0.25rem 0;background:var(--border);opacity:0.7;"; - separator.dataset.contextMenuSeparator = "true"; - separator.setAttribute("role", "separator"); - inner.appendChild(separator); - } - if (item.header === true) { const header = document.createElement("div"); header.className = "px-2 py-1.5 font-medium text-muted-foreground text-xs"; @@ -331,12 +247,10 @@ export function showContextMenuFallback( button.appendChild(label); if (hasChildren) { - const chevron = createIconElement("chevron-right", "neutral"); - if (chevron) { - chevron.setAttribute("class", "ms-auto size-4 shrink-0 text-muted-foreground/80"); - chevron.dataset.contextMenuChevron = "true"; - button.appendChild(chevron); - } + const chevron = document.createElement("span"); + chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; + chevron.textContent = ">"; + button.appendChild(chevron); } if (!isDisabled) { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index bd49f53702c..4e636eb4ff0 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -241,22 +241,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil opacity: 1; } } - @keyframes live-activity-focus { - 0% { - transform: translateX(0); - } - 100% { - transform: translateX(100%); - } - } - @keyframes live-activity-focus-counter { - 0% { - transform: translateX(0); - } - 100% { - transform: translateX(-100%); - } - } @keyframes status-ping { /* Burst first (immediate feedback for click ripples), then hold invisible for the rest of the cycle. Mirrors animate-ping's @@ -447,62 +431,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } -@utility live-activity-focus { - --live-activity-focus-width: 4.5rem; - - right: auto; - left: calc(-1 * var(--live-activity-focus-width)); - width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width)); - -webkit-mask-image: linear-gradient( - to right, - transparent 0, - rgb(0 0 0 / 12%) 0.675rem, - rgb(0 0 0 / 55%) 1.575rem, - black 2.25rem, - rgb(0 0 0 / 55%) 2.925rem, - rgb(0 0 0 / 12%) 3.825rem, - transparent var(--live-activity-focus-width), - transparent 100% - ); - -webkit-mask-repeat: no-repeat; - mask-image: linear-gradient( - to right, - transparent 0, - rgb(0 0 0 / 12%) 0.675rem, - rgb(0 0 0 / 55%) 1.575rem, - black 2.25rem, - rgb(0 0 0 / 55%) 2.925rem, - rgb(0 0 0 / 12%) 3.825rem, - transparent var(--live-activity-focus-width), - transparent 100% - ); - mask-repeat: no-repeat; - animation: live-activity-focus 2.2s linear infinite; - will-change: transform; - - @media (prefers-reduced-motion: reduce) { - animation: none; - opacity: 0; - will-change: auto; - } -} - -@utility live-activity-focus-counter { - width: 100%; - animation: live-activity-focus-counter 2.2s linear infinite; - will-change: transform; - - @media (prefers-reduced-motion: reduce) { - animation: none; - will-change: auto; - } -} - -@utility live-activity-focus-aligned { - width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width)); - margin-left: var(--live-activity-focus-width); -} - @layer base { :root { /* Keep the original T3 Code artwork palettes as the defaults. Built-in @@ -1421,16 +1349,15 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] { /* The panel layout toggles stay ghost: they render both inside the header and in the titlebar strip, so filling them would make them change appearance as - the panel opens. Their icons use the same themed foreground as the toolbar - action text; hover and pressed keep the base ghost accent. The tooltip - trigger's data-slot wins over the toggle's when it renders the toggle, so - match both. */ + the panel opens. They only take the themed foreground; hover and pressed + keep the base ghost accent. The tooltip trigger's data-slot wins over the + toggle's when the trigger renders the toggle, so match both. */ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { - --control-icon-color: var(--toolbar-control-foreground); - color: var(--toolbar-control-foreground); + --control-icon-color: var(--toolbar-foreground); + color: var(--toolbar-foreground); } html[data-theme-id] [data-chat-header] [data-slot="button"]:hover, diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 5bfb80bfec3..0b7e6bf0f97 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -118,17 +118,6 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu return null; } -/** The repository root behind a recognised change-request URL, without PR-specific state. */ -export function changeRequestRepositoryUrl(targetUrl: string): string | null { - const changeRequest = parseChangeRequestUrl(targetUrl); - if (changeRequest === null) return null; - const url = new URL(targetUrl); - url.pathname = `/${changeRequest.repository}`; - url.search = ""; - url.hash = ""; - return url.toString(); -} - function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { const repository = match?.[1]; const number = Number(match?.[2]); diff --git a/apps/web/src/routes/-chatIndexTitlebar.test.ts b/apps/web/src/routes/-chatIndexTitlebar.test.ts index 0e1fdc9f884..803ba787116 100644 --- a/apps/web/src/routes/-chatIndexTitlebar.test.ts +++ b/apps/web/src/routes/-chatIndexTitlebar.test.ts @@ -15,7 +15,9 @@ describe("hosted static onboarding header", () => { const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); - expect(onboardingHeader).toContain(''); + expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]"); + expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]"); + expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS"); expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); }); }); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 271715be3ca..4f4da0c751e 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -8,7 +8,6 @@ import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset } from "../components/ui/sidebar"; -import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useAllEnvironmentShellsBootstrapped, @@ -18,6 +17,8 @@ import { import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -142,13 +143,18 @@ function HostedStaticOnboardingState() { return (
- +
{APP_DISPLAY_NAME}
- +
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 7d73a225d5a..66d9f0caa5d 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -74,12 +74,6 @@ import { WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../components/WorkspaceBreadcrumb"; -import { - WorkspacePageContainer, - WorkspacePageHeader, - WorkspacePageHeaderEdgeControl, -} from "../components/WorkspacePageContainer"; -import { isElectron } from "../env"; import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; import { Button } from "../components/ui/button"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; @@ -105,6 +99,7 @@ import { import { useAtomCommand } from "../state/use-atom-command"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export interface PullRequestsSearch { readonly involvement: PullRequestInvolvement; @@ -1186,17 +1181,6 @@ function PullRequestsRouteView() { : null, [search.number, search.repository, selectedProject], ); - const linkedSelectionMatchesSurface = - linkedSelection !== null && - selectedPullRequestSurface !== null && - linkedSelection.environmentId === selectedPullRequestSurface.environmentId && - linkedSelection.projectId === selectedPullRequestSurface.projectId && - linkedSelection.repository === selectedPullRequestSurface.repository && - linkedSelection.number === selectedPullRequestSurface.number; - // A closed panel keeps its tabs so reopening does not discard work. Those retained tabs are - // history, though, not a current selection: without this check they leave the toggle looking - // available after the selected pull request has been cleared. - const rightPanelAvailable = activePullRequestSurface !== null || linkedSelectionMatchesSurface; useEffect(() => { if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); @@ -1310,10 +1294,9 @@ function PullRequestsRouteView() { terminalAvailable={false} terminalOpen={false} terminalShortcutLabel={null} - rightPanelAvailable={rightPanelAvailable} + rightPanelAvailable={rightPanelState.surfaces.length > 0} rightPanelOpen={rightPanelState.isOpen} rightPanelShortcutLabel={null} - rightPanelUnavailableLabel="Select a pull request first" liveAgentCount={0} onToggleTerminal={() => undefined} onToggleRightPanel={toggleRightPanel} @@ -1620,6 +1603,7 @@ function PullRequestsRouteView() { reviewingQuery.refresh(); }} onStateChange={handlePullRequestTabStatusChange} + chromeVariant="collapse" /> ) : null} @@ -1843,10 +1827,18 @@ function PullRequestsColumn({ // Painted flat like the chat column: the inset underneath carries the chrome grain, and a // content surface that lets it show reads as a different background than every thread.
- {/* A closed right panel leaves this column full-width, so the shared header - reserves native window controls. While the panel is open, the column ends - at the panel and the absolute controls strip owns the top-right corner. */} - +
{condensed ? ( {/* The page name remains the foreground anchor in both states; the live filters are @@ -1888,24 +1880,27 @@ function PullRequestsColumn({ )}
{condensed ? ( -
- { - topbarSearchFocusedRef.current = focused; - }} - /> - -
- ) : null} - {rightPanelControl ? ( - {rightPanelControl} + { + topbarSearchFocusedRef.current = focused; + }} + /> ) : null} - + + {rightPanelControl} +
+
{searchInput} {filtersMenu} - {!condensed ? ( - - ) : null}
{/* Scrolled past this marker, the controls are gone and the title takes over. */}
{listBody} - +
); } - -function PullRequestRefreshControl({ - compact = false, - refreshing, - onRefresh, -}: { - compact?: boolean; - refreshing: boolean; - onRefresh: () => void; -}) { - return ( - - ); -} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 431e196de8b..a4b248c84ed 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -13,8 +13,9 @@ import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; import { Button } from "../components/ui/button"; import { SidebarInset } from "../components/ui/sidebar"; -import { WorkspacePageHeader } from "../components/WorkspacePageContainer"; import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -71,16 +72,41 @@ function SettingsContentLayout() { return (
- -
- - {showRestoreDefaults ? ( -
- -
- ) : null} + {!isElectron && ( +
+
+ + {showRestoreDefaults ? ( +
+ +
+ ) : null} +
+
+ )} + + {isElectron && ( +
+
+ + {showRestoreDefaults ? ( +
+ +
+ ) : null} +
- + )}
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 2eadd1fc5fb..f5effff6602 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -722,144 +722,24 @@ describe("workEntryIndicatesToolFailure", () => { }); describe("deriveWorkLogEntries", () => { - it("shows a command from its start event while it is still running", () => { + it("omits tool started entries and keeps completed entries", () => { const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "tool-start", - createdAt: "2026-02-23T00:00:02.000Z", - summary: "Command run started", - kind: "tool.started", - payload: { - itemType: "command_execution", - toolCallId: "call-1", - status: "inProgress", - title: "Command run", - detail: "Bash: vp test run", - data: { - toolName: "Bash", - input: { command: "vp test run" }, - }, - }, - }), - ]; - - const [entry] = deriveWorkLogEntries(activities); - expect(entry).toMatchObject({ - id: "tool-start", - command: "vp test run", - toolCallId: "call-1", - toolLifecycleStatus: "inProgress", - sourceActivityKind: "tool.started", - }); - }); - - it("retains the start command when the matching completion omits it", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "tool-start", - createdAt: "2026-02-23T00:00:02.000Z", - summary: "Command run started", - kind: "tool.started", - payload: { - itemType: "command_execution", - toolCallId: "call-1", - status: "inProgress", - title: "Command run", - data: { input: { command: "vp test run" } }, - }, - }), - makeActivity({ - id: "other-tool-start", - createdAt: "2026-02-23T00:00:02.500Z", - summary: "Other command started", - kind: "tool.started", - payload: { - itemType: "command_execution", - toolCallId: "call-2", - status: "inProgress", - title: "Other command", - data: { input: { command: "vp lint" } }, - }, - }), makeActivity({ id: "tool-complete", createdAt: "2026-02-23T00:00:03.000Z", - summary: "Command run", - kind: "tool.completed", - payload: { - itemType: "command_execution", - toolCallId: "call-1", - status: "completed", - title: "Command run", - }, - }), - makeActivity({ - id: "other-tool-complete", - createdAt: "2026-02-23T00:00:04.000Z", - summary: "Other command", + summary: "Tool call complete", kind: "tool.completed", - payload: { - itemType: "command_execution", - toolCallId: "call-2", - status: "completed", - title: "Other command", - }, }), - ]; - - const entries = deriveWorkLogEntries(activities); - expect(entries).toHaveLength(2); - expect(entries[0]).toMatchObject({ - id: "tool-complete", - command: "vp test run", - toolCallId: "call-1", - toolLifecycleStatus: "completed", - sourceActivityKind: "tool.completed", - }); - expect(entries[1]).toMatchObject({ - id: "other-tool-complete", - command: "vp lint", - toolCallId: "call-2", - toolLifecycleStatus: "completed", - sourceActivityKind: "tool.completed", - }); - }); - - it("does not merge non-adjacent tool starts without stable call ids", () => { - const activities: OrchestrationThreadActivity[] = [ makeActivity({ - id: "unkeyed-start-1", - createdAt: "2026-02-23T00:00:01.000Z", - summary: "Search started", - kind: "tool.started", - payload: { itemType: "search", title: "Search", status: "inProgress" }, - }), - makeActivity({ - id: "keyed-start", + id: "tool-start", createdAt: "2026-02-23T00:00:02.000Z", - summary: "Command started", - kind: "tool.started", - payload: { - itemType: "command_execution", - toolCallId: "call-between", - title: "Command", - status: "inProgress", - }, - }), - makeActivity({ - id: "unkeyed-start-2", - createdAt: "2026-02-23T00:00:03.000Z", - summary: "Search started", + summary: "Tool call", kind: "tool.started", - payload: { itemType: "search", title: "Search", status: "inProgress" }, }), ]; - expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual([ - "unkeyed-start-1", - "keyed-start", - "unkeyed-start-2", - ]); + const entries = deriveWorkLogEntries(activities); + expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); }); it("omits task.started but shows task.progress and task.completed", () => { @@ -1359,7 +1239,6 @@ describe("deriveWorkLogEntries", () => { expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ id: "grep-complete", - toolCallId: "tool-grep-1", toolTitle: "grep", detail: "19 files", itemType: "web_search", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index efe1876dfc1..4d0a76cf133 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -65,8 +65,6 @@ export interface WorkLogEntry { id: string; createdAt: string; turnId?: TurnId | null; - /** Stable provider identity across in-progress and completed lifecycle updates. */ - toolCallId?: string; label: string; detail?: string; command?: string; @@ -750,6 +748,7 @@ export function deriveWorkLogEntries( const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { + if (activity.kind === "tool.started") continue; // Agent task.started rows are CTA seeds: they carry the true spawn turn, // which is the batch key (completions of background subagents arrive // under later synthetic turns and must not start new batches). They @@ -758,13 +757,8 @@ export function deriveWorkLogEntries( if (activity.kind === "task.updated") continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; - // Plan updates have a dedicated task row. Keeping the raw activity here - // duplicates it as a legacy "Work Log / Plan updated" row when history - // is expanded. - if (activity.kind === "turn.plan.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; - if (isCodexTerminalInteractionActivity(activity)) continue; if (isAgentInternalActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); } @@ -775,11 +769,7 @@ export function deriveWorkLogEntries( } function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { - if ( - activity.kind !== "tool.started" && - activity.kind !== "tool.updated" && - activity.kind !== "tool.completed" - ) { + if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { return false; } @@ -790,28 +780,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); } -/** - * Codex terminal interactions report bytes written to an already-running PTY. - * Some thread histories contain them as generic tool.updated rows, so filter - * their exact wire shape from the presentation model. This repairs existing - * history without deleting or rewriting persisted activities. - */ -function isCodexTerminalInteractionActivity(activity: OrchestrationThreadActivity): boolean { - if (activity.kind !== "tool.updated") { - return false; - } - const payload = asRecord(activity.payload); - const data = asRecord(payload?.data); - return ( - payload?.itemType === "command_execution" && - typeof data?.itemId === "string" && - typeof data.processId === "string" && - typeof data.stdin === "string" && - typeof data.threadId === "string" && - typeof data.turnId === "string" - ); -} - function extractWorkLogToolLifecycleStatus( payload: Record | null, ): WorkLogToolLifecycleStatus | undefined { @@ -910,9 +878,6 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.toolCallId = toolCallId; } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); - if (!toolLifecycleStatus && activity.kind === "tool.started") { - toolLifecycleStatus = "inProgress"; - } if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; } @@ -968,17 +933,6 @@ function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; } -function toolLifecycleCollapseMapKey(entry: DerivedWorkLogEntry): string | undefined { - if ( - entry.activityKind !== "tool.started" && - entry.activityKind !== "tool.updated" && - entry.activityKind !== "tool.completed" - ) { - return undefined; - } - return entry.toolCallId ? `tool:${entry.toolCallId}` : undefined; -} - function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -995,7 +949,6 @@ function collapseDerivedWorkLogEntries( // own turn splintered one batch into a stream of "Kicked off N subagents" // rows (live-test finding, thread 7ac7ef05). const groupKeyByTaskId = new Map(); - const toolLifecycleRowIndex = new Map(); for (const entry of entries) { const isTaskRow = entry.taskId !== undefined && @@ -1040,40 +993,12 @@ function collapseDerivedWorkLogEntries( }); continue; } - const lifecycleKey = toolLifecycleCollapseMapKey(entry); - if (lifecycleKey !== undefined) { - const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey); - if (matchingLifecycleIndex !== undefined) { - const matchingEntry = collapsed[matchingLifecycleIndex]; - if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) { - toolLifecycleRowIndex.delete(lifecycleKey); - const merged = mergeDerivedWorkLogEntries(matchingEntry, entry); - collapsed[matchingLifecycleIndex] = merged; - if (merged.activityKind !== "tool.completed") { - toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex); - } - continue; - } - toolLifecycleRowIndex.delete(lifecycleKey); - } - } const previous = collapsed.at(-1); if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { - const previousIndex = collapsed.length - 1; - const previousKey = toolLifecycleCollapseMapKey(previous); - if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey); - const merged = mergeDerivedWorkLogEntries(previous, entry); - collapsed[previousIndex] = merged; - const mergedKey = toolLifecycleCollapseMapKey(merged); - if (mergedKey !== undefined && merged.activityKind !== "tool.completed") { - toolLifecycleRowIndex.set(mergedKey, previousIndex); - } + collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); continue; } collapsed.push(entry); - if (lifecycleKey !== undefined && entry.activityKind !== "tool.completed") { - toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1); - } } return collapsed; } @@ -1082,18 +1007,10 @@ function shouldCollapseToolLifecycleEntries( previous: DerivedWorkLogEntry, next: DerivedWorkLogEntry, ): boolean { - if ( - previous.activityKind !== "tool.started" && - previous.activityKind !== "tool.updated" && - previous.activityKind !== "tool.completed" - ) { + if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { return false; } - if ( - next.activityKind !== "tool.started" && - next.activityKind !== "tool.updated" && - next.activityKind !== "tool.completed" - ) { + if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { return false; } if (previous.activityKind === "tool.completed") { @@ -1163,11 +1080,7 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un ) { return `task${entry.taskId}`; } - if ( - entry.activityKind !== "tool.started" && - entry.activityKind !== "tool.updated" && - entry.activityKind !== "tool.completed" - ) { + if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; } if (entry.toolCallId) { @@ -1370,8 +1283,6 @@ function extractToolCommand(payload: Record | null): { const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const itemInput = asRecord(item?.input); - const dataInput = asRecord(data?.input); - const stateInput = asRecord(asRecord(data?.state)?.input); const itemType = asTrimmedString(payload?.itemType); const detail = asTrimmedString(payload?.detail); const candidates: unknown[] = [ @@ -1379,8 +1290,6 @@ function extractToolCommand(payload: Record | null): { itemInput?.command, itemResult?.command, data?.command, - dataInput?.command, - stateInput?.command, itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, ]; @@ -1407,7 +1316,7 @@ function extractToolTitle(payload: Record | null): string | nul function extractToolCallId(payload: Record | null): string | null { const data = asRecord(payload?.data); - return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId); + return asTrimmedString(data?.toolCallId); } function normalizeInlinePreview(value: string): string { diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index f7a6412d51d..b0b1df96e1f 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -18,7 +18,6 @@ describe("terminalUiStateStore actions", () => { useTerminalUiStateStore.persist.clearStorage(); useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {}, - terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, }); }); @@ -249,8 +248,6 @@ describe("terminalUiStateStore actions", () => { it("reconciles terminal ids from an external ordered list", () => { const store = useTerminalUiStateStore.getState(); store.setTerminalOpen(THREAD_REF, true); - store.setTerminalCustomLabel(THREAD_REF, "term-a", "API server"); - store.setTerminalCustomLabel(THREAD_REF, "stale-term", "Old task"); store.reconcileTerminalIds(THREAD_REF, ["term-a", "term-b"]); const terminalUiState = selectThreadTerminalUiState( @@ -263,11 +260,6 @@ describe("terminalUiStateStore actions", () => { { id: "group-term-a", terminalIds: ["term-a"] }, { id: "group-term-b", terminalIds: ["term-b"] }, ]); - expect( - useTerminalUiStateStore.getState().terminalCustomLabelsByThreadKey[ - scopedThreadKey(THREAD_REF) - ], - ).toEqual({ "term-a": "API server" }); }); it("does not import a closed panel terminal from stale metadata", () => { diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 545e195a128..290ca8e5954 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -32,11 +32,8 @@ const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; interface PersistedTerminalUiStateStoreState { terminalUiStateByThreadKey?: Record; terminalStateByThreadKey?: Record; - terminalCustomLabelsByThreadKey?: Record>; } -const EMPTY_TERMINAL_CUSTOM_LABELS: Readonly> = Object.freeze({}); - export function migratePersistedTerminalUiStateStoreState( persistedState: unknown, _version: number, @@ -53,32 +50,8 @@ export function migratePersistedTerminalUiStateStoreState( parseScopedThreadKey(threadKey), ), ); - const terminalCustomLabelsByThreadKey = Object.fromEntries( - Object.entries(candidate.terminalCustomLabelsByThreadKey ?? {}).flatMap( - ([threadKey, labels]) => { - if (!parseScopedThreadKey(threadKey) || !labels || typeof labels !== "object") return []; - const normalizedLabels = Object.fromEntries( - Object.entries(labels).flatMap(([terminalId, label]) => { - const normalizedTerminalId = terminalId.trim(); - const normalizedLabel = typeof label === "string" ? label.trim().slice(0, 80) : ""; - return normalizedTerminalId && normalizedLabel - ? [[normalizedTerminalId, normalizedLabel] as const] - : []; - }), - ); - return Object.keys(normalizedLabels).length > 0 - ? [[threadKey, normalizedLabels] as const] - : []; - }, - ), - ); - return { - terminalUiStateByThreadKey, - ...(Object.keys(terminalCustomLabelsByThreadKey).length > 0 - ? { terminalCustomLabelsByThreadKey } - : {}), - }; + return { terminalUiStateByThreadKey }; } function createTerminalUiStateStorage() { @@ -516,18 +489,6 @@ export function selectThreadTerminalUiState( ); } -export function selectThreadTerminalCustomLabels( - terminalCustomLabelsByThreadKey: Record>, - threadRef: ScopedThreadRef | null | undefined, -): Readonly> { - if (!threadRef || threadRef.threadId.length === 0) { - return EMPTY_TERMINAL_CUSTOM_LABELS; - } - return ( - terminalCustomLabelsByThreadKey[terminalThreadKey(threadRef)] ?? EMPTY_TERMINAL_CUSTOM_LABELS - ); -} - function updateTerminalUiStateByThreadKey( terminalUiStateByThreadKey: Record, threadRef: ScopedThreadRef, @@ -601,7 +562,6 @@ function removeRecordEntry(record: Record, key: string): Record; - terminalCustomLabelsByThreadKey: Record>; /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */ suppressedTerminalIdsByThreadKey: Record; setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; @@ -615,11 +575,6 @@ interface TerminalUiStateStoreState { options?: { open?: boolean; active?: boolean }, ) => void; setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; - setTerminalCustomLabel: ( - threadRef: ScopedThreadRef, - terminalId: string, - label: string | null, - ) => void; closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void; clearTerminalUiState: (threadRef: ScopedThreadRef) => void; @@ -636,12 +591,7 @@ export const useTerminalUiStateStore = create()( state: ThreadTerminalUiState, suppressedTerminalIds: readonly string[], ) => ThreadTerminalUiState, - suppression?: { - terminalId: string; - suppressed: boolean; - clearCustomLabel?: boolean; - }, - pruneCustomLabels = false, + suppression?: { terminalId: string; suppressed: boolean }, ) => { set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -659,57 +609,21 @@ export const useTerminalUiStateStore = create()( suppression.suppressed, ) : state.suppressedTerminalIdsByThreadKey; - const terminalIdToClear = suppression?.clearCustomLabel - ? suppression.terminalId.trim() - : ""; - const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; - let nextTerminalCustomLabelsByThreadKey = - terminalIdToClear.length > 0 && currentLabels[terminalIdToClear] !== undefined - ? Object.keys(currentLabels).length === 1 - ? removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey) - : { - ...state.terminalCustomLabelsByThreadKey, - [threadKey]: removeRecordEntry(currentLabels, terminalIdToClear), - } - : state.terminalCustomLabelsByThreadKey; - if (pruneCustomLabels) { - const survivingIds = new Set( - selectThreadTerminalUiState(nextTerminalUiStateByThreadKey, threadRef).terminalIds, - ); - const labelsForThread = nextTerminalCustomLabelsByThreadKey[threadKey] ?? {}; - const survivingLabels = Object.fromEntries( - Object.entries(labelsForThread).filter(([terminalId]) => - survivingIds.has(terminalId), - ), - ); - if (Object.keys(survivingLabels).length !== Object.keys(labelsForThread).length) { - nextTerminalCustomLabelsByThreadKey = - Object.keys(survivingLabels).length > 0 - ? { - ...nextTerminalCustomLabelsByThreadKey, - [threadKey]: survivingLabels, - } - : removeRecordEntry(nextTerminalCustomLabelsByThreadKey, threadKey); - } - } if ( nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && - nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey && - nextTerminalCustomLabelsByThreadKey === state.terminalCustomLabelsByThreadKey + nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, - terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, }; }); }; return { terminalUiStateByThreadKey: {}, - terminalCustomLabelsByThreadKey: {}, suppressedTerminalIdsByThreadKey: {}, setTerminalOpen: (threadRef, open) => { const terminalState = selectThreadTerminalUiState( @@ -768,56 +682,22 @@ export const useTerminalUiStateStore = create()( ), setActiveTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), - setTerminalCustomLabel: (threadRef, terminalId, label) => - set((state) => { - const normalizedTerminalId = terminalId.trim(); - if (normalizedTerminalId.length === 0) return state; - const threadKey = terminalThreadKey(threadRef); - const currentLabels = state.terminalCustomLabelsByThreadKey[threadKey] ?? {}; - const normalizedLabel = label?.trim().slice(0, 80) ?? ""; - if (normalizedLabel.length > 0) { - if (currentLabels[normalizedTerminalId] === normalizedLabel) return state; - return { - terminalCustomLabelsByThreadKey: { - ...state.terminalCustomLabelsByThreadKey, - [threadKey]: { ...currentLabels, [normalizedTerminalId]: normalizedLabel }, - }, - }; - } - if (currentLabels[normalizedTerminalId] === undefined) return state; - const { [normalizedTerminalId]: _removed, ...remainingLabels } = currentLabels; - return { - terminalCustomLabelsByThreadKey: - Object.keys(remainingLabels).length > 0 - ? { - ...state.terminalCustomLabelsByThreadKey, - [threadKey]: remainingLabels, - } - : removeRecordEntry(state.terminalCustomLabelsByThreadKey, threadKey), - }; - }), closeTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), { terminalId, suppressed: true, - clearCustomLabel: true, }), reconcileTerminalIds: (threadRef, nextIds) => - updateTerminal( - threadRef, - (state, suppressedTerminalIds) => { - if (suppressedTerminalIds.length === 0) { - return reconcileThreadTerminalSessionIds(state, nextIds); - } - const suppressedIds = new Set(suppressedTerminalIds); - return reconcileThreadTerminalSessionIds( - state, - nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), - ); - }, - undefined, - true, - ), + updateTerminal(threadRef, (state, suppressedTerminalIds) => { + if (suppressedTerminalIds.length === 0) { + return reconcileThreadTerminalSessionIds(state, nextIds); + } + const suppressedIds = new Set(suppressedTerminalIds); + return reconcileThreadTerminalSessionIds( + state, + nextIds.filter((terminalId) => !suppressedIds.has(terminalId)), + ); + }), clearTerminalUiState: (threadRef) => set((state) => { const threadKey = terminalThreadKey(threadRef); @@ -828,20 +708,14 @@ export const useTerminalUiStateStore = create()( ); const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; - const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; if ( nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey && - !hadSuppressedTerminalIds && - !hadCustomLabels + !hadSuppressedTerminalIds ) { return state; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, - terminalCustomLabelsByThreadKey: removeRecordEntry( - state.terminalCustomLabelsByThreadKey, - threadKey, - ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -854,8 +728,7 @@ export const useTerminalUiStateStore = create()( const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined; const hadSuppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined; - const hadCustomLabels = state.terminalCustomLabelsByThreadKey[threadKey] !== undefined; - if (!hadTerminalUiState && !hadSuppressedTerminalIds && !hadCustomLabels) { + if (!hadTerminalUiState && !hadSuppressedTerminalIds) { return state; } return { @@ -863,10 +736,6 @@ export const useTerminalUiStateStore = create()( state.terminalUiStateByThreadKey, threadKey, ), - terminalCustomLabelsByThreadKey: removeRecordEntry( - state.terminalCustomLabelsByThreadKey, - threadKey, - ), suppressedTerminalIdsByThreadKey: removeRecordEntry( state.suppressedTerminalIdsByThreadKey, threadKey, @@ -878,7 +747,6 @@ export const useTerminalUiStateStore = create()( const orphanedIds = new Set( [ ...Object.keys(state.terminalUiStateByThreadKey), - ...Object.keys(state.terminalCustomLabelsByThreadKey), ...Object.keys(state.suppressedTerminalIdsByThreadKey), ].filter((key) => !activeThreadKeys.has(key)), ); @@ -889,17 +757,12 @@ export const useTerminalUiStateStore = create()( const nextSuppressedTerminalIdsByThreadKey = { ...state.suppressedTerminalIdsByThreadKey, }; - const nextTerminalCustomLabelsByThreadKey = { - ...state.terminalCustomLabelsByThreadKey, - }; for (const id of orphanedIds) { delete nextTerminalUiStateByThreadKey[id]; - delete nextTerminalCustomLabelsByThreadKey[id]; delete nextSuppressedTerminalIdsByThreadKey[id]; } return { terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey, - terminalCustomLabelsByThreadKey: nextTerminalCustomLabelsByThreadKey, suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey, }; }), @@ -907,12 +770,11 @@ export const useTerminalUiStateStore = create()( }, { name: TERMINAL_UI_STATE_STORAGE_KEY, - version: 5, + version: 4, storage: createJSONStorage(createTerminalUiStateStorage), migrate: migratePersistedTerminalUiStateStoreState, partialize: (state) => ({ terminalUiStateByThreadKey: state.terminalUiStateByThreadKey, - terminalCustomLabelsByThreadKey: state.terminalCustomLabelsByThreadKey, }), }, ), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 03451cc7b2e..09d7d7a4602 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -111,8 +111,6 @@ export interface ContextMenuItem { header?: boolean; /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ icon?: string; - /** Inserts a visual section divider immediately before this item. */ - separatorBefore?: boolean; children?: readonly ContextMenuItem[]; } @@ -123,7 +121,6 @@ export interface ContextMenuItemSchemaType { readonly disabled?: boolean; readonly header?: boolean; readonly icon?: string; - readonly separatorBefore?: boolean; readonly children?: readonly ContextMenuItemSchemaType[]; } @@ -134,7 +131,6 @@ export const ContextMenuItemSchema: Schema.Codec = Sc disabled: Schema.optionalKey(Schema.Boolean), header: Schema.optionalKey(Schema.Boolean), icon: Schema.optionalKey(Schema.String), - separatorBefore: Schema.optionalKey(Schema.Boolean), children: Schema.optionalKey( Schema.Array( Schema.suspend((): Schema.Codec => ContextMenuItemSchema), diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 81270ad320c..c2fa9e2a86a 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -248,7 +248,6 @@ describe("mergeUsage", () => { ); expect(merged.sessions).toBe(1); - expect(merged.providers[0]?.sessions).toBe(1); }); it("returns empty totals with no environments", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index f5e54434fd9..886b214183b 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -25,7 +25,6 @@ export interface ProviderTotals { readonly costUsd: number; readonly totalTokens: number; readonly records: number; - readonly sessions: number; readonly costShare: number; readonly tokenShare: number; } @@ -136,29 +135,22 @@ function claimSources(environments: readonly EnvironmentUsage[]): { function ownedContribution( environment: EnvironmentUsage, ownerByFingerprint: ReadonlyMap, -): { - readonly buckets: readonly UsageBucket[]; - readonly sessionsByProvider: ReadonlyMap; -} { +): { readonly buckets: readonly UsageBucket[]; readonly sessions: number } { const ownedProviders = new Set(); - const sessionsByProvider = new Map(); + let sessions = 0; for (const source of environment.summary.sources) { if (source.status === "missing") continue; const key = fingerprintKey(source.fingerprint); if (ownerByFingerprint.get(key) === environment.environmentId) { - const provider = source.fingerprint.provider; - ownedProviders.add(provider); + ownedProviders.add(source.fingerprint.provider); // Distinct within a directory. Summing per-bucket session counts instead // would count a session once per day and model it spans. - sessionsByProvider.set( - provider, - (sessionsByProvider.get(provider) ?? 0) + source.distinctSessions, - ); + sessions += source.distinctSessions; } } return { buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), - sessionsByProvider, + sessions, }; } @@ -236,7 +228,7 @@ export function mergeUsage( const providerAccumulator = new Map< UsageProviderKind, - { costUsd: number; totalTokens: number; records: number; sessions: number } + { costUsd: number; totalTokens: number; records: number } >(); const modelAccumulator = new Map< string, @@ -263,20 +255,12 @@ export function mergeUsage( const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { - const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); + const { buckets, sessions: environmentSessions } = ownedContribution( + environment, + ownerByFingerprint, + ); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); - - for (const [providerKind, providerSessions] of sessionsByProvider) { - sessions += providerSessions; - const provider = providerAccumulator.get(providerKind) ?? { - costUsd: 0, - totalTokens: 0, - records: 0, - sessions: 0, - }; - provider.sessions += providerSessions; - providerAccumulator.set(providerKind, provider); - } + sessions += environmentSessions; for (const bucket of buckets) { const tokens = bucketTokens(bucket); @@ -296,7 +280,6 @@ export function mergeUsage( costUsd: 0, totalTokens: 0, records: 0, - sessions: 0, }; provider.costUsd += bucket.costUsd; provider.totalTokens += tokens; @@ -358,7 +341,6 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, - sessions: totals.sessions, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, tokenShare: totalTokens === 0 ? 0 : totals.totalTokens / totalTokens, })) From 48ddb3d469d245363dba723f774ba449e4d950c5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 14 Aug 2026 22:48:08 -0400 Subject: [PATCH 012/113] feat(web): older chat timestamps show the date, not just the time (#6654) Co-authored-by: Claude Fable 5 --- .../src/components/chat/MessagesTimeline.tsx | 6 +-- apps/web/src/timestampFormat.test.ts | 50 +++++++++++++++++++ apps/web/src/timestampFormat.ts | 38 ++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e190f47569b..f9ad57ff3b8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -105,7 +105,7 @@ import { import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; -import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; +import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { buildInlineTerminalContextText, @@ -1038,7 +1038,7 @@ function UserTimelineRow({ row }: { row: Extract }> - {formatShortTimestamp(row.message.createdAt, ctx.timestampFormat)} + {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} @@ -1129,7 +1129,7 @@ function AssistantTimelineRow({ row }: { row: Extract} > - {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} + {formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index c2fe4b62714..6678549ccd9 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + formatDayAwareTimestamp, formatElapsedDurationLabel, formatExpiresInLabel, formatRelativeTime, @@ -96,6 +97,55 @@ describe("formatExpiresInLabel", () => { }); }); +describe("formatDayAwareTimestamp", () => { + // Instants are built with the local-time Date constructor so the + // calendar-day boundaries hold in any test timezone or locale. + const iso = (y: number, monthIndex: number, d: number, h: number, mi: number) => + new Date(y, monthIndex, d, h, mi).toISOString(); + const now = new Date(2026, 7, 14, 12, 0).getTime(); + const time = (isoDate: string) => formatShortTimestamp(isoDate, "12-hour"); + + it("shows time only for today", () => { + const messageAt = iso(2026, 7, 14, 9, 30); + expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe(time(messageAt)); + }); + + it("labels the previous calendar day as yesterday even when under 24h old", () => { + const messageAt = iso(2026, 7, 13, 23, 30); + const justPastMidnight = new Date(2026, 7, 14, 0, 30).getTime(); + expect(formatDayAwareTimestamp(messageAt, "12-hour", justPastMidnight)).toBe( + `yesterday at ${time(messageAt)}`, + ); + }); + + it("prefixes older same-year messages with the numeric date", () => { + const messageAt = iso(2026, 7, 12, 12, 34); + const datePart = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", + }).format(new Date(messageAt)); + expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe( + `${datePart} ${time(messageAt)}`, + ); + }); + + it("includes the year once the calendar year differs", () => { + const messageAt = iso(2025, 11, 31, 18, 0); + const datePart = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", + year: "numeric", + }).format(new Date(messageAt)); + expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe( + `${datePart} ${time(messageAt)}`, + ); + }); + + it("returns an empty string for invalid input", () => { + expect(formatDayAwareTimestamp("not-a-date", "12-hour", now)).toBe(""); + }); +}); + describe("invalid timestamp inputs", () => { it("returns an empty timestamp instead of throwing", () => { expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index cce5b141c63..c8f9956ebb3 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -91,6 +91,44 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp return getTimestampFormatter(timestampFormat, false).format(date); } +const numericDateFormatter = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", +}); +const numericDateWithYearFormatter = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", + year: "numeric", +}); + +/** + * Chat timestamp that adds the date once the message is no longer from today: + * today `12:34 PM`, yesterday `yesterday at 12:34 PM`, older `8/13 12:34 PM` + * (locale digit order), with the year included once the calendar year differs. + * Boundaries are local calendar days, not 24-hour windows. + */ +export function formatDayAwareTimestamp( + isoDate: string, + timestampFormat: TimestampFormat, + nowMs: number = Date.now(), +): string { + const date = parseTimestampDate(isoDate); + if (!date) return ""; + const time = getTimestampFormatter(timestampFormat, false).format(date); + + const now = new Date(nowMs); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + // Round so DST-shifted 23/25 hour days still count as whole days. + const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000); + + if (dayDiff <= 0) return time; + if (dayDiff === 1) return `yesterday at ${time}`; + const dateFormatter = + date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; + return `${dateFormatter.format(date)} ${time}`; +} + /** * Format a relative time string from an ISO date. * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` From 8c628f14993cb159d467e7a0f8c52578dde77005 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:48:54 +0300 Subject: [PATCH 013/113] fix(web): align pull request action menu rows (#6534) Co-authored-by: Nickolas Kyryliuk Co-authored-by: Claude Opus 5 (1M context) --- .../pullRequest/PullRequestDetailPanel.tsx | 42 +++++++++++++++---- .../pullRequestDetail.logic.test.ts | 7 ++++ .../pullRequest/pullRequestDetail.logic.ts | 9 ++++ apps/web/src/components/ui/menu.test.tsx | 23 ++++++++++ apps/web/src/components/ui/menu.tsx | 2 +- 5 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/ui/menu.test.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 2f4e84dc3fd..015371a86d9 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -103,6 +103,7 @@ import { handoffPrompt, handoffReviewComments, pullRequestActionNeedsHostRefresh, + pullRequestActionMenuHasGroup, pullRequestFindingKey, pullRequestHandoffLabels, readableFailure, @@ -1033,6 +1034,26 @@ export function PullRequestDetailPanel({ : allowedMergeMethods.length > 0 ? "merge" : null; + // What the menu's action group holds. Named once so the separators around it are drawn from + // the same answer as its contents, rather than on the assumption that it has any. + const showsDraftToggle = + detail?.state === "open" && + can(detail.isDraft ? "ready" : "draft") && + !(detail.isDraft && primaryAction === "ready"); + const showsAutoMerge = + detail?.state === "open" && + ((autoMergeArmed && can("disable-auto-merge")) || + (!autoMergeArmed && + !detail.isDraft && + !conflicting && + can("enable-auto-merge") && + allowedMergeMethods.length > 0)); + const showsMergeMethods = + detail?.state === "open" && + can("merge") && + !detail.isDraft && + !conflicting && + allowedMergeMethods.length > 1; // The pull request number carries this state in the overview and the right-panel tab mirrors // it. Conflicts keep their own row below: an open pull request remains green there. const statePresentation = detail @@ -1191,8 +1212,7 @@ export function PullRequestDetailPanel({ {/* Only where the button row could not take it: "Ready for review" on a draft is the primary header button, so offering it here as well would show the same action twice. */} - {can(detail.isDraft ? "ready" : "draft") && - !(detail.isDraft && primaryAction === "ready") ? ( + {showsDraftToggle ? ( void perform(detail.isDraft ? "ready" : "draft")} @@ -1236,12 +1256,12 @@ export function PullRequestDetailPanel({ Hidden while conflicting: every method would fail. */} {/* Only where merging is on offer at all: a strategy to merge with is not a choice for someone who may not merge. */} - {can("merge") && - !detail.isDraft && - !conflicting && - allowedMergeMethods.length > 1 ? ( + {showsMergeMethods ? ( <> - + {/* Only below the draft control. A host with no draft of its own, or + a draft whose control is already the header button, would leave + this against the separator that opened the group. */} + {showsDraftToggle ? : null} @@ -1261,7 +1281,13 @@ export function PullRequestDetailPanel({ ) : null} - + {pullRequestActionMenuHasGroup( + showsDraftToggle, + showsAutoMerge, + showsMergeMethods, + ) ? ( + + ) : null} ) : null} void readLocalApi()?.shell.openExternal(detail.url)}> diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 9b247002fce..faab9d847bd 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -19,6 +19,7 @@ import { isThreadOwnPullRequest, orderPullRequestComments, pullRequestActionNeedsHostRefresh, + pullRequestActionMenuHasGroup, pullRequestFindingKey, pullRequestHandoffLabels, readableFailure, @@ -53,6 +54,12 @@ const TIMELINE_SOURCE: Pick< closedAt: null, }; +describe("pull request action menu", () => { + it("keeps the group divider when auto-merge is the only action", () => { + expect(pullRequestActionMenuHasGroup(false, true, false)).toBe(true); + }); +}); + describe("pull request state description", () => { it("keeps draft and conflicts orthogonal to the terminal states", () => { expect(describePullRequestState("open", true)).toBe("Draft"); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index ddb4e813bf4..26054f6ef69 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -57,6 +57,15 @@ export function pullRequestHandoffLabels(inThisThread: boolean) { }; } +/** Whether the open pull-request action group contains at least one action. */ +export function pullRequestActionMenuHasGroup( + showsDraftToggle: boolean, + showsAutoMerge: boolean, + showsMergeMethods: boolean, +): boolean { + return showsDraftToggle || showsAutoMerge || showsMergeMethods; +} + /** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */ export function describePullRequestState(state: PullRequestState, isDraft: boolean): string { if (state === "merged") return "Merged"; diff --git a/apps/web/src/components/ui/menu.test.tsx b/apps/web/src/components/ui/menu.test.tsx new file mode 100644 index 00000000000..079d2a1794b --- /dev/null +++ b/apps/web/src/components/ui/menu.test.tsx @@ -0,0 +1,23 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { Menu, MenuRadioGroup, MenuRadioItem } from "./menu"; + +describe("menu radio item geometry", () => { + it("keeps radio-item icons on the same text grid as menu items", () => { + const html = renderToStaticMarkup( + + + + + + Merge + + + + , + ); + + expect(html).toContain("-mx-0.5"); + }); +}); diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 803d6c1987c..b66782ebe2d 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -166,7 +166,7 @@ function MenuRadioItem({ return ( Date: Sat, 15 Aug 2026 09:33:08 +0200 Subject: [PATCH 014/113] fix(web): restore selected themes in dark mode (#6665) --- apps/web/src/index.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4e636eb4ff0..b2c914c0b69 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1169,7 +1169,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil compatibility overrides so both navigation implementations receive the same palette. Success, info, provider, and channel identity colors remain independent; error, warning, and update roles are themeable below. */ -html[data-theme-id] { +html[data-theme-id], +html.dark[data-theme-id] { --background: var(--app-theme-canvas); --app-chrome-background: var(--app-theme-chrome); --toolbar-background: var(--app-theme-toolbar); From f0ebc628c6dd83fd0c7963078ad7778ce6028d0c Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:42:35 +0200 Subject: [PATCH 015/113] fix(web): improve Codex usage graph contrast (#6669) --- apps/web/src/components/usage/UsagePage.tsx | 12 ++--- .../components/usage/UsageProviderChart.tsx | 23 +++++---- .../src/components/usage/usageProviders.ts | 47 +++++++++---------- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7a5cdd883db..92e2c5b6fc3 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -25,7 +25,7 @@ import { SidebarInset } from "../ui/sidebar"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, @@ -209,7 +209,7 @@ export function UsagePage() {
- {PROVIDER_LABEL[provider.provider]} + {PROVIDER_PRESENTATION[provider.provider].label} {metric === "cost" @@ -222,7 +222,7 @@ export function UsagePage() { className="h-full" style={{ width: `${(share * 100).toFixed(1)}%`, - backgroundColor: PROVIDER_COLOR[provider.provider], + backgroundColor: PROVIDER_PRESENTATION[provider.provider].color, }} />
@@ -385,7 +385,7 @@ export function UsagePage() { {isPast24Hours ? "Hour" : "Day"} {PROVIDER_ORDER.map((provider) => ( - {PROVIDER_LABEL[provider]} + {PROVIDER_PRESENTATION[provider].label} ))} Total @@ -448,7 +448,7 @@ function ProviderMark({ readonly provider: UsageProviderKind; readonly className: string; }) { - const Mark = PROVIDER_MARK[provider]; + const Mark = PROVIDER_PRESENTATION[provider].mark; return ; } @@ -595,7 +595,7 @@ function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" })
- {PROVIDER_LABEL[provider]} + {PROVIDER_PRESENTATION[provider].label}
diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index f41945bfe28..d7582a0e4bd 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -9,7 +9,7 @@ import { formatTokens, formatUsd, } from "@t3tools/shared/usageFormat"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; const VIEW_WIDTH = 960; const VIEW_HEIGHT = 260; @@ -339,14 +339,19 @@ export function UsageProviderChart({ {/* Fills first, then every stroke, so no series covers another's line. */} {paths.map(({ provider, area }) => ( - + ))} {paths.map(({ provider, line }) => ( @@ -376,12 +381,12 @@ export function UsageProviderChart({ >
{formatTooltipPeriod(hoveredPeriod)}
{PROVIDER_ORDER.map((provider) => { - const Mark = PROVIDER_MARK[provider]; + const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; return (
- {PROVIDER_LABEL[provider]} + {label} {format( @@ -423,13 +428,13 @@ export function UsageChartLegend() { return (
{PROVIDER_ORDER.map((provider) => { - // The marks carry the same fills as the bands, so they key the chart - // just as a colour swatch would. - const Mark = PROVIDER_MARK[provider]; + // Brand marks keep monochrome providers identifiable even when their + // chart series use distinct colors. + const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; return ( - {PROVIDER_LABEL[provider]} + {label} ); })} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index f8b65877dcf..00db67e28a8 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -2,32 +2,29 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, type Icon, OpenAI } from "../Icons"; -/** - * Series and table order. The chart layers both providers from a shared zero - * baseline, so this only fixes the reading order of legends, tables and hover - * rows; it does not decide which series sits above the other. - */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; - -export const PROVIDER_LABEL: Record = { - claude: "Claude Code", - codex: "Codex", -}; - -/** Claude's brand orange against a neutral white for Codex. */ -export const PROVIDER_COLOR: Record = { - claude: "#d97757", - codex: "#e6e6e6", +type UsageProviderPresentation = { + readonly label: string; + readonly color: string; + readonly mark: Icon; }; /** - * Brand marks, reused from the provider picker. - * - * These ship their own fills (`#d97757` for Claude, white on dark for OpenAI), - * which are the same colours as the chart bands, so swapping a colour dot for a - * mark keeps the series association intact rather than trading it away. + * Exhaustive presentation for providers supported by the usage contract. + * Declaration order is reused by every chart, table, legend, and skeleton, so + * adding a provider only requires its contract support and one entry here. */ -export const PROVIDER_MARK: Record = { - claude: ClaudeAI, - codex: OpenAI, -}; +export const PROVIDER_PRESENTATION = { + codex: { + label: "Codex", + color: "var(--foreground)", + mark: OpenAI, + }, + claude: { + label: "Claude Code", + color: "#d97757", + mark: ClaudeAI, + }, +} satisfies Record; + +/** The chart layers every series from zero, so order only controls how it is read. */ +export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; From e9ae134c59bedd39428e1d279df0ada3e86cd500 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 15 Aug 2026 11:30:08 +0200 Subject: [PATCH 016/113] docs: route feature requests to Discussions - Disable feature-request issue templates - Direct contributors to Ideas discussions for proposals --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 102 --------------------- CONTRIBUTING.md | 8 +- README.md | 4 +- 5 files changed, 14 insertions(+), 106 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9bc321dac0d..38a764eab6d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,7 @@ body: attributes: value: | Use this form for broken behavior, regressions, crashes, or reliability problems. + Feature requests belong in [Discussions](https://github.com/pingdotgg/t3code/discussions/categories/ideas). Search existing issues first and keep the report focused on one problem. - type: checkboxes diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..4f4940ba665 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Feature request + url: https://github.com/pingdotgg/t3code/discussions/categories/ideas + about: Suggest an improvement or new capability in Discussions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 3c9424fb322..00000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Feature request -description: Propose a scoped improvement or new capability. -title: "[Feature]: " -labels: - - enhancement - - needs-triage -body: - - type: markdown - attributes: - value: | - Use this form for new capabilities or meaningful improvements to existing behavior. - This repo is still early. Small, concrete requests that clearly explain the problem and scope are much easier to evaluate. - - - type: checkboxes - id: checks - attributes: - label: Before submitting - options: - - label: I searched existing issues and did not find a duplicate. - required: true - - label: I am describing a concrete problem or use case, not just a vague idea. - required: true - - - type: dropdown - id: area - attributes: - label: Area - description: Which part of the project would this change affect? - options: - - apps/web - - apps/server - - apps/desktop - - apps/mobile - - packages/contracts or packages/shared - - Build, CI, or release tooling - - Docs - - Not sure - validations: - required: true - - - type: textarea - id: problem - attributes: - label: Problem or use case - description: What are you trying to do? What is hard, slow, or impossible today? - placeholder: I want to reconnect to an existing provider session after a browser refresh without losing the current thread state. - validations: - required: true - - - type: textarea - id: proposal - attributes: - label: Proposed solution - description: Describe the behavior, API, or UX you want. - placeholder: Persist enough session metadata so the client can discover and reattach to the active provider session on load. - validations: - required: true - - - type: textarea - id: value - attributes: - label: Why this matters - description: Who benefits, and what outcome does this unlock? - placeholder: This would make reconnects predictable during network drops and reduce accidental duplicate sessions. - validations: - required: true - - - type: textarea - id: scope - attributes: - label: Smallest useful scope - description: What is the narrowest version of this request that would still solve your problem? - placeholder: A first pass only needs to support restoring the active session for the current thread. - validations: - required: true - - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: Workarounds, prior art, or other approaches you considered. - placeholder: I currently work around this by manually restarting the provider session, but that loses in-flight context. - - - type: textarea - id: tradeoffs - attributes: - label: Risks or tradeoffs - description: What costs, complexity, or edge cases should be considered? - placeholder: This may require careful handling when the underlying provider session has already exited. - - - type: textarea - id: references - attributes: - label: Examples or references - description: Links, screenshots, mockups, or comparable tools. - - - type: checkboxes - id: contribution - attributes: - label: Contribution - options: - - label: I would be open to helping implement this. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b734a99bbb..e8e2f9b1178 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,9 @@ We are not actively accepting contributions right now. -You can still open an issue or PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it. +You can still report a bug or open a PR, but please do so knowing there is a high chance we close it, defer it forever, or never look at it. + +Feature requests and proposals belong in [Ideas discussions](https://github.com/pingdotgg/t3code/discussions/categories/ideas), not issues. If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control. @@ -50,9 +52,9 @@ If the change depends on motion, timing, transitions, or interaction details, in If we have to guess what changed, we are much less likely to review it. -## Issues First +## Discuss Changes First -If you are thinking about a non-trivial change, open an issue first. +If you are thinking about a non-trivial change, start a discussion first. Issues are reserved for bug reports. That still does not mean we will want the PR, but it gives you a chance to avoid wasting your time. diff --git a/README.md b/README.md index a7264ef62e9..8ec101387f6 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ Checkout their getting started guide for more information: https://viteplus.dev/ vp i ``` -Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. +Read [CONTRIBUTING.md](./CONTRIBUTING.md) before reporting a bug or opening a PR. + +Have a feature request? Start an [Ideas discussion](https://github.com/pingdotgg/t3code/discussions/categories/ideas). Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). From d8a6dfd31539a86d08bd4fbd030f8252b3c405ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 15 Aug 2026 12:29:26 +0200 Subject: [PATCH 017/113] fix(desktop): app zoom no longer zooms the preview browser (#6649) Co-authored-by: Claude Opus 5 (1M context) --- apps/desktop/src/preview/Manager.test.ts | 134 ++++++++++++++++-- apps/desktop/src/preview/Manager.ts | 72 +++++++--- apps/desktop/src/window/DesktopWindow.test.ts | 47 ++++++ apps/desktop/src/window/DesktopWindow.ts | 4 + 4 files changed, 219 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index c24dca802c5..5c336eec8da 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -979,7 +979,10 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => + // The guest reports whatever zoom level Chromium handed it from the app + // window, so the tab's own zoom is the source of truth in both directions: + // asserted onto every guest, never read back off one. + effectIt.effect("keeps the tab's own zoom instead of the guest's reported zoom", () => withManager((manager) => Effect.gen(function* () { let effectiveZoom = 0.9; @@ -1025,18 +1028,13 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_zoom"); yield* manager.registerWebview("tab_zoom", 42); - expect(states.at(-1)?.zoomFactor).toBe(0.9); - expect(setZoomFactor).not.toHaveBeenCalled(); + expect(states.at(-1)?.zoomFactor).toBe(1); + expect(setZoomFactor).toHaveBeenCalledWith(1); - effectiveZoom = 1.25; - listeners.get("did-navigate")?.(); - yield* Effect.yieldNow; - - expect(states.at(-1)?.zoomFactor).toBe(1.25); - expect(setZoomFactor).not.toHaveBeenCalled(); - - zoomReadable = false; - url = "https://example.com/after-zoom-read-failed"; + // An app zoom leaves the guest reporting the inherited level. Navigating + // must not adopt it as the preview's zoom. + effectiveZoom = 0.8; + url = "https://example.com/after-app-zoom"; listeners.get("did-navigate")?.(); yield* Effect.yieldNow; @@ -1045,7 +1043,18 @@ describe("PreviewManager", () => { url, title: "Example", }); - expect(states.at(-1)?.zoomFactor).toBe(1.25); + expect(states.at(-1)?.zoomFactor).toBe(1); + + // Only the preview's own zoom controls move it. + yield* manager.zoomIn("tab_zoom"); + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.at(-1)?.zoomFactor).toBe(1.1); + + zoomReadable = false; + listeners.get("did-navigate")?.(); + yield* Effect.yieldNow; + + expect(states.at(-1)?.zoomFactor).toBe(1.1); const replacementSetZoomFactor = vi.fn(); fromId.mockReturnValue({ @@ -1074,8 +1083,103 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_zoom", 43); - expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25); - expect(states.at(-1)?.zoomFactor).toBe(1.25); + expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.at(-1)?.zoomFactor).toBe(1.1); + }), + ), + ); + + // Zooming the app UI pushes the window's zoom level onto every guest, so the + // preview has to be put back at the zoom the user gave it. + effectIt.effect("re-applies each tab's own zoom when the app window zooms", () => + withManager((manager) => + Effect.gen(function* () { + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_reapply"); + yield* manager.registerWebview("tab_reapply", 42); + yield* manager.zoomIn("tab_reapply"); + setZoomFactor.mockClear(); + + yield* manager.reapplyZoom(); + + expect(setZoomFactor).toHaveBeenCalledTimes(1); + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + }), + ), + ); + + // did-attach and dom-ready both re-register the guest that is already + // attached, and a guest that just inherited the app window's zoom needs its + // own back — without that round trip republishing tab state. + effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () => + withManager((manager) => + Effect.gen(function* () { + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + + yield* manager.createTab("tab_reregister_zoom"); + yield* manager.registerWebview("tab_reregister_zoom", 42); + yield* manager.zoomIn("tab_reregister_zoom"); + setZoomFactor.mockClear(); + const publishedBefore = states.length; + + yield* manager.registerWebview("tab_reregister_zoom", 42); + + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.length).toBe(publishedBefore); + expect(states.at(-1)?.zoomFactor).toBe(1.1); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 4799a7dfac2..d48b1303739 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -647,6 +647,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (Option.isSome(next)) yield* emit(tabId, next.value); }); + /** + * Pushes a tab's zoom factor onto whichever guest it currently owns, reading + * both at call time. Anything that applies zoom after an await goes through + * here: a snapshot taken before the await can be older than a zoom action that + * landed in between, and re-applying it would roll that action back. + */ + const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || tab.webContentsId == null) return; + const wc = webContents.fromId(tab.webContentsId); + if (!wc || wc.isDestroyed()) return; + yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () => + wc.setZoomFactor(tab.zoomFactor), + ).pipe(Effect.ignore); + }); + const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( tabId: string, ) { @@ -1305,10 +1321,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function confirmedNavigation = false, ) { if (wc.isDestroyed()) return; - const zoomFactor = yield* attempt( - { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id }, - () => wc.getZoomFactor(), - ).pipe(Effect.option); const computedNavStatus = computeNavStatus(wc); const canGoBack = wc.navigationHistory.canGoBack(); const canGoForward = wc.navigationHistory.canGoForward(); @@ -1338,7 +1350,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus, canGoBack, canGoForward, - ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}), + // zoomFactor is deliberately not read back from the guest: Chromium + // reports the level it inherited from the app window, so mirroring it + // would turn an app zoom into the preview's own zoom. updatedAt, }; return [ @@ -1716,11 +1730,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationTheme = yield* Ref.get(annotationThemeRef); const currentAttachment = attached.get(webContentsId); if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { - const zoomFactor = yield* attempt( - { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, - () => wc.getZoomFactor(), - ); - yield* update(tabId, { zoomFactor }); + // The guest we already own re-announced itself, so nothing about the tab + // changed. Only push its zoom back down — Chromium may have just handed + // this guest the app window's zoom level. + yield* assertTabZoom(tabId); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); @@ -1749,18 +1762,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* new PreviewTabNotFoundError({ tabId }); } - const zoomFactor = - replacedWebContentsId !== null - ? yield* attempt( - { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, - () => { - wc.setZoomFactor(currentTab.zoomFactor); - return currentTab.zoomFactor; - }, - ) - : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => - wc.getZoomFactor(), - ); + // Always assert the tab's own zoom rather than reading the guest's: a guest + // attaching while the app UI is zoomed starts at the embedder's inherited + // zoom level, which is not the preview's zoom. Done before the guest is + // published so it never paints a frame at the inherited zoom. + yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => + wc.setZoomFactor(currentTab.zoomFactor), + ); yield* attachListeners(tabId, wc); const registeredAt = yield* currentIso; const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => @@ -1784,7 +1792,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), canGoForward: wc.navigationHistory.canGoForward(), - zoomFactor, updatedAt: registeredAt, }; return [ @@ -1806,6 +1813,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; + // A zoom action that landed while this attach was in flight addressed the + // guest this one replaced, so settle the new guest on the committed factor. + yield* assertTabZoom(tabId); runFork(restoreControlSession(tabId, wc)); yield* emit(tabId, registered); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => @@ -2099,6 +2109,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + /** + * Chromium hands every guest `` the embedder's zoom level, so zooming + * the app UI drags the previewed page along with it. The preview browser owns + * its own zoom factor, so re-assert it on each attached guest whenever the main + * window's zoom changes (see DesktopWindow.zoomMain). + */ + const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () { + const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys()); + yield* Effect.forEach(tabIds, assertTabZoom, { discard: true }); + }); + const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* ( tabId: string, transform: (current: number) => number, @@ -3476,6 +3497,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + reapplyZoom, refresh, registerWebview, resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), @@ -3774,6 +3796,9 @@ export class PreviewManager extends Context.Service< readonly zoomIn: (tabId: string) => Effect.Effect; readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; + // Re-applies every attached guest's own zoom factor, undoing the zoom level + // Chromium inherits from the embedder when the app UI zooms. + readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; readonly setColorScheme: ( tabId: string, @@ -3874,6 +3899,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { zoomIn: operations.zoomIn, zoomOut: operations.zoomOut, resetZoom: operations.resetZoom, + reapplyZoom: operations.reapplyZoom, hardReload: operations.hardReload, setColorScheme: operations.setColorScheme, openDevTools: operations.openDevTools, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3aedd2ea6c0..ed0fbf8b568 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -61,9 +61,14 @@ const environmentInput = { function makeFakeBrowserWindow() { const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); + let zoomLevel = 0; const webContents = { copyImageAt: vi.fn(), getURL: vi.fn(() => "t3code-dev://app/"), + getZoomLevel: vi.fn(() => zoomLevel), + setZoomLevel: vi.fn((level: number) => { + zoomLevel = level; + }), isLoadingMainFrame: vi.fn(() => false), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); @@ -116,6 +121,7 @@ function makeFakeBrowserWindow() { openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, + setZoomLevel: webContents.setZoomLevel, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, windowListeners, @@ -186,6 +192,7 @@ function makeTestLayer(input: { bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; + readonly previewZoomReapplies?: number[]; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -264,6 +271,10 @@ function makeTestLayer(input: { setMainWindow: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), + reapplyZoom: () => + Effect.sync(() => { + input.previewZoomReapplies?.push(input.window.webContents.getZoomLevel()); + }), }), ), ), @@ -483,6 +494,42 @@ describe("DesktopWindow", () => { }), ); + // Chromium hands the main window's zoom level down to embedded preview + // guests, so every app zoom has to put the preview browser back at its own + // zoom or zooming the UI drags the previewed page with it. + it.effect("restores the preview browser's own zoom after zooming the app", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const previewZoomReapplies: number[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + previewZoomReapplies, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("in"); + yield* desktopWindow.zoomMain("reset"); + + assert.deepEqual( + fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), + [-0.5, -1, -0.5, 0], + ); + // Recorded after the window level moved, so the preview is put back at + // its own zoom on every step rather than left on the inherited one. + assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index bf8c681448f..2ae3d353279 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -855,6 +855,10 @@ export const make = Effect.gen(function* () { webContents.setZoomLevel( direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); + // Chromium pushes the new level down to embedded guests, which would zoom + // the previewed page along with the app UI. The preview browser keeps its + // own zoom, so put each guest back where the preview left it. + yield* previewManager.reapplyZoom(); }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; From afca73d3683c99057ea8af1ad7d77511a0faf680 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 15 Aug 2026 05:43:13 -0500 Subject: [PATCH 018/113] fix(server): keep provider notification consumers alive past startSession (#6538) Co-authored-by: tsouth89 --- .../src/provider/Layers/CodexAdapter.test.ts | 58 ++++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 6 +- .../src/provider/Layers/CursorAdapter.test.ts | 68 +++++++++++++++++++ .../src/provider/Layers/CursorAdapter.ts | 8 ++- .../src/provider/Layers/GrokAdapter.test.ts | 67 ++++++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 8 ++- 6 files changed, 212 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7b8fbec5666..5358716aabe 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -32,6 +32,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as CodexErrors from "effect-codex-app-server/errors"; import { ServerConfig } from "../../config.ts"; @@ -1150,6 +1151,63 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }); }), ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the runtime event consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every event the session + // emitted afterwards was dropped. The other tests here start the session from + // the test fiber, which never completes, so the consumer survived and the bug + // stayed invisible. Starting it in a fiber that finishes reproduces + // production. + it.effect("keeps consuming runtime events after the startSession fiber completes", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const startSessionFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-outlives-start"), + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber); + + const runtime = lifecycleRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + yield* runtime.emit({ + id: asEventId("evt-after-start-session"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-outlives-start"), + turnId: asTurnId("turn-1"), + itemId: asItemId("msg_after_start"), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-outlives-start", + turnId: "turn-1", + item: { + type: "agentMessage", + id: "msg_after_start", + text: "emitted after startSession returned", + }, + }, + }); + + const firstEvent = yield* Fiber.join(firstEventFiber).pipe(Effect.timeout("10 seconds")); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "item.completed"); + // Live clock so the timeout above is real: under the default test clock it + // waits on virtual time that never advances, and a regression would hang + // until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 6b99bf52b1e..065156d3647 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1715,6 +1715,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); + // Fork into the session scope, not the calling fiber. `forkChild` makes + // this a child of `startSession`, and Effect interrupts a fiber's + // children when it completes, so the consumer died on return and every + // runtime event the session emitted afterwards was dropped. const eventFiber = yield* Stream.runForEach(runtime.events, (event) => Effect.gen(function* () { yield* writeNativeEvent(event); @@ -1730,7 +1734,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); }), - ).pipe(Effect.forkChild); + ).pipe(Effect.forkIn(sessionScope)); const started = yield* runtime.start().pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 491f718a977..cd5cdb7f01a 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1429,4 +1429,72 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }).pipe(Effect.provide(customAdapterLayer)); }, ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the notification consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every later session/update + // was dropped: the thread sat on "Working" forever while the provider + // streamed its whole turn. The other tests here call startSession directly + // from the test fiber, which never completes, so the consumer survived and + // the bug stayed invisible. Running it in a fiber that finishes is what + // reproduces production. + it.effect("keeps consuming notifications after the startSession fiber completes", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-consumer-outlives-start-session"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const sawContentDelta = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "content.delta" && String(event.threadId) === String(threadId) + ? Deferred.succeed(sawContentDelta, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const startSessionFiber = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber).pipe(Effect.timeout("10 seconds")); + + // Forked, and the assertion waits on the projected event rather than on + // sendTurn: with the consumer dead the turn never settles, so awaiting it + // directly would hang until the suite timeout instead of failing here. + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hello mock", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(sawContentDelta).pipe(Effect.timeout("10 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("10 seconds")); + + const delta = runtimeEvents.find( + (event) => event.type === "content.delta" && String(event.threadId) === String(threadId), + ); + assert.isDefined( + delta, + "no content.delta was projected after the startSession fiber completed", + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + // Live clock so the timeouts above are real: under the default test clock + // they wait on virtual time that never advances, and a regression would + // hang until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 80475a5c269..30c173d8fae 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -874,7 +874,13 @@ export function makeCursorAdapter( Effect.catch((cause) => Effect.logError("Failed to process Cursor runtime notification.", { cause }), ), - Effect.forkChild, + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), ); ctx.notificationFiber = nf; diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972ae8..6cb71660a74 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1197,4 +1197,71 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the notification consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every later session/update + // was dropped: the thread sat on "Working" forever while the provider + // streamed its whole turn. Every other test here calls startSession directly + // from the test fiber, which never completes, so the consumer survived and + // the bug stayed invisible. Running it in a fiber that finishes is what + // reproduces production. + it.effect("keeps consuming notifications after the startSession fiber completes", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-consumer-outlives-start-session"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const startSessionFiber = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber).pipe(Effect.timeout("10 seconds")); + + // Forked, and the assertion waits on the projected event rather than on + // sendTurn: with the consumer dead the turn never settles, so awaiting it + // directly would hang until the suite timeout instead of failing here. + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hello grok", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("10 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("10 seconds")); + + const delta = runtimeEvents.find( + (event) => event.type === "content.delta" && String(event.threadId) === String(threadId), + ); + assert.isDefined( + delta, + "no content.delta was projected after the startSession fiber completed", + ); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + // Live clock so the timeouts above are real: under the default test clock + // they wait on virtual time that never advances, and a regression would + // hang until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 977cc8caadd..858d862e6d5 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -876,7 +876,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte Effect.catch((cause) => Effect.logError("Failed to process Grok runtime notification.", { cause }), ), - Effect.forkChild, + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), ); ctx.notificationFiber = nf; From 75472802bc5ddaba860dc652000223600e529937 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:43:20 +0200 Subject: [PATCH 019/113] fix(server): treat removed Bitbucket permissions endpoint as unknown, not blocking (#6525) --- .../BitbucketPullRequestApi.test.ts | 40 +++++++++++++++++++ .../pullRequest/BitbucketPullRequestApi.ts | 19 ++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index f57bb67a4c4..4120cf55e62 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -867,6 +867,46 @@ layer("BitbucketPullRequestApi.layer", (it) => { }), ); + it.effect( + "reads a removed permissions endpoint as granted rather than failing the merge on it", + () => + Effect.gen(function* () { + // Bitbucket retired /user/permissions/repositories under CHANGE-2770: every account now + // gets HTTP 410 here, whatever it may do. + mockedRequest.mockReturnValue( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 410, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" })); + }), + ); + + it.effect("still fails the permission read on a failure that is not the removed endpoint", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 401, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" })); + + assert.strictEqual(error._tag, "BitbucketResponseError"); + }), + ); + it.effect("reads the workspace's people and marks whoever is already a reviewer", () => Effect.gen(function* () { mockedRequest diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index a20d4aaaa05..a2c57bfc5fd 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -107,6 +107,16 @@ export type BitbucketPullRequestApiError = | BitbucketRepositoryUnsupportedError | BitbucketDiffCommitError; +/** + * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a + * permission — Bitbucket sends this for every account now, not only ones it would have refused. + */ +function isRepositoryPermissionRemovedError( + error: BitbucketPullRequestApiError, +): error is BitbucketApi.BitbucketResponseError { + return error._tag === "BitbucketResponseError" && error.status === 410; +} + /** * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no * error at all, so this is a number to respect rather than to push against. @@ -553,6 +563,13 @@ export const make = Effect.gen(function* () { // Nothing on the repository, the pull request or the workspace states what the credentials // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked // alongside the reads the detail was already making, so it costs no round trip of its own. + // + // Bitbucket permanently removed this endpoint (CHANGE-2770): every account now gets HTTP 410 + // in place of an answer, whatever it may do. That is the deprecated-endpoint signal, not a + // permission being refused, so it is read the same way an unreachable read already is + // elsewhere — as a permission that could not be learned, which grants rather than blocks, and + // leaves the actual merge or write to say why if the account may not do it. Any other failure + // (a bad token, a network fault, an unreadable body) still fails as it did before. getRepositoryPermission: (input) => withRepository(input.repository, () => readPage({ @@ -562,7 +579,7 @@ export const make = Effect.gen(function* () { )}`, decode: decodeRepositoryPermissionJson, }), - ), + ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))), getPullRequestDiff: (input) => input.commit !== undefined && !isCommitSha(input.commit) From 672216d7e152241213a8757892f281e1f4434e8a Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 12:43:42 +0200 Subject: [PATCH 020/113] fix(ssh): let cold remote servers finish starting (#6168) --- packages/ssh/src/tunnel.test.ts | 34 +++++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 4 +++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 76b8ecccb30..be17b8ffaf3 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -45,6 +45,16 @@ const makeSuccessfulProcess = (stdout: string) => { }); }; +const makeDelayedSuccessfulProcess = (stdout: string, delayMs: number) => { + const process = makeSuccessfulProcess(stdout); + return { + ...process, + exitCode: Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }; +}; + const makeRunningProcess = (onKill: () => void) => { let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null; return ChildProcessSpawner.makeHandle({ @@ -174,6 +184,7 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); assert.notInclude(buildRemoteLaunchScript(), "server-home"); assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); + assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( buildRemotePairingScript(target), @@ -235,6 +246,29 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); + it.effect("allows cold remote launches to exceed the default SSH command timeout", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 75_000)), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); + + return Effect.gen(function* () { + const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(75)); + + const result = yield* Fiber.join(fiber); + assert.equal(result.remotePort, 3774); + }).pipe(Effect.provide(processLayer)); + }); + it("allows the remote port picker to run without a state file path", () => { assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 179d1fcb547..a1611c5770f 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -54,7 +54,8 @@ const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; const SSH_READY_PROBE_TIMEOUT_MS = 1_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; -const REMOTE_READY_TIMEOUT_MS = 15_000; +const REMOTE_READY_TIMEOUT_MS = 60_000; +const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { @@ -705,6 +706,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)], stdin: buildRemoteLaunchScript(runner), + timeoutMs: REMOTE_LAUNCH_TIMEOUT_MS, ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), From 1e87029261f9b81061a2a7420849b9eeaf1a2ebe Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:43:50 +0800 Subject: [PATCH 021/113] fix(web): preserve Claude insight line breaks (#4344) --- .../components/chat/MessagesTimeline.logic.test.ts | 12 ++++++++++++ .../src/components/chat/MessagesTimeline.logic.ts | 4 ++++ apps/web/src/components/chat/MessagesTimeline.tsx | 2 ++ 3 files changed, 18 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6d74204bc1c..70a330d4630 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -5,8 +5,20 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + shouldPreserveAssistantLineBreaks, } from "./MessagesTimeline.logic"; +describe("shouldPreserveAssistantLineBreaks", () => { + it("preserves Claude insight formatting without changing regular markdown", () => { + expect( + shouldPreserveAssistantLineBreaks( + "★ Insight ─────────────────\\nFirst observation\\nSecond observation\\n─────────────────", + ), + ).toBe(true); + expect(shouldPreserveAssistantLineBreaks("A normal\\nmarkdown paragraph")).toBe(false); + }); +}); + describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { const result = computeMessageDurationStart([ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 6bc0a2a6203..c89bbd0557d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -52,6 +52,10 @@ export function resolveTimelineIsAtEnd( return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } +export function shouldPreserveAssistantLineBreaks(text: string): boolean { + return /^★ Insight(?:\s|─)/mu.test(text); +} + export function resolveTimelineMinimapHeightStyle(itemCount: number): string { const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING); return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f9ad57ff3b8..f5c529ff315 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -83,6 +83,7 @@ import { resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, + shouldPreserveAssistantLineBreaks, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -1113,6 +1114,7 @@ function AssistantTimelineRow({ row }: { row: Extract Date: Sat, 15 Aug 2026 03:43:58 -0700 Subject: [PATCH 022/113] feat(web): accept file drops across the chat workspace (#6636) --- apps/web/src/components/ChatView.tsx | 42 +++++++++- apps/web/src/components/chat/ChatComposer.tsx | 49 ++---------- .../components/chat/workspaceFileDrop.test.ts | 78 +++++++++++++++++++ .../src/components/chat/workspaceFileDrop.ts | 54 +++++++++++++ 4 files changed, 180 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/chat/workspaceFileDrop.test.ts create mode 100644 apps/web/src/components/chat/workspaceFileDrop.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6eab33aec1c..7a5bde6345c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -141,6 +141,7 @@ import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, @@ -164,6 +165,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + PaperclipIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; @@ -1336,6 +1338,7 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -1356,6 +1359,17 @@ function ChatViewContent(props: ChatViewProps) { const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); + + useEffect(() => { + setIsWorkspaceFileDragActive(false); + }, [draftId, routeThreadKey]); + + useEffect(() => { + if (!isWorkspaceFileDragActive) return; + const clearWorkspaceFileDrag = () => setIsWorkspaceFileDragActive(false); + window.addEventListener("dragend", clearWorkspaceFileDrag); + return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); + }, [isWorkspaceFileDragActive]); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record> >({}); @@ -6149,6 +6163,11 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; + const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ + setDragActive: setIsWorkspaceFileDragActive, + addFiles: (files) => composerRef.current?.addDroppedFiles(files), + }); + return (
{rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null} @@ -6217,7 +6236,28 @@ function ChatViewContent(props: ChatViewProps) { {/* Main content area with optional plan sidebar */}
{/* Chat column */} -
+
+ {isWorkspaceFileDragActive ? ( +
+
+
+
+ ) : null} {/* Provider status overlays the timeline without changing its content height. */}
void; focusAt: (cursor: number) => void; + addDroppedFiles: (files: File[]) => void; insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; openModelPicker: () => void; toggleModelPicker: () => void; @@ -971,7 +972,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandFrameRef = useRef(null); const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); - const dragDepthRef = useRef(0); const stashPulseKeyRef = useRef(0); const stashPulseTimeoutRef = useRef(null); /** @@ -1399,7 +1399,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); - dragDepthRef.current = 0; setIsDragOverComposer(false); }, [draftId, activeThreadId, promptRef]); @@ -2380,41 +2379,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) void addComposerImages(imageFiles); }; - const onComposerDragEnter = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dragDepthRef.current += 1; - setIsDragOverComposer(true); - }; - - const onComposerDragOver = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setIsDragOverComposer(true); - }; - - const onComposerDragLeave = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) { - setIsDragOverComposer(false); - } - }; - - const onComposerDrop = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dragDepthRef.current = 0; - setIsDragOverComposer(false); - const files = Array.from(event.dataTransfer.files); - void addComposerImages(files); - focusComposer(); - }; - const insertComposerTextAtEnd = ( text: string, options?: { ensureLeadingBoundary?: boolean }, @@ -2468,7 +2432,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { if (!isDragOverComposer) return; const onWindowDragEnd = () => { - dragDepthRef.current = 0; setIsDragOverComposer(false); }; window.addEventListener("dragend", onWindowDragEnd); @@ -2537,6 +2500,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, + addDroppedFiles: (files: File[]) => { + void addComposerImages(files); + focusComposer(); + }, insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { setIsComposerModelPickerOpen(true); @@ -2619,6 +2586,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), [ activeThread, + addComposerImages, composerDraftTarget, composerCursor, composerTerminalContexts, @@ -2629,6 +2597,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerElementContextsRef, composerPreviewAnnotations, composerReviewComments, + focusComposer, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -2660,10 +2629,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "group rounded-[22px] p-px transition-colors duration-200", composerProviderState.composerFrameClassName, )} - onDragEnter={onComposerDragEnter} - onDragOver={onComposerDragOver} - onDragLeave={onComposerDragLeave} - onDrop={onComposerDrop} onDragEnterCapture={composerMentionDragHandlers.onDragEnter} onDragOverCapture={composerMentionDragHandlers.onDragOver} onDragLeaveCapture={onComposerMentionDragLeaveCapture} diff --git a/apps/web/src/components/chat/workspaceFileDrop.test.ts b/apps/web/src/components/chat/workspaceFileDrop.test.ts new file mode 100644 index 00000000000..ec5d074a3eb --- /dev/null +++ b/apps/web/src/components/chat/workspaceFileDrop.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { + makeWorkspaceFileDropHandlers, + type WorkspaceFileDragEvent, + type WorkspaceFileDropHost, +} from "./workspaceFileDrop"; + +function makeDragEvent(options?: { + types?: string[]; + files?: File[]; + movedWithinTarget?: boolean; +}) { + const preventDefault = vi.fn(); + const event = { + dataTransfer: { + types: options?.types ?? ["Files"], + files: options?.files ?? [], + dropEffect: "none", + }, + relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, + currentTarget: { + contains: () => options?.movedWithinTarget ?? false, + }, + preventDefault, + } satisfies WorkspaceFileDragEvent; + return { event, preventDefault }; +} + +function makeHost() { + const setDragActive = vi.fn(); + const addFiles = vi.fn(); + const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost; + return { host, setDragActive, addFiles }; +} + +describe("makeWorkspaceFileDropHandlers", () => { + it("activates the target for an external file drag", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent(); + + makeWorkspaceFileDropHandlers(host).onDragEnter(event); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(setDragActive).toHaveBeenCalledWith(true); + }); + + it("ignores non-file drags", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent({ types: ["text/plain"] }); + + makeWorkspaceFileDropHandlers(host).onDragOver(event); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("does not flicker when the drag moves between children", () => { + const { host, setDragActive } = makeHost(); + const { event } = makeDragEvent({ movedWithinTarget: true }); + + const handlers = makeWorkspaceFileDropHandlers(host); + handlers.onDragEnter(event); + handlers.onDragLeave(event); + + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("forwards dropped files and clears the active state", () => { + const file = new File(["contents"], "example.txt", { type: "text/plain" }); + const { host, setDragActive, addFiles } = makeHost(); + const { event } = makeDragEvent({ files: [file] }); + + makeWorkspaceFileDropHandlers(host).onDrop(event); + + expect(setDragActive).toHaveBeenCalledWith(false); + expect(addFiles).toHaveBeenCalledWith([file]); + }); +}); diff --git a/apps/web/src/components/chat/workspaceFileDrop.ts b/apps/web/src/components/chat/workspaceFileDrop.ts new file mode 100644 index 00000000000..132a8051e15 --- /dev/null +++ b/apps/web/src/components/chat/workspaceFileDrop.ts @@ -0,0 +1,54 @@ +export interface WorkspaceFileDragEvent { + readonly dataTransfer: { + readonly types: ReadonlyArray; + readonly files: Iterable; + dropEffect: string; + }; + readonly relatedTarget: EventTarget | null; + readonly currentTarget: { + contains(target: Node | null): boolean; + }; + preventDefault(): void; +} + +export interface WorkspaceFileDropHost { + setDragActive(active: boolean): void; + addFiles(files: File[]): void; +} + +function isFileDrag(event: WorkspaceFileDragEvent): boolean { + return event.dataTransfer.types.includes("Files"); +} + +function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { + return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); +} + +export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { + return { + onDragEnter(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(true); + }, + onDragOver(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + host.setDragActive(true); + }, + onDragLeave(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(false); + }, + onDrop(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + host.setDragActive(false); + host.addFiles(Array.from(event.dataTransfer.files)); + }, + }; +} From eaa6c4712fe11f0396e549b1873f163dc202d229 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:01 +0200 Subject: [PATCH 023/113] fix(web): widen ordered-list marker gutter for 3+ digit item numbers (#6527) --- apps/web/src/components/ChatMarkdown.test.tsx | 36 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 29 +++++++++++++++ apps/web/src/index.css | 14 ++++++-- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ChatMarkdown.test.tsx diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx new file mode 100644 index 00000000000..9499ee5a691 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { orderedListGutterStyle } from "./ChatMarkdown"; + +describe("orderedListGutterStyle", () => { + it("leaves the default gutter alone for single-digit lists", () => { + expect(orderedListGutterStyle(9, undefined)).toBeUndefined(); + }); + + it("leaves the default gutter alone for two-digit lists", () => { + expect(orderedListGutterStyle(99, undefined)).toBeUndefined(); + }); + + it("leaves the default gutter alone for a two-digit list that starts above 1", () => { + // start=50 + 49 items => last marker is "98", still two digits. + expect(orderedListGutterStyle(49, 50)).toBeUndefined(); + }); + + it("widens the gutter once the last marker reaches three digits", () => { + // item 100 is the bug from #6512: a 100-item list starting at 1. + expect(orderedListGutterStyle(100, undefined)).toEqual({ "--list-gutter": "4ch" }); + }); + + it("accounts for a non-default start attribute", () => { + // start=95 + 9 items => last marker is "103", three digits. + expect(orderedListGutterStyle(9, 95)).toEqual({ "--list-gutter": "4ch" }); + }); + + it("scales further for four-digit markers", () => { + expect(orderedListGutterStyle(1000, undefined)).toEqual({ "--list-gutter": "5ch" }); + }); + + it("treats a missing/zero item count as a single item", () => { + expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 53b043f3a8f..294a9e22ad7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -146,6 +146,26 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb if (!match?.[1]) return null; return listItemStart + firstLine.indexOf(match[1]); } + +/** + * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits two-digit + * decimal markers. Once a list's last item reaches three digits (item 100+), + * `list-style-position: outside` paints the marker wider than that gutter and + * the leading digit gets clipped by the item's own overflow. Rather than + * widening the gutter for every list, only lists whose last marker is 3+ + * digits get a wider `--list-gutter`, sized to that marker's digit count. + */ +export function orderedListGutterStyle( + itemCount: number, + start: number | undefined, +): { "--list-gutter": string } | undefined { + const firstNumber = typeof start === "number" && Number.isFinite(start) ? start : 1; + const lastNumber = firstNumber + Math.max(itemCount - 1, 0); + const digits = String(Math.abs(lastNumber)).length; + if (digits <= 2) return undefined; + return { "--list-gutter": `${digits + 1}ch` }; +} + const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { @@ -1506,6 +1526,15 @@ function ChatMarkdown({
); }, + ol({ node, start, style, ...props }) { + const itemCount = + node?.children?.filter((child) => child.type === "element" && child.tagName === "li") + .length ?? 0; + const gutterStyle = orderedListGutterStyle(itemCount, start); + return ( +
    + ); + }, li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b2c914c0b69..299506c30ad 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1630,12 +1630,22 @@ code { } .chat-markdown ul { + /* Reset for nested uls under a widened ol — --list-gutter is an inherited + custom property, so without this a task-list under a 3+ digit ordered + list would inherit the outer gutter instead of its own default. */ + --list-gutter: 1.25rem; padding-left: 1.25rem; list-style-type: disc; } +/* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but + ChatMarkdown's `ol` renderer widens it (via inline style) for lists whose + last marker is 3+ digits, so item 100+ isn't clipped by list-style-position: + outside painting the marker past the padding box. Reset it here too so a + nested ol without its own widened marker doesn't inherit the outer one. */ .chat-markdown ol { - padding-left: 1.25rem; + --list-gutter: 1.25rem; + padding-left: var(--list-gutter, 1.25rem); list-style-type: decimal; } @@ -1665,7 +1675,7 @@ code { } .chat-markdown li.task-list-item input[type="checkbox"] { - margin: 0 0.35em 0.15em -1.25rem; + margin: 0 0.35em 0.15em calc(-1 * var(--list-gutter, 1.25rem)); vertical-align: middle; } From 71c6f8248775066ebaf4bfc6680d3e2acb4bb2d1 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:03 +0200 Subject: [PATCH 024/113] fix(server): bound thread activity hydration (#6153) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- .../Layers/ProjectionSnapshotQuery.test.ts | 124 +++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 203 +++++++++++++++--- 2 files changed, 299 insertions(+), 28 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index be596b36b85..83ae3cfe049 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2281,6 +2281,130 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("bounds activity hydration and preserves unresolved requests", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql` + WITH RECURSIVE activity_rows(sequence) AS ( + SELECT 1 + UNION ALL + SELECT sequence + 1 FROM activity_rows WHERE sequence < 501 + ) + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + SELECT + printf('activity-%04d', sequence), + 'thread-w', + 'turn-5', + 'tool', + 'tool.completed', + 'ran tool', + printf('{"sequence":%d}', sequence), + sequence, + '2026-03-01T00:04:00.000Z' + FROM activity_rows + `; + + const fullDetail = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(fullDetail._tag, "Some"); + if (fullDetail._tag === "Some") { + assert.equal(fullDetail.value.activities.length, 500); + assert.equal(fullDetail.value.activities[0]?.id, asEventId("activity-0002")); + assert.equal(fullDetail.value.activities.at(-1)?.id, asEventId("activity-0501")); + } + + const windowedDetail = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + }); + assert.equal(windowedDetail._tag, "Some"); + if (windowedDetail._tag === "Some") { + assert.equal(windowedDetail.value.thread.activities.length, 500); + assert.equal(windowedDetail.value.thread.activities[0]?.id, asEventId("activity-0002")); + assert.equal(windowedDetail.value.thread.activities.at(-1)?.id, asEventId("activity-0501")); + } + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + VALUES + ( + 'approval-old', 'thread-w', NULL, 'approval', 'approval.requested', + 'Approve old command', '{"requestId":"approval-1"}', NULL, + '2026-03-01T00:00:01.000Z' + ), + ( + 'user-input-old', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Answer old question', '{"requestId":"input-1"}', NULL, + '2026-03-01T00:00:02.000Z' + ), + ( + 'user-input-closed', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Closed question', '{"requestId":"input-closed"}', NULL, + '2026-03-01T00:00:03.000Z' + ), + ( + 'user-input-closed-resolution', 'thread-w', NULL, 'info', 'user-input.resolved', + 'Closed question', '{"requestId":"input-closed"}', NULL, + '2026-03-01T00:00:04.000Z' + ), + ( + 'user-input-tied-z-request', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Tied open question', '{"requestId":"input-tied-open"}', NULL, + '2026-03-01T00:00:05.000Z' + ), + ( + 'user-input-tied-a-resolution', 'thread-w', NULL, 'info', 'user-input.resolved', + 'Tied open question', '{"requestId":"input-tied-open"}', NULL, + '2026-03-01T00:00:05.000Z' + ) + `; + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, thread_id, turn_id, status, decision, created_at, resolved_at + ) + VALUES ( + 'approval-1', 'thread-w', NULL, 'pending', NULL, + '2026-03-01T00:00:01.000Z', NULL + ) + `; + yield* sql` + UPDATE projection_threads + SET pending_approval_count = 1, pending_user_input_count = 1 + WHERE thread_id = 'thread-w' + `; + + const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(detailWithPinnedRequests._tag, "Some"); + if (detailWithPinnedRequests._tag === "Some") { + const ids = detailWithPinnedRequests.value.activities.map((activity) => activity.id); + assert.equal(detailWithPinnedRequests.value.activities.length, 503); + assert.equal(ids.includes(asEventId("approval-old")), true); + assert.equal(ids.includes(asEventId("user-input-old")), true); + assert.equal(ids.includes(asEventId("user-input-closed")), false); + assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + } + + const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + }); + assert.equal(windowWithPinnedRequests._tag, "Some"); + if (windowWithPinnedRequests._tag === "Some") { + const ids = windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id); + assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503); + assert.equal(ids.includes(asEventId("approval-old")), true); + assert.equal(ids.includes(asEventId("user-input-old")), true); + assert.equal(ids.includes(asEventId("user-input-closed")), false); + assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + } + }), + ); + it.effect("a thread with no turns returns its content unwindowed on the first page", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3e77f9cf875..c6c5ad1d7e8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -69,6 +69,10 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); +// Keep detail reads consistent with the in-memory projector's retained +// activity window. Applying the limit in SQL avoids decoding an unbounded +// payload_json set before the projector can enforce that invariant. +const THREAD_DETAIL_ACTIVITY_LIMIT = 500; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -1015,8 +1019,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities ORDER BY sequence ASC, created_at ASC, @@ -1232,6 +1253,95 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // Blocking request payloads must remain available even if they predate the + // recent activity window. Each CTE returns at most one unresolved row per + // request, so the merge below stays bounded by actionable work. + const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + WITH pending_approval_requests AS ( + SELECT request_id, thread_id + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + AND status = 'pending' + ), + pending_approval_activities AS ( + SELECT + activity.activity_id, + ROW_NUMBER() OVER ( + PARTITION BY pending.request_id + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS request_order + FROM pending_approval_requests AS pending + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND activity.kind = 'approval.requested' + AND json_extract(activity.payload_json, '$.requestId') = pending.request_id + ), + pending_user_input_thread AS ( + SELECT thread_id + FROM projection_threads + WHERE thread_id = ${threadId} + AND pending_user_input_count > 0 + ), + user_input_lifecycle AS ( + SELECT + activity.activity_id, + activity.kind, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS request_order + FROM pending_user_input_thread AS pending + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND ( + activity.kind IN ('user-input.requested', 'user-input.resolved') + OR ( + activity.kind = 'provider.user-input.respond.failed' + AND ( + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%stale pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending codex user input request%' + ) + ) + ) + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + ), + pinned_activity_ids AS ( + SELECT activity_id + FROM pending_approval_activities + WHERE request_order = 1 + UNION ALL + SELECT activity_id + FROM user_input_lifecycle + WHERE request_order = 1 + AND kind = 'user-input.requested' + ) + SELECT + activity.activity_id AS "activityId", + activity.thread_id AS "threadId", + activity.turn_id AS "turnId", + activity.tone, + activity.kind, + activity.summary, + activity.payload_json AS "payload", + activity.sequence, + activity.created_at AS "createdAt" + FROM pinned_activity_ids AS pinned + INNER JOIN projection_thread_activities AS activity + ON activity.activity_id = pinned.activity_id + ORDER BY activity.created_at ASC, activity.activity_id ASC + `, + }); + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ Request: ThreadTurnRangeLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1247,34 +1357,51 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} - AND ( - turn_id IN ( - SELECT turn_id FROM projection_turns - WHERE thread_id = ${threadId} - AND turn_id IS NOT NULL - AND ( - requested_at > ${minAnchorAt} - OR ( - requested_at = ${minAnchorAt} - AND turn_id >= ${minTurnKey} + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) ) - ) - AND ( - requested_at < ${beforeAnchorAt} - OR ( - requested_at = ${beforeAnchorAt} - AND turn_id < ${beforeTurnKey} + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) ) - ) - ) - OR ( - turn_id IS NULL - AND created_at >= ${minAnchorAt} - AND created_at < ${beforeAnchorAt} + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) ) - ) + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities ORDER BY sequence ASC, created_at ASC, @@ -2374,6 +2501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { messageRows, proposedPlanRows, activityRows, + pinnedActivityRows, checkpointRows, latestTurnRow, sessionRow, @@ -2416,6 +2544,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listPinnedThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + ), + ), + ), listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2446,6 +2582,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.none(); } + const selectedActivityRows = [ + ...new Map( + [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const), + ).values(), + ].toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ); + const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -2483,7 +2630,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return message; }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - activities: activityRows.map((row) => { + activities: selectedActivityRows.map((row) => { const activity = { id: row.activityId, tone: row.tone, From 48cba7d93c8c63508f31cce2544d480ace86f929 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:19 +0200 Subject: [PATCH 025/113] fix(web): restore the Archive action in the default sidebar thread menu (#6526) --- apps/web/src/components/Sidebar.tsx | 34 +++++++++++++++++++ .../components/threadActionMenu.logic.test.ts | 27 ++++++++++++++- .../src/components/threadActionMenu.logic.ts | 9 +++++ apps/web/src/hooks/useThreadActionMenu.ts | 26 ++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2f0c5a22140..a7a5b638c0e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1622,6 +1622,7 @@ export default function Sidebar() { const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -1633,6 +1634,7 @@ export default function Sidebar() { pinThread, unpinThread, reorderPinnedThread, + archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -2990,6 +2992,8 @@ export default function Sidebar() { isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, + isRunning: + thread.session?.status === "running" && thread.session.activeTurnId != null, supports: { settlement: supportsSettlement, snooze: supportsSnooze, @@ -3093,6 +3097,34 @@ export default function Sidebar() { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: didArchive + ? "Thread archived, but navigation failed" + : "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + return; + } case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -3126,12 +3158,14 @@ export default function Sidebar() { })(); }, [ + archiveThread, attemptPin, attemptSettle, attemptSnooze, attemptUnpin, attemptUnsettle, attemptUnsnooze, + confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 93dc653e7c0..c839ddc3be7 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -9,6 +9,7 @@ const baseState: ThreadActionMenuState = { isSnoozed: false, canSnoozeNow: true, isRegeneratingTitle: false, + isRunning: false, supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true }, snoozePresets: [ { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, @@ -26,7 +27,7 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); + ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]); }); it("includes branch items only for threads with a branch", () => { @@ -63,4 +64,28 @@ describe("buildThreadActionMenuItems", () => { const items = buildThreadActionMenuItems({ ...baseState, branch: "main" }); expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true }); }); + + it("offers archive as a non-destructive action right before delete", () => { + const items = buildThreadActionMenuItems(baseState); + const archiveItem = items.at(-2); + expect(archiveItem?.id).toBe("archive"); + expect(archiveItem?.destructive).toBeFalsy(); + expect(items.at(-1)?.id).toBe("delete"); + }); + + it("keeps archive available even when the environment lacks every other capability", () => { + expect( + ids({ + ...baseState, + supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + }), + ).toContain("archive"); + }); + + it("disables archive while the thread is running", () => { + const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( + (item) => item.id === "archive", + ); + expect(archiveItem?.disabled).toBe(true); + }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index ef4b38dcdac..44c2e907ca5 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -21,6 +21,7 @@ export type ThreadActionMenuId = | "copy-path" | "copy-branch" | "copy-thread-id" + | "archive" | "delete"; export interface ThreadActionMenuState { @@ -30,6 +31,8 @@ export interface ThreadActionMenuState { readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; readonly isRegeneratingTitle: boolean; + /** Archive rejects a thread with an active turn, so disable it here rather than let the action fail. */ + readonly isRunning: boolean; readonly supports: { readonly settlement: boolean; readonly snooze: boolean; @@ -102,6 +105,12 @@ export function buildThreadActionMenuItems( { id: "copy-path", label: "Copy path", icon: "copy" }, ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, + // Archive removes the thread from the sidebar while keeping its + // conversation under Settings > Archived threads — distinct from Settle + // (stays visible in the Settled shelf) and Delete (clears history for + // good), so it sits beside Delete without borrowing its destructive + // styling. + { id: "archive", label: "Archive thread", disabled: state.isRunning }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index d7ca2305163..4a25df47b02 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -72,6 +72,7 @@ export function useThreadActionMenu(input: { unsnoozeThread, pinThread, unpinThread, + archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -82,6 +83,7 @@ export function useThreadActionMenu(input: { const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { @@ -139,6 +141,7 @@ export function useThreadActionMenu(input: { isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, + isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, supports, snoozePresets, }); @@ -253,6 +256,27 @@ export function useThreadActionMenu(input: { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast( + didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread", + squashAtomCommandFailure(result), + ); + } + return; + } case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -285,9 +309,11 @@ export function useThreadActionMenu(input: { })(); }, [ + archiveThread, autoSettleAfterDays, autoSettleOnMerge, changeRequestState, + confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, From 9f26656cb958853f90f7215387d604c098937db8 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 12:44:22 +0200 Subject: [PATCH 026/113] fix(web): open diff files from nested projects (#6174) --- apps/web/src/components/DiffPanel.tsx | 6 +- apps/web/src/diffFileActions.test.ts | 75 ++++++++++++++++++++++++- apps/web/src/diffFileActions.ts | 79 ++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index b929d05a719..66f0a4e111b 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -134,6 +134,9 @@ export default function DiffPanel({ : null, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; + const activeRepositoryRoot = activeThread?.worktreePath + ? undefined + : activeProject?.repositoryIdentity?.rootPath; const serverConfig = useAtomValue( serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), ); @@ -443,6 +446,7 @@ export default function DiffPanel({ threadRef: routeThreadRef, filePath, activeCwd, + repositoryRoot: activeRepositoryRoot, openInEditor: (targetPath) => { void (async () => { const result = await openInPreferredEditor(targetPath); @@ -462,7 +466,7 @@ export default function DiffPanel({ }, }); }, - [activeCwd, openInPreferredEditor, routeThreadRef], + [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef], ); const toggleDiffFileCollapsed = useCallback( (fileKey: string) => { diff --git a/apps/web/src/diffFileActions.test.ts b/apps/web/src/diffFileActions.test.ts index 9c358ab1d29..c5d3571a9c1 100644 --- a/apps/web/src/diffFileActions.test.ts +++ b/apps/web/src/diffFileActions.test.ts @@ -2,7 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { openDiffFilePrimaryAction } from "./diffFileActions"; +import { openDiffFilePrimaryAction, resolveDiffPathForWorkspace } from "./diffFileActions"; import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore"; const THREAD_REF = scopeThreadRef( @@ -48,4 +48,77 @@ describe("openDiffFilePrimaryAction", () => { "/repo/project/apps/web/src/components/DiffPanel.tsx", ); }); + + it("opens repository-relative diff files from a nested project", () => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath: "frontend/Dockerfile", + activeCwd: "/repo/frontend", + repositoryRoot: "/repo", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ + isOpen: true, + activeSurfaceId: "file:Dockerfile", + }); + expect(openInEditor).not.toHaveBeenCalled(); + }); + + it("preserves repository-relative paths in a separate worktree", () => { + expect( + resolveDiffPathForWorkspace({ + filePath: "frontend/Dockerfile", + workspaceRoot: "/worktrees/feature", + repositoryRoot: "/repo", + }), + ).toBe("frontend/Dockerfile"); + }); + + it("handles Windows roots and mixed diff separators", () => { + expect( + resolveDiffPathForWorkspace({ + filePath: "Frontend/src\\index.ts", + workspaceRoot: "C:\\repo\\frontend", + repositoryRoot: "C:\\repo", + }), + ).toBe("src/index.ts"); + }); + + it.each([ + { workspaceRoot: "/frontend", repositoryRoot: "/" }, + { workspaceRoot: "C:\\frontend", repositoryRoot: "C:\\" }, + ])("handles filesystem roots: $repositoryRoot", ({ workspaceRoot, repositoryRoot }) => { + expect( + resolveDiffPathForWorkspace({ + filePath: "frontend/index.ts", + workspaceRoot, + repositoryRoot, + }), + ).toBe("index.ts"); + }); + + it.each(["backend/server.ts", "frontend2/app.ts", "frontend/../secret.ts", "C:secret.ts"])( + "does not open an out-of-project diff path: %s", + (filePath) => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath, + activeCwd: "/repo/frontend", + repositoryRoot: "/repo", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ isOpen: false }); + expect(openInEditor).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/web/src/diffFileActions.ts b/apps/web/src/diffFileActions.ts index 335ad21fccf..3ac22c28cf2 100644 --- a/apps/web/src/diffFileActions.ts +++ b/apps/web/src/diffFileActions.ts @@ -1,4 +1,5 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; +import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { useRightPanelStore } from "./rightPanelStore"; import { resolvePathLinkTarget } from "./terminal-links"; @@ -7,19 +8,93 @@ interface OpenDiffFilePrimaryActionInput { readonly threadRef: ScopedThreadRef | null; readonly filePath: string; readonly activeCwd: string | undefined; + readonly repositoryRoot?: string | undefined; readonly openInEditor: (targetPath: string) => void; } +function normalizedRelativePathSegments(filePath: string): ReadonlyArray | null { + if (filePath.startsWith("/") || isWindowsAbsolutePath(filePath) || /^[a-zA-Z]:/.test(filePath)) { + return null; + } + + const segments = filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0 && segment !== "."); + if (segments.length === 0 || segments.includes("..")) return null; + return segments; +} + +function repositoryRelativeWorkspaceSegments( + workspaceRoot: string | undefined, + repositoryRoot: string | undefined, +): ReadonlyArray | null { + if (!workspaceRoot || !repositoryRoot) return null; + + const normalizedWorkspaceRoot = normalizeProjectPathForComparison(workspaceRoot); + const normalizedRepositoryRoot = normalizeProjectPathForComparison(repositoryRoot); + if (normalizedWorkspaceRoot === normalizedRepositoryRoot) return []; + + const separator = normalizedRepositoryRoot.includes("\\") ? "\\" : "/"; + const repositoryPrefix = normalizedRepositoryRoot.endsWith(separator) + ? normalizedRepositoryRoot + : `${normalizedRepositoryRoot}${separator}`; + if (!normalizedWorkspaceRoot.startsWith(repositoryPrefix)) return null; + + return normalizedWorkspaceRoot + .slice(repositoryPrefix.length) + .split(/[\\/]+/) + .filter(Boolean); +} + +export function resolveDiffPathForWorkspace(input: { + readonly filePath: string; + readonly workspaceRoot: string | undefined; + readonly repositoryRoot: string | undefined; +}): string | null { + const fileSegments = normalizedRelativePathSegments(input.filePath); + if (!fileSegments) return null; + + const workspaceSegments = repositoryRelativeWorkspaceSegments( + input.workspaceRoot, + input.repositoryRoot, + ); + if (!workspaceSegments || workspaceSegments.length === 0) { + return fileSegments.join("/"); + } + + const caseInsensitive = input.repositoryRoot + ? isWindowsAbsolutePath(input.repositoryRoot) + : false; + const belongsToWorkspace = workspaceSegments.every((segment, index) => { + const candidate = fileSegments[index]; + if (candidate === undefined) return false; + return caseInsensitive ? candidate.toLowerCase() === segment : candidate === segment; + }); + if (!belongsToWorkspace) return null; + + const relativeSegments = fileSegments.slice(workspaceSegments.length); + return relativeSegments.length > 0 ? relativeSegments.join("/") : null; +} + export function openDiffFilePrimaryAction({ threadRef, filePath, activeCwd, + repositoryRoot, openInEditor, }: OpenDiffFilePrimaryActionInput): void { + const workspaceFilePath = resolveDiffPathForWorkspace({ + filePath, + workspaceRoot: activeCwd, + repositoryRoot, + }); + if (!workspaceFilePath) return; + if (threadRef) { - useRightPanelStore.getState().openFile(threadRef, filePath); + useRightPanelStore.getState().openFile(threadRef, workspaceFilePath); return; } - openInEditor(activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath); + openInEditor(activeCwd ? resolvePathLinkTarget(workspaceFilePath, activeCwd) : workspaceFilePath); } From b277cc65e045899e7aa941e92d04f2b4996f27bb Mon Sep 17 00:00:00 2001 From: mohamedmastouri-hue Date: Sat, 15 Aug 2026 11:44:30 +0100 Subject: [PATCH 027/113] fix(mobile): use tryOpenExternalUrl for markdown links in ThreadFeed (#5872) Co-authored-by: codex Co-authored-by: Julius Marminge --- apps/mobile/src/features/threads/ThreadFeed.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d138bb0c99d..c5edb822ae5 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -28,7 +28,6 @@ import { import { ActivityIndicator, Image, - Linking, Platform, type LayoutChangeEvent, type NativeScrollEvent, @@ -51,6 +50,7 @@ import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, @@ -283,7 +283,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { { - void Linking.openURL(props.href); + void tryOpenExternalUrl(props.href, "markdown-link"); }} style={{ color: props.color, @@ -613,7 +613,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe onPress={ linkHref ? () => { - void Linking.openURL(linkHref); + void tryOpenExternalUrl(linkHref, "markdown-link"); } : undefined } @@ -1436,7 +1436,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } if (presentation.href) { - void Linking.openURL(presentation.href); + void tryOpenExternalUrl(presentation.href, "markdown-link"); } }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], From 2cb1a26f061fa9029ccbe2a614f02bb14b22dd45 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Sat, 15 Aug 2026 12:44:51 +0200 Subject: [PATCH 028/113] fix(web): open the file a bare filename reference names (#6297) Co-authored-by: Rodrigo Brechard Co-authored-by: Claude Opus 5 (1M context) --- apps/web/src/components/ChatMarkdown.tsx | 53 ++++++++++- apps/web/src/workspaceBasenameLookup.test.ts | 93 ++++++++++++++++++++ apps/web/src/workspaceBasenameLookup.ts | 48 ++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/workspaceBasenameLookup.test.ts create mode 100644 apps/web/src/workspaceBasenameLookup.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 294a9e22ad7..ec88bc912f0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -90,6 +90,13 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { projectEnvironment } from "../state/projects"; +import { + claimWorkspaceBasenameLookup, + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, + WORKSPACE_BASENAME_LOOKUP_LIMIT, +} from "../workspaceBasenameLookup"; import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; @@ -811,6 +818,7 @@ interface MarkdownFileLinkProps { theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen: (targetPath: string) => Promise>; + onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; onOpenInBrowser?: (() => Promise>) | undefined; className?: string | undefined; } @@ -1116,6 +1124,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ theme, threadRef, onOpen, + onOpenInPanel, onOpenInBrowser, className, }: MarkdownFileLinkProps) { @@ -1159,8 +1168,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInEditor(); return; } - useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line); - }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); + onOpenInPanel(workspaceRelativePath, line); + }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1336,6 +1345,7 @@ function areMarkdownFileLinkPropsEqual( previous.theme === next.theme && previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && + previous.onOpenInPanel === next.onOpenInPanel && previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); @@ -1355,6 +1365,9 @@ function ChatMarkdown({ const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); + const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { + reportFailure: false, + }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); @@ -1457,6 +1470,40 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + // A bare filename resolves to the workspace root, which is rarely where the + // file is, so ask the index before opening. + const openFileInPanel = useCallback( + (workspaceRelativePath: string, line: number | undefined) => { + if (!threadRef) return; + // Claimed on every open so a synchronous one supersedes a lookup already + // in flight. + const isLatestLookup = claimWorkspaceBasenameLookup(); + const openAt = (path: string) => + useRightPanelStore.getState().openFile(threadRef, path, line); + if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + openAt(workspaceRelativePath); + return; + } + void (async () => { + const result = await searchProjectEntries({ + environmentId: threadRef.environmentId, + input: { + cwd, + query: workspaceRelativePath, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + const match = + result._tag === "Success" + ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + : null; + if (!isLatestLookup()) return; + openAt(match ?? workspaceRelativePath); + })(); + }, + [cwd, searchProjectEntries, threadRef], + ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that * metadata changes. */ @@ -1490,6 +1537,7 @@ function ChatMarkdown({ theme={resolvedTheme} threadRef={threadRef} onOpen={openInPreferredEditor} + onOpenInPanel={openFileInPanel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -1718,6 +1766,7 @@ function ChatMarkdown({ isStreaming, markdownFileLinkMetaByHref, onTaskListChange, + openFileInPanel, openInPreferredEditor, openExternalLinkInPreview, openMarkdownFileInPreview, diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts new file mode 100644 index 00000000000..e96e5f18b4f --- /dev/null +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + claimWorkspaceBasenameLookup, + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, +} from "./workspaceBasenameLookup"; + +describe("needsWorkspaceBasenameLookup", () => { + it("flags bare filenames", () => { + expect(needsWorkspaceBasenameLookup("ChatView.tsx")).toBe(true); + expect(needsWorkspaceBasenameLookup("Makefile")).toBe(true); + }); + + it("leaves anything with a directory alone", () => { + expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup(" ")).toBe(false); + }); +}); + +describe("pickWorkspaceBasenameMatch", () => { + const entries = [ + { path: "apps/web/src/components/ChatView.test.tsx", kind: "file" as const }, + { path: "apps/web/src/components/ChatView.tsx", kind: "file" as const }, + ]; + + it("takes the first exact filename match, not the closest fuzzy one", () => { + expect(pickWorkspaceBasenameMatch("ChatView.tsx", entries)).toBe( + "apps/web/src/components/ChatView.tsx", + ); + }); + + it("ignores directories", () => { + expect( + pickWorkspaceBasenameMatch("components", [ + { path: "apps/web/src/components", kind: "directory" }, + { path: "apps/web/src/components/components", kind: "file" }, + ]), + ).toBe("apps/web/src/components/components"); + }); + + it("prefers the exactly-cased file over a case-only twin", () => { + expect( + pickWorkspaceBasenameMatch("foo.ts", [ + { path: "src/Foo.ts", kind: "file" }, + { path: "src/foo.ts", kind: "file" }, + ]), + ).toBe("src/foo.ts"); + }); + + it("falls back to case-insensitive when only the casing differs", () => { + expect(pickWorkspaceBasenameMatch("chatview.tsx", entries)).toBe( + "apps/web/src/components/ChatView.tsx", + ); + }); + + it("returns null when the case-insensitive fallback is ambiguous", () => { + expect( + pickWorkspaceBasenameMatch("FOO.ts", [ + { path: "src/Foo.ts", kind: "file" }, + { path: "src/foo.ts", kind: "file" }, + ]), + ).toBeNull(); + }); + + it("returns null when nothing matches the name", () => { + expect(pickWorkspaceBasenameMatch("ChatView.tsx", [])).toBeNull(); + expect( + pickWorkspaceBasenameMatch("ChatView.tsx", [ + { path: "apps/web/src/components/ChatHeader.tsx", kind: "file" }, + ]), + ).toBeNull(); + }); +}); + +describe("claimWorkspaceBasenameLookup", () => { + it("keeps only the newest claim, whatever order the lookups settle in", () => { + const first = claimWorkspaceBasenameLookup(); + const second = claimWorkspaceBasenameLookup(); + + // The older lookup answering last must not reopen the panel behind the + // newer one. + expect(second()).toBe(true); + expect(first()).toBe(false); + }); + + it("stays valid while it is the only claim", () => { + const only = claimWorkspaceBasenameLookup(); + expect(only()).toBe(true); + expect(only()).toBe(true); + }); +}); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts new file mode 100644 index 00000000000..b99d3ba4ded --- /dev/null +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -0,0 +1,48 @@ +// Enough hits to look past same-named neighbours (`ChatView.test.tsx`) without +// asking for a full listing on a single click. +export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25; + +// One counter for every caller: they all open the same panel, so the newest +// click wins regardless of which one started the lookup. +let latestLookupSequence = 0; + +/** Call the returned predicate when the search settles; false means a later click superseded it. */ +export function claimWorkspaceBasenameLookup(): () => boolean { + latestLookupSequence += 1; + const claimed = latestLookupSequence; + return () => claimed === latestLookupSequence; +} + +export interface WorkspaceEntryCandidate { + readonly path: string; + readonly kind: "file" | "directory"; +} + +function basenameOfPath(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; +} + +export function needsWorkspaceBasenameLookup(relativePath: string): boolean { + const trimmed = relativePath.trim(); + return trimmed.length > 0 && !trimmed.includes("/") && !trimmed.includes("\\"); +} + +export function pickWorkspaceBasenameMatch( + basename: string, + entries: ReadonlyArray, +): string | null { + const target = basename.trim(); + if (!target) return null; + const files = entries.filter((entry) => entry.kind === "file"); + const exact = files.find((entry) => basenameOfPath(entry.path) === target); + if (exact) return exact.path; + // Folded matching covers casing that drifted from disk, but `FOO.ts` against + // both `Foo.ts` and `foo.ts` has no right answer, so it resolves to nothing + // rather than opening whichever the index ranked first. + const folded = target.toLowerCase(); + const foldedMatches = files.filter( + (entry) => basenameOfPath(entry.path).toLowerCase() === folded, + ); + return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null; +} From ddee418a8d6d3e242ca26a8053a886ecc3b56b53 Mon Sep 17 00:00:00 2001 From: Ulises Britos <45952970+repparw@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:31:36 -0300 Subject: [PATCH 029/113] fix(server): stop the provider title mirror from overwriting real thread titles (#5941) --- .../Layers/ProviderCommandReactor.ts | 15 +---- .../Layers/ProviderRuntimeIngestion.test.ts | 57 ++++++++++++++-- .../Layers/ProviderRuntimeIngestion.ts | 15 +++-- apps/server/src/orchestration/threadTitles.ts | 13 ++++ .../provider/Layers/OpenCodeAdapter.test.ts | 67 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 20 +++++- packages/contracts/src/provider.ts | 1 + 7 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/orchestration/threadTitles.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ff639797179..cfc95f2613f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -39,6 +39,7 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { forkParked, ServerActivation } from "../../serverActivation.ts"; +import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { resolveSourceControlWriterModelSelection, ServerSettingsService, @@ -91,7 +92,6 @@ const turnStartKeyForEvent = (event: ProviderIntentEvent): string => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; -const DEFAULT_THREAD_TITLE = "New thread"; const MAX_REGENERATION_ATTACHMENTS = 4; const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000; @@ -227,18 +227,6 @@ export function providerErrorLabelFromInstanceHint(input: { ); } -function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { - const trimmedCurrentTitle = currentTitle.trim(); - if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { - return true; - } - - const trimmedTitleSeed = titleSeed?.trim(); - return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 - ? trimmedCurrentTitle === trimmedTitleSeed - : false; -} - function findProviderAdapterRequestError( cause: Cause.Cause, ): ProviderAdapterRequestError | undefined { @@ -626,6 +614,7 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + ...(thread.title ? { title: thread.title } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 258aa010e3e..449b1fbf513 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -48,6 +48,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; +import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -221,7 +222,10 @@ describe("ProviderRuntimeIngestion", () => { } }); - async function createHarness(options?: { serverSettings?: Partial }) { + async function createHarness(options?: { + serverSettings?: Partial; + threadTitle?: string; + }) { const workspaceRoot = makeTempDir("t3-provider-project-"); NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); @@ -277,7 +281,7 @@ describe("ProviderRuntimeIngestion", () => { commandId: CommandId.make("cmd-thread-create"), threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), - title: "Thread", + title: options?.threadTitle ?? "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -2915,7 +2919,7 @@ describe("ProviderRuntimeIngestion", () => { const thread = await waitForThread( harness.readModel, (entry) => - entry.title === "Renamed by provider" && + entry.title === "Thread" && entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "turn.plan.updated", ) && @@ -2930,7 +2934,7 @@ describe("ProviderRuntimeIngestion", () => { ), ); - expect(thread.title).toBe("Renamed by provider"); + expect(thread.title).toBe("Thread"); const planActivity = thread.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-turn-plan-updated", @@ -2971,6 +2975,51 @@ describe("ProviderRuntimeIngestion", () => { expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); }); + it("mirrors a provider title only while the thread still has the default title", async () => { + const harness = await createHarness({ threadTitle: DEFAULT_THREAD_TITLE }); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-default"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { + name: "Renamed by provider", + metadata: { source: "provider" }, + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => entry.title === "Renamed by provider", + ); + expect(thread.title).toBe("Renamed by provider"); + }); + + it("rejects a provider title once the thread has a real title", async () => { + const harness = await createHarness({ threadTitle: "User-set title" }); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-real"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { + name: "Renamed by provider", + metadata: { source: "provider" }, + }, + }); + + await harness.drain(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("User-set title"); + }); + it("projects context window updates into normalized thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 03253797242..c942960f3c6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -43,6 +43,7 @@ import { } from "../Services/ProviderRuntimeIngestion.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { canReplaceThreadTitle } from "../threadTitles.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`; @@ -1892,12 +1893,14 @@ const make = Effect.gen(function* () { } if (event.type === "thread.metadata.updated" && event.payload.name) { - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: yield* providerCommandId(event, "thread-meta-update"), - threadId: thread.id, - title: event.payload.name, - }); + if (canReplaceThreadTitle(thread.title)) { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* providerCommandId(event, "thread-meta-update"), + threadId: thread.id, + title: event.payload.name, + }); + } } if (event.type === "turn.diff.updated") { diff --git a/apps/server/src/orchestration/threadTitles.ts b/apps/server/src/orchestration/threadTitles.ts new file mode 100644 index 00000000000..c9a9c4f7283 --- /dev/null +++ b/apps/server/src/orchestration/threadTitles.ts @@ -0,0 +1,13 @@ +export const DEFAULT_THREAD_TITLE = "New thread"; + +export function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { + const trimmedCurrentTitle = currentTitle.trim(); + if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { + return true; + } + + const trimmedTitleSeed = titleSeed?.trim(); + return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 + ? trimmedCurrentTitle === trimmedTitleSeed + : false; +} diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1385ccbaabe..eea328e05d1 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1191,6 +1191,73 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("passes the thread title to session.create when provided", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-title-provided"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + title: "Investigate reconnect failures", + }); + + NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); + NodeAssert.equal( + runtimeMock.state.sessionCreateInputs[0]?.title, + "Investigate reconnect failures", + ); + }), + ); + + it.effect("does not mirror OpenCode's default placeholder session titles", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-placeholder-title"); + runtimeMock.state.subscribedEvents = [ + { + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "New session - 2026-08-09T10:20:30.456Z", + }, + }, + }, + { + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "Investigate reconnect failures", + }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const metadataUpdated = events.filter((event) => event.type === "thread.metadata.updated"); + NodeAssert.equal(metadataUpdated.length, 1); + if (metadataUpdated[0]?.type === "thread.metadata.updated") { + NodeAssert.equal(metadataUpdated[0].payload.name, "Investigate reconnect failures"); + } + }), + ); + it.effect("writes provider-native observability records using the session thread id", () => Effect.gen(function* () { const nativeEvents: Array<{ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 73c23b77e68..8f7e42c11d7 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -201,7 +201,24 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und return undefined; } - return trimText(event.properties.info.title); + const title = trimText(event.properties.info.title); + // OpenCode mints a placeholder title at session.create when no title was + // provided, and re-emits it on every `session.updated`. Mirroring it would + // overwrite the thread's real title (openCodeEventSessionTitle feeds the + // `thread.metadata.updated` mirror). Ignore OpenCode's auto-generated + // placeholders so the thread isn't locked onto them. + if (!title || isOpenCodeDefaultTitle(title)) { + return undefined; + } + + return title; +} + +const OPENCODE_DEFAULT_TITLE_PATTERN = + /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function isOpenCodeDefaultTitle(title: string): boolean { + return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); } interface OpenCodeSessionContext { @@ -1302,6 +1319,7 @@ export function makeOpenCodeAdapter( } const createdSession = yield* runOpenCodeSdk("session.create", () => client.session.create({ + ...(input.title ? { title: input.title } : {}), permission: buildOpenCodePermissionRules(input.runtimeMode), }), ); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 94fb007a7bc..c84ad43c4e7 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -56,6 +56,7 @@ export const ProviderSessionStartInput = Schema.Struct({ // See ProviderSession for the migration story. providerInstanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), + title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), From 178da6bc3210b82c4a83f33c8b149f623a3375e1 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 13:31:56 +0200 Subject: [PATCH 030/113] fix(shared): match source-control providers by DNS label (#6175) --- packages/shared/src/sourceControl.test.ts | 29 +++++++++++++++++++++++ packages/shared/src/sourceControl.ts | 10 +++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index bfee883dd9f..3842fa84b5a 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -91,4 +91,33 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { baseUrl: "https://self-hosted.example.test:8443", }); }); + + it("matches self-hosted providers by complete DNS labels", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://github.example.com/owner/repo.git")?.kind, + ).toBe("github"); + expect( + detectSourceControlProviderFromRemoteUrl("https://gitlab.example.com/group/repo.git")?.kind, + ).toBe("gitlab"); + expect( + detectSourceControlProviderFromRemoteUrl("https://bitbucket.example.com/workspace/repo.git") + ?.kind, + ).toBe("bitbucket"); + }); + + it("does not match provider names embedded in unrelated DNS labels", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://notgithub.example.com/owner/repo.git") + ?.kind, + ).toBe("unknown"); + expect( + detectSourceControlProviderFromRemoteUrl("https://notgitlab.example.com/group/repo.git") + ?.kind, + ).toBe("unknown"); + expect( + detectSourceControlProviderFromRemoteUrl( + "https://notbitbucket.example.com/workspace/repo.git", + )?.kind, + ).toBe("unknown"); + }); }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index a29fe968e44..ad6fa890bf2 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -167,12 +167,16 @@ function toBaseUrl(host: string): string { return `https://${host}`; } +function hasDnsLabel(host: string, label: string): boolean { + return host.split(".").includes(label); +} + function isGitHubHost(host: string): boolean { - return host === "github.com" || host.includes("github"); + return host === "github.com" || hasDnsLabel(host, "github"); } function isGitLabHost(host: string): boolean { - return host === "gitlab.com" || host.includes("gitlab"); + return host === "gitlab.com" || hasDnsLabel(host, "gitlab"); } function isAzureDevOpsHost(host: string): boolean { @@ -188,7 +192,7 @@ function isAzureDevOpsHost(host: string): boolean { } function isBitbucketHost(host: string): boolean { - return host === "bitbucket.org" || host.includes("bitbucket"); + return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); } export function detectSourceControlProviderFromRemoteUrl( From b7dbbbaf6c394621cba57cf58dfcc1845f445ef6 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:33:29 +0300 Subject: [PATCH 031/113] feat(desktop): Chrome-style hold-to-quit (#5508) --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/wsl.test.ts | 12 ++ apps/desktop/src/preload.ts | 11 + .../settings/DesktopClientSettings.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 14 ++ apps/desktop/src/window/DesktopWindow.ts | 40 +++- apps/desktop/src/window/QuitHold.test.ts | 201 ++++++++++++++++++ apps/desktop/src/window/QuitHold.ts | 148 +++++++++++++ apps/web/src/AppRoot.test.tsx | 4 +- apps/web/src/AppRoot.tsx | 2 + apps/web/src/components/QuitHoldOverlay.tsx | 47 ++++ .../components/settings/SettingsPanels.tsx | 29 +++ .../settings/settingsSearch.test.ts | 5 + .../src/components/settings/settingsSearch.ts | 17 +- packages/contracts/src/ipc.ts | 6 + packages/contracts/src/settings.ts | 4 + 16 files changed, 539 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/window/QuitHold.test.ts create mode 100644 apps/desktop/src/window/QuitHold.ts create mode 100644 apps/web/src/components/QuitHoldOverlay.tsx diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0e31082afb5..ac1ee879280 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -5,6 +5,7 @@ export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 3e07ae7f39b..38435e286fa 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; import * as DesktopState from "../../app/DesktopState.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; @@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( ElectronTheme.ElectronTheme, ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), ), + Layer.succeed( + ElectronDialog.ElectronDialog, + ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]), + ), + Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]), + ), + DesktopClientSettings.layerTest(), ); describe("WSL IPC", () => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 61e345b9084..cbbadb708ab 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -117,6 +117,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + onQuitShortcut: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { + if (state !== "down" && state !== "up") return; + listener(state); + }; + + ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); + }; + }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, onWindowFullscreenStateChange: (listener) => { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 44c12cc554a..23a75eb3f79 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + confirmQuit: true, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index ed0fbf8b568..42ba818acf5 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -37,6 +37,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -128,6 +130,14 @@ function makeFakeBrowserWindow() { }; } +const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ + get: Effect.succeed(Option.none()), +}); + +const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ + quit: Effect.void, +}); + const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { iconPaths: Effect.succeed({ ico: Option.none(), @@ -253,8 +263,10 @@ function makeTestLayer(input: { desktopAssetsLayer, desktopEnvironmentLayer, desktopAppSettingsLayer, + desktopClientSettingsLayer, desktopServerExposureLayer, DesktopState.layer, + electronAppLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: (url) => @@ -356,7 +368,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n desktopAssetsLayer, desktopEnvironmentLayer, DesktopAppSettings.layerTest(), + desktopClientSettingsLayer, desktopServerExposureLayer, + electronAppLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 2ae3d353279..9018b9b92c2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -8,6 +8,8 @@ import * as Ref from "effect/Ref"; import * as Electron from "electron"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; + import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; @@ -16,9 +18,16 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { + MENU_ACTION_CHANNEL, + QUIT_SHORTCUT_CHANNEL, + WINDOW_FULLSCREEN_STATE_CHANNEL, +} from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import { makeQuitHoldHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -51,6 +60,8 @@ type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets | DesktopAppSettings.DesktopAppSettings + | DesktopClientSettings.DesktopClientSettings + | ElectronApp.ElectronApp | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -261,6 +272,8 @@ export const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const electronApp = yield* ElectronApp.ElectronApp; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -533,7 +546,32 @@ export const make = Effect.gen(function* () { // close-terminal shortcut can outlive the terminal that handled its first // press, so reject repeats before they reach the native window accelerator. // Deliberate presses still flow through the renderer or native menu. + // Chrome-style hold-to-quit: intercept the quit accelerator before the + // native menu sees it and only quit after the shortcut is held. The + // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. + const quitHoldHandler = makeQuitHoldHandler({ + platform: environment.platform, + isEnabled: () => + runPromise( + Effect.map( + clientSettings.get, + Option.match({ + onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, + onSome: (settings) => settings.confirmQuit, + }), + ), + ), + notify: (state) => { + if (!window.isDestroyed()) { + window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); + } + }, + quit: () => { + void runPromise(electronApp.quit); + }, + }); window.webContents.on("before-input-event", (event, input) => { + quitHoldHandler(event, input); if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts new file mode 100644 index 00000000000..c900a865439 --- /dev/null +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + makeQuitHoldHandler, + QUIT_DOUBLE_TAP_MS, + QUIT_HOLD_DURATION_MS, + QUIT_HOLD_RELEASE_GRACE_MS, +} from "./QuitHold.ts"; +import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; + +function makeInput(overrides: Partial): QuitHoldKeyInput { + return { + type: "keyDown", + key: "q", + meta: true, + control: false, + alt: false, + shift: false, + isAutoRepeat: false, + ...overrides, + }; +} + +function makeHarness(options?: { + enabled?: boolean; + platform?: NodeJS.Platform; + isEnabled?: () => Promise; +}) { + const notifications: Array = []; + const quit = vi.fn(); + const handler = makeQuitHoldHandler({ + platform: options?.platform ?? "darwin", + isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), + notify: (state) => notifications.push(state), + quit, + }); + const preventDefault = vi.fn(); + const send = async (input: QuitHoldKeyInput) => { + handler({ preventDefault }, input); + // Let the isEnabled promise settle. + await Promise.resolve(); + await Promise.resolve(); + }; + // Simulates the OS auto-repeating the held shortcut every `intervalMs`. + const holdFor = async ( + durationMs: number, + repeatOverrides: Partial = {}, + intervalMs = 100, + ) => { + for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) { + vi.advanceTimersByTime(intervalMs); + await send(makeInput({ isAutoRepeat: true, ...repeatOverrides })); + } + }; + return { notifications, quit, preventDefault, send, holdFor }; +} + +describe("makeQuitHoldHandler", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows the hint on a tap without quitting, even when the release is never seen", async () => { + // macOS suppresses the letter's keyUp while Cmd is held, so a tap may + // produce no keyUp at all. Quit must still not fire. + const harness = makeHarness(); + await harness.send(makeInput({})); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual(["down"]); + + vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); + expect(harness.quit).not.toHaveBeenCalled(); + // The watchdog dismisses the hint once the press is clearly over. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("quits once the shortcut auto-repeats past the hold duration", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(QUIT_HOLD_DURATION_MS - 200); + expect(harness.quit).not.toHaveBeenCalled(); + await harness.holdFor(400); + expect(harness.quit).toHaveBeenCalledTimes(1); + // Exactly one hint cycle for the whole hold. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("does not quit when the hold stops before the duration", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(500); + await harness.send(makeInput({ type: "keyUp" })); + expect(harness.notifications).toEqual(["down", "up"]); + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("cancels the hold when the modifier is released first", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + expect(harness.notifications).toEqual(["down", "up"]); + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("quits immediately on a single press when disabled", async () => { + const harness = makeHarness({ enabled: false }); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + // The hint is dismissed in case the quit gets cancelled downstream. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("discards a stale isEnabled resolution from a superseded press", async () => { + // Press #1's isEnabled is still pending when the user releases and + // presses again; its late resolution must not act for press #2. + const resolvers: Array<(enabled: boolean) => void> = []; + const harness = makeHarness({ + isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + // Outside the double-tap window, so the second press starts a new hold. + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({})); + expect(resolvers).toHaveLength(2); + + // Press #1 resolves late with "disabled" — it must not quit press #2. + resolvers[0]?.(false); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.quit).not.toHaveBeenCalled(); + + // Press #2 resolves enabled and completes a full hold. + resolvers[1]?.(true); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); + + it("quits on a quick double tap, even when the first release was never seen", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); + + it("treats two slow taps as separate presses", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual(["down", "up", "down"]); + }); + + it("cancels the hold when another key interrupts it", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(500); + // Shift pressed mid-hold breaks the gesture... + await harness.send(makeInput({ shift: true })); + expect(harness.notifications).toEqual(["down", "up"]); + // ...so later repeats past the threshold must not quit. + await harness.holdFor(QUIT_HOLD_DURATION_MS); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("does not count an interrupted press toward a double tap", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ shift: true })); + // A fresh press right after the interruption starts a new hold, not a + // double-tap quit. + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual(["down", "up", "down"]); + }); + + it("ignores other shortcuts", async () => { + const harness = makeHarness(); + await harness.send(makeInput({ key: "w" })); + await harness.send(makeInput({ shift: true })); + await harness.send(makeInput({ meta: false })); + expect(harness.preventDefault).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([]); + }); + + it("uses control on non-mac platforms", async () => { + const harness = makeHarness({ platform: "linux" }); + await harness.send(makeInput({ meta: false, control: true })); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts new file mode 100644 index 00000000000..ea2fc7854ac --- /dev/null +++ b/apps/desktop/src/window/QuitHold.ts @@ -0,0 +1,148 @@ +// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. + +// Chrome-style hold-to-quit. The quit accelerator is intercepted in +// before-input-event (which runs before the native menu accelerator), and the +// app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS. +// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap +// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application +// menu itself is untouched and quits immediately. +export const QUIT_HOLD_DURATION_MS = 1200; +// A second quick tap of the shortcut is the user insisting: quit immediately. +export const QUIT_DOUBLE_TAP_MS = 500; +// "Still held" is proven by auto-repeat keydowns, not by the absence of a +// release: macOS suppresses a letter's keyUp while the command key is down, so +// a tap's release can go completely unseen and a release-based timer would +// quit anyway. The press is treated as released once no key event has arrived +// for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with +// auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit. +export const QUIT_HOLD_RELEASE_GRACE_MS = 600; + +export type QuitHoldState = "down" | "up"; + +export interface QuitHoldKeyInput { + readonly type: string; + readonly key: string; + readonly meta: boolean; + readonly control: boolean; + readonly alt: boolean; + readonly shift: boolean; + readonly isAutoRepeat: boolean; +} + +export interface QuitHoldOptions { + readonly platform: NodeJS.Platform; + readonly isEnabled: () => Promise; + readonly notify: (state: QuitHoldState) => void; + readonly quit: () => void; +} + +export function makeQuitHoldHandler( + options: QuitHoldOptions, +): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { + const modifierKey = options.platform === "darwin" ? "meta" : "control"; + let watchdog: NodeJS.Timeout | undefined; + let holding = false; + // Set once isEnabled resolves true; auto-repeats may only quit when armed. + let armed = false; + let heldSince = 0; + let lastPressAt = 0; + // Incremented on every new press and every release/quit so a pending + // isEnabled() resolution from a superseded press cannot arm (or quit for) + // the current one. + let generation = 0; + + const clearWatchdog = () => { + if (watchdog !== undefined) { + clearTimeout(watchdog); + watchdog = undefined; + } + }; + + const release = () => { + if (!holding) return; + generation += 1; + holding = false; + armed = false; + clearWatchdog(); + options.notify("up"); + }; + + // Dismisses the overlay first: if the quit is cancelled downstream the + // renderer must not be left with a stuck "Hold to Quit" hint. + const quitNow = () => { + release(); + options.quit(); + }; + + return (event, input) => { + const key = input.key.toLowerCase(); + if (input.type === "keyUp") { + if (key === "q" || key === modifierKey) release(); + return; + } + if (input.type !== "keyDown") return; + + const modifierDown = options.platform === "darwin" ? input.meta : input.control; + if (!modifierDown || input.alt || input.shift || key !== "q") { + // Any other key (or an extra modifier) pressed mid-hold breaks the + // gesture; without this the hold timer keeps running through the + // interruption and the next qualifying repeat would quit early. The + // interrupted press also stops counting toward a double tap — but only + // here, not in release(), which runs mid-restart on an unseen-release + // re-press and must not wipe that press's own tap timestamp. + if (holding && !input.isAutoRepeat) { + lastPressAt = 0; + release(); + } + return; + } + + event.preventDefault(); + + if (input.isAutoRepeat) { + if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { + quitNow(); + } + return; + } + + const now = Date.now(); + const previousPressAt = lastPressAt; + lastPressAt = now; + // A fresh keydown while "holding" means the key came back down after a + // release macOS never delivered — so both branches below see real taps. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { + quitNow(); + return; + } + if (holding) release(); + + generation += 1; + const pressGeneration = generation; + holding = true; + heldSince = now; + options.notify("down"); + void options.isEnabled().then( + (enabled) => { + if (generation !== pressGeneration) return; + if (!enabled) { + // Hold-to-quit disabled: a single press quits immediately. + quitNow(); + return; + } + armed = true; + // No auto-repeat by then means the key was released (possibly with a + // suppressed keyUp) or repeat is disabled; either way, don't quit. + watchdog = setTimeout(() => { + watchdog = undefined; + release(); + }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); + }, + // A failed settings read must never strand the quit request. + () => { + if (generation !== pressGeneration) return; + quitNow(); + }, + ); + }; +} diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index d6d7434769e..791004b74fa 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; +import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; import { AppRoot } from "./AppRoot"; @@ -16,9 +17,10 @@ describe("AppRoot", () => { const children = Children.toArray( (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(3); + expect(children).toHaveLength(4); expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); + expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay); }); }); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index b1fd21f84fa..857125c9fda 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; +import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; @@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { + ); } diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx new file mode 100644 index 00000000000..29c04401521 --- /dev/null +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; + +import { isMacPlatform } from "../lib/utils"; + +// Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint +// from a quick tap lingers for as long as a full hold would have taken. +const HIDE_AFTER_RELEASE_MS = 1200; + +/** + * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts + * the quit accelerator and pushes press/release states; a quick tap shows + * this pill while a full hold quits the app. + */ +export function QuitHoldOverlay() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const subscribe = window.desktopBridge?.onQuitShortcut; + if (!subscribe) return; + let hideTimer: number | undefined; + const unsubscribe = subscribe((state) => { + window.clearTimeout(hideTimer); + if (state === "down") { + setVisible(true); + return; + } + hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS); + }); + return () => { + window.clearTimeout(hideTimer); + unsubscribe(); + }; + }, []); + + if (!visible) return null; + const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; + return ( +
    +
    + Hold {shortcut} to Quit +
    +
    + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9df7f88ab1d..d57a4da1c2f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -526,11 +526,15 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit + ? ["Quit confirmation"] + : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ], [ isTextGenerationModelDirty, isBackgroundActivityDirty, + settings.confirmQuit, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -644,6 +648,7 @@ export function useSettingsRestore(onRestored?: () => void) { addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, @@ -2234,6 +2239,30 @@ export function GeneralSettingsPanel() { } /> + {isElectron ? ( + + updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) + } + /> + ) : null + } + control={ + updateSettings({ confirmQuit: Boolean(checked) })} + aria-label="Hold to quit" + /> + } + /> + ) : null} + { expect(searchSettings(" ", ITEMS)).toEqual([]); }); + it("hides desktop-only settings from browser search", () => { + expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); + expect(searchSettings("quit confirmation")).toEqual([]); + }); + it("keeps catalog result ids unique", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e0fc3d2f07e..e3aef670566 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,3 +1,5 @@ +import { isElectron } from "~/env"; + export type SettingsPath = | "/settings/general" | "/settings/appearance" @@ -12,6 +14,9 @@ export interface SettingsSearchItem { readonly title: string; readonly to: SettingsPath; readonly targetId?: string; + // Its row only renders in the desktop app, so a browser result would land on + // an anchor that isn't there. + readonly desktopOnly?: boolean; } /** @@ -149,6 +154,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Delete confirmation", to: "/settings/general", }, + { + id: "quit-confirmation", + title: "Hold to quit", + to: "/settings/general", + desktopOnly: true, + }, { id: "text-generation-model", title: "Text generation model", @@ -236,5 +247,9 @@ export function searchSettings( const normalizedQuery = normalizeSearchText(query); if (normalizedQuery.length === 0) return []; - return items.filter((item) => normalizeSearchText(item.title).includes(normalizedQuery)); + return items.filter( + (item) => + (isElectron || item.desktopOnly !== true) && + normalizeSearchText(item.title).includes(normalizedQuery), + ); } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 09d7d7a4602..3341c0bb062 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1080,6 +1080,12 @@ export interface DesktopBridge { */ probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; + /** + * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, + * "up" when it is released before the hold completes. Optional: older + * desktop builds never emit it. + */ + onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ee1970639ad..22ce210ed89 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -112,6 +112,9 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const ClientSettingsSchema = Schema.Struct({ + // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the + // app quits; a quick tap only shows a hint. Browser clients ignore it. + confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -756,6 +759,7 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + confirmQuit: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), From d94fbda344398bd266b9f6645e531eb17d3c9e4e Mon Sep 17 00:00:00 2001 From: Taras Date: Sat, 15 Aug 2026 14:37:02 +0300 Subject: [PATCH 032/113] fix(gitlab): submit review comments on context lines (#6348) --- .../BitbucketPullRequestApi.test.ts | 8 +- .../pullRequest/BitbucketPullRequestApi.ts | 16 +- .../pullRequest/GitHubPullRequestCli.test.ts | 2 +- .../pullRequest/GitLabPullRequestCli.test.ts | 9 +- .../src/pullRequest/GitLabPullRequestCli.ts | 21 +- .../pullRequest/PullRequestService.test.ts | 2 +- .../pullRequest/gitHubPullRequestJson.test.ts | 12 +- .../src/pullRequest/gitHubPullRequestJson.ts | 20 +- .../pullRequest/PullRequestCodeTab.tsx | 45 +++- .../pullRequestReviewStore.test.ts | 2 +- .../pullRequest/pullRequestReviewStore.ts | 11 +- apps/web/src/reviewCommentContext.ts | 221 ++++++++++++++++-- packages/contracts/src/pullRequest.ts | 23 +- 13 files changed, 338 insertions(+), 54 deletions(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 4120cf55e62..8945ecc5e1e 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -776,7 +776,13 @@ layer("BitbucketPullRequestApi.layer", (it) => { number: 7, verdict: "request-changes", body: "Two things.", - comments: [{ path: "src/a.ts", line: 12, side: "left", body: "why remove?" }], + comments: [ + { + path: "src/a.ts", + position: { kind: "deleted", oldLine: 12 }, + body: "why remove?", + }, + ], }); expect(callAt(0).url).toContain("/pullrequests/7/comments"); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index a2c57bfc5fd..5b3149b0d75 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -12,6 +12,7 @@ import type { PullRequestMergeMethod, PullRequestMergeability, PullRequestReviewCommentDraft, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -364,6 +365,19 @@ function mergeStrategy(method: PullRequestMergeMethod | undefined): string { } } +function bitbucketReviewPosition( + position: PullRequestReviewPosition, +): { readonly from: number } | { readonly to: number } { + switch (position.kind) { + case "added": + return { to: position.newLine }; + case "deleted": + return { from: position.oldLine }; + case "context": + return position.side === "left" ? { from: position.oldLine } : { to: position.newLine }; + } +} + export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; @@ -794,7 +808,7 @@ export const make = Effect.gen(function* () { content: { raw: comment.body }, inline: { path: comment.path, - ...(comment.side === "left" ? { from: comment.line } : { to: comment.line }), + ...bitbucketReviewPosition(comment.position), }, }), }), diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 848c4cd5ebc..d1af03db9d7 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1624,7 +1624,7 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, verdict: "approve", body: "Looks right.", - comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], }); expect(callAt(0).args).toEqual([ diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index c33e01c2d72..014d91a0274 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -1018,7 +1018,12 @@ layer("GitLabPullRequestCli.layer", (it) => { verdict: "approve", body: "Looks right.", comments: [ - { path: "src/b.ts", oldPath: "src/a.ts", line: 4, side: "left", body: "why remove?" }, + { + path: "src/b.ts", + oldPath: "src/a.ts", + position: { kind: "deleted", oldLine: 4 }, + body: "why remove?", + }, ], }); @@ -1197,7 +1202,7 @@ layer("GitLabPullRequestCli.layer", (it) => { number: 7, verdict: "comment", body: "", - comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], }), ); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 17c23bf86f4..9f968dddbbc 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -14,6 +14,7 @@ import type { PullRequestReaction, PullRequestReactionContent, PullRequestReviewCommentDraft, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -400,6 +401,22 @@ function projectPath(repository: string): string { return encodeURIComponent(repository.trim()); } +function gitLabReviewPositionLines( + position: PullRequestReviewPosition, +): + | { readonly new_line: number } + | { readonly old_line: number } + | { readonly old_line: number; readonly new_line: number } { + switch (position.kind) { + case "added": + return { new_line: position.newLine }; + case "deleted": + return { old_line: position.oldLine }; + case "context": + return { old_line: position.oldLine, new_line: position.newLine }; + } +} + function stateParam(state: PullRequestListState): string { // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed, // and it spans every state under `all`. @@ -1324,9 +1341,7 @@ export const make = Effect.gen(function* () { // draft carries the name the file had before the change. old_path: comment.oldPath ?? comment.path, new_path: comment.path, - ...(comment.side === "left" - ? { old_line: comment.line } - : { new_line: comment.line }), + ...gitLabReviewPositionLines(comment.position), }, }), }), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 243cfe06c21..456a5023b16 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1534,7 +1534,7 @@ it.effect("refuses line comments on a host that takes only a summary", () => number: 1, verdict: "comment", body: "", - comments: [{ path: "src/a.ts", line: 1, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 1 }, body: "nit" }], }), ); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index a3c3524a6d3..946394dcda8 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -1147,8 +1147,16 @@ describe("review submission payload", () => { verdict: "request-changes", body: "Two things.", comments: [ - { path: "src/a.ts", line: 12, side: "right", body: "rename this" }, - { path: "src/b.ts", line: 3, side: "left", body: "why remove?" }, + { + path: "src/a.ts", + position: { kind: "added", newLine: 12 }, + body: "rename this", + }, + { + path: "src/b.ts", + position: { kind: "deleted", oldLine: 3 }, + body: "why remove?", + }, ], }), ) as Record; diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index e113b87d81d..7b9ff9d41fe 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -17,6 +17,7 @@ import type { PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewDecision, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidate, @@ -933,6 +934,22 @@ export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: St } }`; +function gitHubReviewPosition(position: PullRequestReviewPosition): { + readonly line: number; + readonly side: "LEFT" | "RIGHT"; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "RIGHT" }; + case "deleted": + return { line: position.oldLine, side: "LEFT" }; + case "context": + return position.side === "left" + ? { line: position.oldLine, side: "LEFT" } + : { line: position.newLine, side: "RIGHT" }; + } +} + /** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ export function buildReviewSubmissionJson(input: { readonly verdict: PullRequestReviewVerdict; @@ -944,8 +961,7 @@ export function buildReviewSubmissionJson(input: { body: input.body, comments: input.comments.map((comment) => ({ path: comment.path, - line: comment.line, - side: comment.side === "left" ? ("LEFT" as const) : ("RIGHT" as const), + ...gitHubReviewPosition(comment.position), body: comment.body, })), }); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 71f3ffc7f37..776a4d67136 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -6,6 +6,7 @@ import type { PullRequestDiffSide, PullRequestOmittedFileStat, PullRequestRef, + PullRequestReviewPosition, PullRequestReviewThread, } from "@t3tools/contracts"; import { @@ -43,7 +44,11 @@ import { } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; -import { buildDiffReviewComment, type ReviewCommentContext } from "~/reviewCommentContext"; +import { + buildDiffReviewComment, + resolveDiffReviewPosition, + type ReviewCommentContext, +} from "~/reviewCommentContext"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -129,8 +134,7 @@ interface DraftAnchor { readonly path: string; /** What the file was called before the change, for the hosts that resolve a position by both. */ readonly oldPath: string | null; - readonly line: number; - readonly side: PullRequestDiffSide; + readonly position: PullRequestReviewPosition; /** The whole selection, which the comment collapses to one line but a question keeps. */ readonly range: SelectedLineRange; } @@ -148,8 +152,21 @@ function toViewerSide(side: PullRequestDiffSide) { return side === "left" ? ("deletions" as const) : ("additions" as const); } -function fromViewerSide(side: string | undefined): PullRequestDiffSide { - return side === "deletions" ? "left" : "right"; +function getReviewPositionAnchor(position: PullRequestReviewPosition): { + line: number; + side: PullRequestDiffSide; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "right" }; + case "deleted": + return { line: position.oldLine, side: "left" }; + case "context": + return { + line: position.side === "left" ? position.oldLine : position.newLine, + side: position.side, + }; + } } /** @@ -442,10 +459,14 @@ export function PullRequestCodeTab({ if (commit === null) { for (const comment of pendingComments) { if (comment.path !== path) continue; - groupAt(comment.side, comment.line).pending.push(comment); + const anchor = getReviewPositionAnchor(comment.position); + groupAt(anchor.side, anchor.line).pending.push(comment); } } - if (draft?.fileKey === fileKey) groupAt(draft.side, draft.line).draft = true; + if (draft?.fileKey === fileKey) { + const anchor = getReviewPositionAnchor(draft.position); + groupAt(anchor.side, anchor.line).draft = true; + } const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); @@ -594,12 +615,13 @@ export function PullRequestCodeTab({ // that silently lost its first line on the other hosts would be worse than one line. const path = resolveFileDiffPath(file); const previousPath = resolveFileDiffPreviousPath(file); + const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side); + if (position === null) return; setDraft({ fileKey: item.id, path, oldPath: previousPath === path ? null : previousPath, - line: range.end, - side: fromViewerSide(range.endSide ?? range.side), + position, range, }); }, @@ -842,7 +864,7 @@ export function PullRequestCodeTab({ {annotation.metadata.draft && draft ? ( { diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts index 8e207c2529b..41906a710fc 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts @@ -6,17 +6,10 @@ * hosts that have no pending review of their own. That also means a draft lives only as long * as the tab does, which is why this is deliberately not persisted. */ -import type { ProjectId, PullRequestDiffSide, PullRequestRef } from "@t3tools/contracts"; +import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; import { create } from "zustand"; -export interface PendingReviewComment { - readonly id: string; - readonly path: string; - /** The line in the file the comment's side names: the new file on the right, the old on the left. */ - readonly line: number; - readonly side: PullRequestDiffSide; - readonly body: string; -} +export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string }; /** * A counter rather than anything derived from the comment: two remarks on one line can be the diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index 7ce31997351..41f75eb384f 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -1,6 +1,15 @@ import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs"; +import type { PullRequestReviewPosition } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +const ReviewCommentSelectionSchema = Schema.Struct({ + start: Schema.Number, + side: Schema.Literals(["additions", "deletions"]), + end: Schema.Number, + endSide: Schema.Literals(["additions", "deletions"]), +}); +type ReviewCommentSelection = typeof ReviewCommentSelectionSchema.Type; + export const ReviewCommentContextSchema = Schema.Struct({ id: Schema.String, sectionId: Schema.String, @@ -12,6 +21,7 @@ export const ReviewCommentContextSchema = Schema.Struct({ text: Schema.String, diff: Schema.String, fenceLanguage: Schema.optional(Schema.String), + selection: Schema.optional(ReviewCommentSelectionSchema), }); export interface ReviewCommentContext { @@ -25,6 +35,7 @@ export interface ReviewCommentContext { readonly text: string; readonly diff: string; readonly fenceLanguage?: string | undefined; + readonly selection?: ReviewCommentSelection | undefined; } interface DiffReviewLine { @@ -267,10 +278,44 @@ function stripTrailingNewline(value: string): string { return value.endsWith("\n") ? value.slice(0, -1) : value; } -function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray { +function buildDiffReviewLines( + fileDiff: FileDiffMetadata, + includeExpandedContext: boolean, + slice?: { readonly startIndex: number; readonly endIndex: number }, +): ReadonlyArray { const rows: DiffReviewLine[] = []; + let rowIndex = 0; + let oldContextStart = 1; + let newContextStart = 1; + const pushRow = (row: DiffReviewLine) => { + if (!slice || (rowIndex >= slice.startIndex && rowIndex <= slice.endIndex)) { + rows.push(row); + } + rowIndex += 1; + }; + const pushContextGap = (oldStart: number, newStart: number, lineCount: number) => { + const count = Math.max(0, lineCount); + const firstOffset = slice ? Math.max(0, slice.startIndex - rowIndex) : 0; + const lastOffset = slice ? Math.min(count - 1, slice.endIndex - rowIndex) : count - 1; + for (let offset = firstOffset; offset <= lastOffset; offset += 1) { + rows.push({ + change: "context", + oldLineNumber: oldStart + offset, + newLineNumber: newStart + offset, + content: stripTrailingNewline(fileDiff.additionLines[newStart + offset - 1] ?? ""), + }); + } + rowIndex += count; + }; for (const hunk of fileDiff.hunks) { + if (includeExpandedContext) { + const oldHunkStart = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); + const newHunkStart = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); + const contextLines = Math.min(oldHunkStart - oldContextStart, newHunkStart - newContextStart); + pushContextGap(oldContextStart, newContextStart, contextLines); + } + let oldLineNumber = hunk.deletionStart; let newLineNumber = hunk.additionStart; let deletionLineIndex = hunk.deletionLineIndex; @@ -279,7 +324,7 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray, + fileDiff: FileDiffMetadata, lineNumber: number, side: SelectionSide | undefined, + includeExpandedContext = !fileDiff.isPartial, ): number { - const preferredKey = side === "deletions" ? "oldLineNumber" : "newLineNumber"; - const preferredIndex = lines.findIndex((line) => line[preferredKey] === lineNumber); - if (preferredIndex >= 0) return preferredIndex; - const fallbackKey = preferredKey === "oldLineNumber" ? "newLineNumber" : "oldLineNumber"; - return lines.findIndex((line) => line[fallbackKey] === lineNumber); + const findOnSide = (selectedSide: "left" | "right") => { + let rowIndex = 0; + let oldContextStart = 1; + let newContextStart = 1; + const findContextIndex = (oldStart: number, newStart: number, lineCount: number) => { + const count = Math.max(0, lineCount); + const selectedStart = selectedSide === "left" ? oldStart : newStart; + const offset = lineNumber - selectedStart; + return offset >= 0 && offset < count ? rowIndex + offset : -1; + }; + + for (const hunk of fileDiff.hunks) { + if (includeExpandedContext) { + const oldContextEnd = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); + const newContextEnd = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); + const contextLines = Math.min( + oldContextEnd - oldContextStart, + newContextEnd - newContextStart, + ); + const contextIndex = findContextIndex(oldContextStart, newContextStart, contextLines); + if (contextIndex >= 0) return contextIndex; + rowIndex += Math.max(0, contextLines); + } + + let oldLineNumber = hunk.deletionStart; + let newLineNumber = hunk.additionStart; + for (const segment of hunk.hunkContent) { + if (segment.type === "context") { + const contextIndex = findContextIndex(oldLineNumber, newLineNumber, segment.lines); + if (contextIndex >= 0) return contextIndex; + rowIndex += segment.lines; + oldLineNumber += segment.lines; + newLineNumber += segment.lines; + continue; + } + + if ( + selectedSide === "left" && + lineNumber >= oldLineNumber && + lineNumber < oldLineNumber + segment.deletions + ) { + return rowIndex + lineNumber - oldLineNumber; + } + rowIndex += segment.deletions; + oldLineNumber += segment.deletions; + + if ( + selectedSide === "right" && + lineNumber >= newLineNumber && + lineNumber < newLineNumber + segment.additions + ) { + return rowIndex + lineNumber - newLineNumber; + } + rowIndex += segment.additions; + newLineNumber += segment.additions; + } + + oldContextStart = hunk.deletionStart + hunk.deletionCount; + newContextStart = hunk.additionStart + hunk.additionCount; + if (hunk.deletionCount === 0) oldContextStart += 1; + if (hunk.additionCount === 0) newContextStart += 1; + } + + if (!includeExpandedContext) return -1; + const trailingLines = Math.min( + fileDiff.deletionLines.length - oldContextStart + 1, + fileDiff.additionLines.length - newContextStart + 1, + ); + return findContextIndex(oldContextStart, newContextStart, trailingLines); + }; + + const selectedSide = side === "deletions" ? "left" : "right"; + const preferredIndex = findOnSide(selectedSide); + return preferredIndex >= 0 + ? preferredIndex + : findOnSide(selectedSide === "left" ? "right" : "left"); +} + +/** Resolve the host-facing coordinates of a line selected in the diff viewer. */ +export function resolveDiffReviewPosition( + fileDiff: FileDiffMetadata, + lineNumber: number, + side: SelectionSide | undefined, +): PullRequestReviewPosition | null { + const lineIndex = findDiffReviewLineIndex(fileDiff, lineNumber, side); + if (lineIndex < 0) return null; + const line = buildDiffReviewLines(fileDiff, !fileDiff.isPartial, { + startIndex: lineIndex, + endIndex: lineIndex, + })[0]; + if (line === undefined) return null; + + switch (line.change) { + case "add": + return line.newLineNumber === null ? null : { kind: "added", newLine: line.newLineNumber }; + case "delete": + return line.oldLineNumber === null ? null : { kind: "deleted", oldLine: line.oldLineNumber }; + case "context": + return line.oldLineNumber === null || line.newLineNumber === null + ? null + : { + kind: "context", + oldLine: line.oldLineNumber, + newLine: line.newLineNumber, + side: side === "deletions" ? "left" : "right", + }; + } } function getDiffRange( @@ -416,18 +588,27 @@ export function buildDiffReviewComment(input: { range: SelectedLineRange; text: string; }): ReviewCommentContext | null { - const lines = buildDiffReviewLines(input.fileDiff); - const startIndex = findDiffReviewLineIndex(lines, input.range.start, input.range.side); + const includeExpandedContext = !input.fileDiff.isPartial; + const startIndex = findDiffReviewLineIndex( + input.fileDiff, + input.range.start, + input.range.side, + includeExpandedContext, + ); const endIndex = findDiffReviewLineIndex( - lines, + input.fileDiff, input.range.end, input.range.endSide ?? input.range.side, + includeExpandedContext, ); if (startIndex < 0 || endIndex < 0) return null; const normalizedStartIndex = Math.min(startIndex, endIndex); const normalizedEndIndex = Math.max(startIndex, endIndex); - const selectedLines = lines.slice(normalizedStartIndex, normalizedEndIndex + 1); + const selectedLines = buildDiffReviewLines(input.fileDiff, includeExpandedContext, { + startIndex: normalizedStartIndex, + endIndex: normalizedEndIndex, + }); const oldRange = getDiffRange(selectedLines, "oldLineNumber"); const newRange = getDiffRange(selectedLines, "newLineNumber"); @@ -445,6 +626,12 @@ export function buildDiffReviewComment(input: { ...selectedLines.map((line) => `${getDiffChangeMarker(line.change)}${line.content}`), ].join("\n"), fenceLanguage: "diff", + selection: { + start: input.range.start, + side: input.range.side ?? "additions", + end: input.range.end, + endSide: input.range.endSide ?? input.range.side ?? "additions", + }, }; } diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index d1b2ba705f5..dea49ea8fa5 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -856,6 +856,26 @@ export const PullRequestCommentUpdateInput = Schema.Struct({ }); export type PullRequestCommentUpdateInput = typeof PullRequestCommentUpdateInput.Type; +/** The coordinates of one line in a pull request diff. */ +export const PullRequestReviewPosition = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("added"), + newLine: PositiveInt, + }), + Schema.Struct({ + kind: Schema.Literal("deleted"), + oldLine: PositiveInt, + }), + Schema.Struct({ + kind: Schema.Literal("context"), + oldLine: PositiveInt, + newLine: PositiveInt, + /** Which copy of an unchanged line the reviewer selected in a split diff. */ + side: PullRequestDiffSide, + }), +]); +export type PullRequestReviewPosition = typeof PullRequestReviewPosition.Type; + /** One remark in a review that has not been sent yet, anchored to a line of the diff. */ export const PullRequestReviewCommentDraft = Schema.Struct({ path: TrimmedNonEmptyString, @@ -865,8 +885,7 @@ export const PullRequestReviewCommentDraft = Schema.Struct({ * the hosts that address a comment by one path ignore this. */ oldPath: Schema.optional(TrimmedNonEmptyString), - line: PositiveInt, - side: PullRequestDiffSide, + position: PullRequestReviewPosition, body: CommentBody, }); export type PullRequestReviewCommentDraft = typeof PullRequestReviewCommentDraft.Type; From db3278f97721f89b8b11a28bf59e59ce1fb68598 Mon Sep 17 00:00:00 2001 From: Nicolas Layne <49288482+NicL9923@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:39:05 -0500 Subject: [PATCH 033/113] fix(marketing): keep Grok mark clear of mobile hero copy (#4542) --- apps/marketing/src/pages/index.astro | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 20fae288279..4d43fc595c0 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -704,7 +704,7 @@ const mobileEndorsementRows = [ height: 44px; } - /* Three stacked on the left, two on the right — keeps the center CTA clear. */ + /* Three above the headline, two beside the CTA — keeps the copy clear. */ .hero-float-mark.hf-claude { top: 44px; left: 10px; @@ -712,8 +712,8 @@ const mobileEndorsementRows = [ } .hero-float-mark.hf-grok { - top: 240px; - left: 4px; + top: 44px; + left: calc(50% - 39px); right: auto; transform: rotate(-4deg); } @@ -741,8 +741,8 @@ const mobileEndorsementRows = [ @media (max-width: 340px) { .hero-float-mark.hf-grok { - top: 220px; - left: 0; + top: 57px; + left: calc(50% - 26px); width: 52px; height: 52px; border-radius: 14px; From 3bc4fdf05b6b748a7b506c81dc125f3504f35278 Mon Sep 17 00:00:00 2001 From: JJ <93147993+hey-jj@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:41:51 -0600 Subject: [PATCH 034/113] fix(mobile): recover the QR pairing scanner when camera access is denied (#6487) --- .../connection/ConnectionsNewRouteScreen.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 37d53cbd8ee..7fa3c691b44 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -3,7 +3,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Alert, Platform, ScrollView, View } from "react-native"; +import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -95,9 +95,21 @@ export function ConnectionsNewRouteScreen({ return; } + if (permission.canAskAgain) { + Alert.alert( + "Camera access needed", + "Allow camera access to scan an environment pairing QR code.", + ); + return; + } + Alert.alert( "Camera access needed", - "Allow camera access to scan an environment pairing QR code.", + "Camera access was denied for this app. Open Settings to enable it.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], ); }, [cameraPermission?.granted, requestCameraPermission]); From a38cac81d82b82a6967eaf8cb90ed2770c514f3c Mon Sep 17 00:00:00 2001 From: Simon Doba Date: Sat, 15 Aug 2026 13:42:08 +0200 Subject: [PATCH 035/113] fix(web): keep a long path from running under the folder picker button (#4823) Co-authored-by: Sy-D <8460326+Sy-D@users.noreply.github.com> Co-authored-by: Claude Opus 5 Co-authored-by: Julius Marminge Co-authored-by: codex --- .../components/CommandPalette.logic.test.ts | 30 +++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 13 ++++++++ apps/web/src/components/CommandPalette.tsx | 12 +++++--- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 06dabc5e849..17949b7c97c 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + browseInputEndPaddingClass, buildBrowseGroups, buildThreadActionItems, enumerateCommandPaletteItems, @@ -10,6 +11,35 @@ import { type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("browseInputEndPaddingClass", () => { + it("reserves the widest space for the create action", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: true, + hasHighlightedBrowseItem: false, + }), + ).toContain("pe-38"); + }); + + it("reserves space for the wider highlighted-item shortcut", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: false, + hasHighlightedBrowseItem: true, + }), + ).toContain("pe-30"); + }); + + it("keeps the compact reserve for the normal add action", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: false, + hasHighlightedBrowseItem: false, + }), + ).toContain("pe-24"); + }); +}); + describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 07e0e520d84..95d7a91b780 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,6 +15,19 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; +export function browseInputEndPaddingClass(input: { + readonly willCreateProjectPath: boolean; + readonly hasHighlightedBrowseItem: boolean; +}): string { + if (input.willCreateProjectPath) { + return "*:data-[slot=autocomplete-input]:pe-38!"; + } + if (input.hasHighlightedBrowseItem) { + return "*:data-[slot=autocomplete-input]:pe-30!"; + } + return "*:data-[slot=autocomplete-input]:pe-24!"; +} + /** * The global search overlay hosts three mutually exclusive surfaces: the * command palette (⌘K), the project file picker (⌘P), and project content diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 48471accb99..413ebca305f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -98,6 +98,7 @@ import { } from "../wslPaths"; import { ADDON_ICON_CLASS, + browseInputEndPaddingClass, buildBrowseGroups, buildProjectActionItems, buildRootGroups, @@ -2345,13 +2346,16 @@ function OpenCommandPaletteDialog(props: { footerTrailing={footerTrailing} inputAccessory={inputAccessory} inputProps={{ + // The submit button is absolutely positioned over the field, so the + // inner input must reserve enough room for the full action label. className: addProjectCloneFlow?.step === "repository" - ? "pe-32" + ? "*:data-[slot=autocomplete-input]:pe-32!" : isBrowsing - ? willCreateProjectPath - ? "pe-36" - : "pe-16" + ? browseInputEndPaddingClass({ + willCreateProjectPath, + hasHighlightedBrowseItem, + }) : undefined, placeholder: inputPlaceholder, wrapperClassName: isSubmenu From 270489b887420db3319898ab4046516e4c457711 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:44:37 +0200 Subject: [PATCH 036/113] fix(terminal): right-click paste works in the terminal (#5240) --- .../src/components/ThreadTerminalDrawer.tsx | 189 +++++++++++++++--- apps/web/src/hooks/useCopyToClipboard.ts | 44 ++++ apps/web/src/terminal/ghostty/surface.ts | 30 +++ 3 files changed, 230 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1266e5ed7e9..cf2adaca2cf 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -13,6 +13,7 @@ import { XIcon, } from "lucide-react"; import { + type ContextMenuItem, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -32,7 +33,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; -import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { @@ -255,6 +256,49 @@ export function terminalSelectionLineRange(position: { }; } +export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; + +/** Post-selection popup: just the two selection actions, always enabled. */ +export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { + return [ + { id: "add-to-chat", label: "Add to chat" }, + { id: "copy", label: "Copy" }, + ]; +} + +/** + * Right-click menu for the terminal canvas: the selection actions (disabled + * until a selection exists) plus Paste. Paste is always offered: the browser + * (and Electron's default editing menu) can only paste into an editable + * element, so a canvas terminal never gets a usable entry from them. + */ +export function terminalContextMenuItems(options: { + hasSelection: boolean; +}): ContextMenuItem[] { + return [ + ...terminalSelectionMenuItems().map((item) => ({ + ...item, + disabled: !options.hasSelection, + })), + { id: "paste", label: "Paste" }, + ]; +} + +/** + * An empty selection change may only cancel a selection-action flow that is + * still current: a pending popup timer, or an open popup whose request id has + * not been superseded. A popup already superseded by a right-click keeps its + * menu promise unsettled for a moment; treating it as active would cancel the + * newer context-menu flow instead. + */ +export function shouldClearTerminalSelectionAction(options: { + timerPending: boolean; + openMenuRequestId: number | null; + currentRequestId: number; +}): boolean { + return options.timerPending || options.openMenuRequestId === options.currentRequestId; +} + export function shouldHandleTerminalExit( current: TerminalSessionState["status"], synchronized: TerminalSessionState["status"], @@ -328,7 +372,10 @@ export function TerminalViewport({ const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); const selectionActionRequestIdRef = useRef(0); - const selectionActionMenuOpenRef = useRef(false); + // Holds the request id of the selection popup currently on screen, so a + // popup that was superseded (but whose menu promise has not settled yet) + // cannot be mistaken for the active flow. + const openSelectionMenuRequestIdRef = useRef(null); const selectionActionTimerRef = useRef(null); const keybindingsRef = useRef(keybindings); const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); @@ -443,6 +490,12 @@ export function TerminalViewport({ onSelectionChange: () => handleSelectionChange(), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), + // The surface listens from construction, so a right-click can land + // while `create` is still awaiting WASM — before the handler below it + // exists. The ref is only assigned once that setup has run. + onContextMenu: (event) => { + if (terminalRef.current) void showTerminalContextMenu(event); + }, }; const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); if (cancelled) { @@ -518,12 +571,98 @@ export function TerminalViewport({ }; }; + const addSelectionToChat = (selection: TerminalContextSelection) => { + handleAddTerminalContext(selection); + terminalRef.current?.clearSelection(); + terminalRef.current?.focus(); + }; + + // A selection-action flow that was superseded while its async work ran + // must go silent: no error message, no focus steal. + const reportIfCurrent = (requestId: number, error: unknown, fallback: string) => { + if (requestId !== selectionActionRequestIdRef.current) return; + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallback); + } + }; + + const focusIfCurrent = (requestId: number) => { + if (requestId === selectionActionRequestIdRef.current) { + terminalRef.current?.focus(); + } + }; + + const copySelection = async (text: string, requestId: number) => { + try { + await writeTextToClipboard(text, "terminal selection"); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to copy terminal selection"); + } + focusIfCurrent(requestId); + }; + + const pasteFromClipboard = async (requestId: number) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + try { + // The surface owns the read so it can claim the paste race before it + // starts: a paste shortcut fired while the menu read is in flight + // supersedes this paste instead of landing alongside it. + await activeTerminal.pasteFromClipboard( + () => readTextFromClipboard("terminal input"), + () => requestId === selectionActionRequestIdRef.current, + ); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to read the clipboard"); + return; + } + focusIfCurrent(requestId); + }; + + const showTerminalContextMenu = async (event: MouseEvent) => { + if (!localApi || !terminalRef.current) return; + // Own the gesture before anything async: leaving the default alive lets + // the browser (or Electron's editing menu) answer with a Paste entry + // that is permanently disabled over the terminal canvas. + event.preventDefault(); + // A right-click supersedes a selection popup that is pending or open. + clearSelectionAction(); + const selectionAction = readSelectionAction(); + const requestId = selectionActionRequestIdRef.current; + let clicked: TerminalContextMenuAction | null; + try { + clicked = await localApi.contextMenu.show( + terminalContextMenuItems({ hasSelection: selectionAction !== null }), + { x: event.clientX, y: event.clientY }, + ); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to open the terminal context menu"); + focusIfCurrent(requestId); + return; + } + if (requestId !== selectionActionRequestIdRef.current || clicked === null) { + return; + } + switch (clicked) { + case "add-to-chat": + if (selectionAction) addSelectionToChat(selectionAction.selection); + return; + case "copy": + if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); + return; + case "paste": + await pasteFromClipboard(requestId); + return; + } + }; + const showSelectionAction = async () => { if (!localApi) { clearSelectionAction(); return; } - if (selectionActionMenuOpenRef.current) { + if (openSelectionMenuRequestIdRef.current !== null) { return; } const nextAction = readSelectionAction(); @@ -532,45 +671,23 @@ export function TerminalViewport({ return; } const requestId = ++selectionActionRequestIdRef.current; - selectionActionMenuOpenRef.current = true; + openSelectionMenuRequestIdRef.current = requestId; const clicked = await localApi.contextMenu - .show( - [ - { id: "add-to-chat", label: "Add to chat" }, - { id: "copy", label: "Copy" }, - ], - nextAction.position, - ) + .show(terminalSelectionMenuItems(), nextAction.position) .finally(() => { - selectionActionMenuOpenRef.current = false; + if (openSelectionMenuRequestIdRef.current === requestId) { + openSelectionMenuRequestIdRef.current = null; + } }); if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } switch (clicked) { case "add-to-chat": - handleAddTerminalContext(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); + addSelectionToChat(nextAction.selection); return; case "copy": - try { - await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); - } catch (error) { - if (requestId !== selectionActionRequestIdRef.current) { - return; - } - const activeTerminal = terminalRef.current; - if (activeTerminal) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); - } - } - if (requestId === selectionActionRequestIdRef.current) { - terminalRef.current?.focus(); - } + await copySelection(nextAction.clipboardText, requestId); return; } }; @@ -684,11 +801,17 @@ export function TerminalViewport({ if (terminalRef.current?.hasSelection()) { return; } + const shouldClear = shouldClearTerminalSelectionAction({ + timerPending: selectionActionTimerRef.current !== null, + openMenuRequestId: openSelectionMenuRequestIdRef.current, + currentRequestId: selectionActionRequestIdRef.current, + }); + if (!shouldClear) return; clearSelectionAction(); // A copy shortcut that clears the selection (Ctrl+C) must also close // the context menu that appears with the selection, but a clear that // never opened a menu must not dismiss an unrelated one. - if (selectionActionMenuOpenRef.current) { + if (openSelectionMenuRequestIdRef.current !== null) { void localApi?.contextMenu.close(); } } diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 0129f2d6593..ef66410f7db 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -24,6 +24,29 @@ export class ClipboardWriteError extends Schema.TaggedErrorClass()( + "ClipboardReadUnavailableError", + { + target: Schema.String, + }, +) { + override get message(): string { + return `Clipboard API is unavailable while reading ${this.target}.`; + } +} + +export class ClipboardReadError extends Schema.TaggedErrorClass()( + "ClipboardReadError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.target} from the clipboard.`; + } +} + export async function writeTextToClipboard(value: string, target = "text") { if ( typeof window === "undefined" || @@ -48,6 +71,27 @@ export async function writeTextToClipboard(value: string, target = "text") { } } +export async function readTextFromClipboard(target = "text"): Promise { + if ( + typeof window === "undefined" || + typeof navigator === "undefined" || + !navigator.clipboard?.readText + ) { + throw new ClipboardReadUnavailableError({ + target, + }); + } + + try { + return await navigator.clipboard.readText(); + } catch (cause) { + throw new ClipboardReadError({ + target, + cause, + }); + } +} + export function useCopyToClipboard({ timeout = 2000, target = "text", diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 0bb33875568..2ac3c68d158 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -465,6 +465,12 @@ export interface GhosttyTerminalSurfaceOptions { readonly onSelectionChange: () => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; + /** + * A right-click the running application did not claim through mouse + * reporting. The host owns the menu, so it also owns preventing the browser + * default — whose Paste entry can never reach a canvas terminal. + */ + readonly onContextMenu?: (event: MouseEvent) => void; } export class GhosttyTerminalSurface { @@ -801,6 +807,28 @@ export class GhosttyTerminalSurface { this.input.focus({ preventScroll: true }); } + /** + * Pastes clipboard text read by the host (context menu) with the same + * bracketed-paste encoding as a native paste event. The read joins the same + * race the paste shortcut uses — the token is claimed before it starts — so + * a shortcut or native paste arriving during the read supersedes this one + * instead of both reaching the shell. + */ + async pasteFromClipboard( + readText: () => Promise, + isCurrent: () => boolean = () => true, + ): Promise { + const token = ++this.pasteShortcutToken; + const text = await readText(); + if (this.disposed || this.pasteShortcutToken !== token || !isCurrent()) return; + // As in every paste path, delivering bumps the token so a clipboard read + // still in flight cannot land after this text reaches the shell. + this.pasteShortcutToken += 1; + if (text.length === 0) return; + const encoded = this.core.encodePaste(text); + if (encoded.length > 0) this.options.onData(encoded); + } + hasSelection(): boolean { return this.core.selectionText().length > 0; } @@ -1373,7 +1401,9 @@ export class GhosttyTerminalSurface { private readonly onContextMenu = (event: MouseEvent) => { if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { event.preventDefault(); + return; } + this.options.onContextMenu?.(event); }; private readonly onScrollbarPointerDown = (event: PointerEvent) => { From 4db50757c0b618293997a7f81bcfe30b68356969 Mon Sep 17 00:00:00 2001 From: Daniel Vernon Date: Sat, 15 Aug 2026 13:00:18 +0100 Subject: [PATCH 037/113] fix(mobile): explain iOS-only settings on Android (#4981) --- .../SettingsRouteScreen.logic.test.ts | 19 +++++++++++++++++++ .../settings/SettingsRouteScreen.logic.ts | 8 ++++++++ .../features/settings/SettingsRouteScreen.tsx | 6 ++++++ .../settings/components/SettingsSwitchRow.tsx | 8 +++++++- 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts create mode 100644 apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts new file mode 100644 index 00000000000..aec583d67f7 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; + +describe("resolveAgentAwarenessPlatformPresentation", () => { + it("explains that agent awareness settings are unavailable on Android", () => { + expect(resolveAgentAwarenessPlatformPresentation("android")).toEqual({ + supported: false, + subtitle: "iOS only", + }); + }); + + it("leaves supported iOS settings unchanged", () => { + expect(resolveAgentAwarenessPlatformPresentation("ios")).toEqual({ + supported: true, + subtitle: undefined, + }); + }); +}); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts new file mode 100644 index 00000000000..94fa5965e99 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts @@ -0,0 +1,8 @@ +export function resolveAgentAwarenessPlatformPresentation(platform: string): { + readonly supported: boolean; + readonly subtitle: string | undefined; +} { + return platform === "ios" + ? { supported: true, subtitle: undefined } + : { supported: false, subtitle: "iOS only" }; +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index bcf2ce386d9..b0e851b59d8 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -46,6 +46,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -144,6 +145,7 @@ function ConfiguredSettingsRouteScreen() { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); + const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); @@ -473,10 +475,12 @@ function ConfiguredSettingsRouteScreen() { icon="bell.badge" label="Device Notifications" disabled={ + !agentAwarenessPlatform.supported || !agentAwarenessPushAvailable || notificationStatus === "checking" || notificationStatus === "unsupported" } + subtitle={agentAwarenessPlatform.subtitle} // Only reads as on when this device is actually registered with the // relay; otherwise notifications cannot be delivered regardless of // the local iOS permission. @@ -487,6 +491,7 @@ function ConfiguredSettingsRouteScreen() { /> void; }) { @@ -27,7 +28,12 @@ export function SettingsSwitchRow(props: { } > - {props.label} + + {props.label} + {props.subtitle ? ( + {props.subtitle} + ) : null} + Date: Sat, 15 Aug 2026 17:30:51 +0530 Subject: [PATCH 038/113] fix(web): stop counting a workflow coordinator as a working agent (#6672) --- .../src/state/subagentRuntime.test.ts | 42 +++++++++++++++++-- .../src/state/subagentRuntime.ts | 11 ++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index ceb40517550..ff0aea7c8a5 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -383,13 +383,49 @@ describe("deriveAgentPanelModel", () => { it("counts idle deliberately and waiting as active", () => { const model = deriveAgentPanelModel({ agents: roster }); expect(model.idleCount).toBe(1); - // wf-1 coordinator + member 1 running. - expect(model.runningCount).toBeGreaterThanOrEqual(1); + // Member 1 is running; the wf-1 coordinator is a container, not a worker. + expect(model.runningCount).toBe(1); + // Every agent lands in exactly one bucket, except coordinators that stand + // in for their members. expect(model.idleCount + model.runningCount + model.waitingCount + model.settledCount).toBe( - roster.length, + roster.length - 1, ); }); + it("omits a workflow coordinator from the working-agent count", () => { + const model = deriveAgentPanelModel({ agents: roster }); + // One member still running plus one idle direct spawn. The coordinator + // reports running for the whole workflow and must not inflate the banner. + expect(model.liveCount).toBe(1); + }); + + it("omits a finished workflow coordinator from the settled count", () => { + const finished = fold([ + activity("task.started", { taskId: "wf-2", taskType: "local_workflow", title: "sweep" }), + activity("task.progress", { + taskId: "wf-2:wf:0", + title: "sweep:a", + status: "completed", + parentAgentId: "wf-2", + agentIndex: 0, + phaseIndex: 0, + }), + activity("task.completed", { + taskId: "wf-2:wf:0", + status: "completed", + parentAgentId: "wf-2", + }), + activity("task.completed", { taskId: "wf-2", status: "completed" }), + ]); + + const model = deriveAgentPanelModel({ agents: finished }); + + // Only the member settled. The coordinator stands in for it, so counting + // both would report two finished agents where one ran. + expect(model.settledCount).toBe(1); + expect(model.liveCount).toBe(0); + }); + it("keeps direct spawns in first-seen order as their activity changes", () => { const directRoster = fold([ activity("task.started", { taskId: "direct-a", title: "First" }, "2026-08-01T11:00:00.000Z"), diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index e5f2b586b8c..c1ea1cc2b15 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -826,15 +826,16 @@ export function deriveAgentPanelModel({ let settledCount = 0; let totalTokens = 0; for (const agent of source) { + // A workflow coordinator with members is a container for those members, not + // work of its own: it reports running for the whole run and aggregates their + // usage upstream in some providers. Counting it would report one more agent + // working than there are, and double count tokens. + if (agent.kind === "workflow" && (members.get(agent.id) ?? []).length > 0) continue; if (agent.status === "running" || agent.status === "pending") runningCount += 1; else if (agent.status === "waiting") waitingCount += 1; else if (agent.status === "idle") idleCount += 1; else settledCount += 1; - // Workflow coordinators aggregate member usage upstream in some providers; - // avoid double counting by only summing leaf agents when members exist. - if (agent.kind !== "workflow" || (members.get(agent.id) ?? []).length === 0) { - totalTokens += agent.usage?.totalTokens ?? 0; - } + totalTokens += agent.usage?.totalTokens ?? 0; } return { From 6e6d1b49412d064ccbde7daae2e287f9b62efd7d Mon Sep 17 00:00:00 2001 From: Akshar Patel <123344143+AksharP5@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:00:53 -0400 Subject: [PATCH 039/113] fix(web): keep floating preview anchored after panel closes (#6547) --- .../preview/ThreadPreviewMiniPlayer.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 3e7c46ef0e0..2bdba1afe9e 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -2,7 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; -import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react"; +import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -17,6 +17,7 @@ import { clampPreviewMiniPlayerPosition, clampPreviewMiniPlayerSize, PREVIEW_MINI_PLAYER_DEFAULT_SIZE, + PREVIEW_MINI_PLAYER_EDGE_GAP, } from "./previewMiniPlayerLayout"; interface DragState { @@ -31,6 +32,8 @@ interface ResizeState { readonly pointerId: number; readonly pointerX: number; readonly pointerY: number; + readonly playerX: number; + readonly playerY: number; readonly width: number; readonly height: number; } @@ -45,6 +48,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const rootRef = useRef(null); const dragRef = useRef(null); const resizeRef = useRef(null); + const [defaultLayoutVersion, setDefaultLayoutVersion] = useState(""); const miniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), ); @@ -91,8 +95,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props bottomInset, ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); + if (!position) { + setDefaultLayoutVersion(`${parent.clientWidth}:${parent.clientHeight}`); + return; + } const next = clampPreviewMiniPlayerPosition( - position ?? { x: root.offsetLeft, y: root.offsetTop }, + position, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, @@ -159,11 +167,16 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const handleResizePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; const root = rootRef.current; - if (!root) return; + const parent = root?.offsetParent; + if (!root || !(parent instanceof HTMLElement)) return; + const rootRect = root.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); resizeRef.current = { pointerId: event.pointerId, pointerX: event.clientX, pointerY: event.clientY, + playerX: rootRect.left - parentRect.left, + playerY: rootRect.top - parentRect.top, width: root.offsetWidth, height: root.offsetHeight, }; @@ -194,7 +207,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); const nextPosition = clampPreviewMiniPlayerPosition( - position ?? { x: root.offsetLeft, y: root.offsetTop }, + { x: resize.playerX, y: resize.playerY }, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, @@ -222,8 +235,8 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props position ? { left: position.x, top: position.y, width: size.width, height: size.height } : { - right: 16, - top: 16, + right: PREVIEW_MINI_PLAYER_EDGE_GAP, + top: PREVIEW_MINI_PLAYER_EDGE_GAP, width: size.width, height: size.height, } @@ -290,7 +303,11 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props visible={Boolean(desktopOverlay?.hasWebContents)} cornerRadius={12} fitSourceContent - layoutVersion={position ? `${position.x}:${position.y}` : `initial:${bottomInset}`} + layoutVersion={ + position + ? `${position.x}:${position.y}` + : `initial:${bottomInset}:${defaultLayoutVersion}` + } className="absolute inset-0" />
    From a7c5ad5db167b3a172ccb26408b0638c99b2a459 Mon Sep 17 00:00:00 2001 From: Torben Wetter Date: Sat, 15 Aug 2026 14:01:08 +0200 Subject: [PATCH 040/113] fix(web): unstick /connect after in-modal sign-in by redirecting to the authorize endpoint (#5133) --- apps/web/src/cloud/connectCliAuth.test.ts | 24 +++++++++++++++ apps/web/src/cloud/connectCliAuth.ts | 17 +++++++++++ .../src/components/clerk/authRedirect.test.ts | 5 +++- apps/web/src/components/clerk/authRedirect.ts | 4 ++- .../cloud/ConnectCliAuthSurface.tsx | 29 +++++++++++++------ 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts index 59b443a49d9..3d41c416633 100644 --- a/apps/web/src/cloud/connectCliAuth.test.ts +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { buildConnectCliClerkAuthorizeUrl, + connectCliSignInRedirectUrl, hasConnectCliAuthConfig, readConnectCliCallbackResult, } from "./connectCliAuth"; @@ -69,6 +70,29 @@ describe("connectCliAuth", () => { ).toBeNull(); }); + it("sends the sign-in redirect to the authorize endpoint, not back to /connect", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + + const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + const redirectUrl = connectCliSignInRedirectUrl( + { state: "state-1", challenge: "challenge-1" }, + connectUrl, + ); + + expect(redirectUrl).not.toBe(connectUrl); + expect(new URL(redirectUrl).pathname).toBe("/oauth/authorize"); + }); + + it("falls back to the current URL when the authorize URL cannot be built", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + + const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + expect( + connectCliSignInRedirectUrl({ state: "state-1", challenge: "challenge-1" }, connectUrl), + ).toBe(connectUrl); + }); + it("reads the code and state Clerk echoes back to the callback", () => { expect( readConnectCliCallbackResult( diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 969215d97ad..815715da249 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -60,6 +60,23 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques }); } +/** + * Where Clerk sends the browser once the sign-in modal on /connect completes. + * It has to be the authorize endpoint rather than this page: /connect carries + * the CLI request in its fragment, so navigating back to the same URL is a + * same-document fragment navigation the browser never reloads — and Clerk + * treats any post-sign-in navigation as a page unload and skips the state emit + * that would otherwise re-render the surface, so the session never arrives + * either. Falls back to the current URL when the authorize URL cannot be + * built, which only happens on a deployment without the CLI OAuth config. + */ +export function connectCliSignInRedirectUrl( + request: ConnectAuthorizeRequest, + currentHref: string, +): string { + return buildConnectCliClerkAuthorizeUrl(request) ?? currentHref; +} + export function rememberConnectCliAuthState(state: string): void { try { window.sessionStorage.setItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY, state); diff --git a/apps/web/src/components/clerk/authRedirect.test.ts b/apps/web/src/components/clerk/authRedirect.test.ts index 140474120cc..e948d1d9c04 100644 --- a/apps/web/src/components/clerk/authRedirect.test.ts +++ b/apps/web/src/components/clerk/authRedirect.test.ts @@ -5,7 +5,10 @@ import { resolveClerkSignInProps } from "./authRedirect"; describe("resolveClerkSignInProps", () => { it("returns to the current browser URL on the web", () => { const href = "https://app.t3.codes/connect?state=state-1#details"; - expect(resolveClerkSignInProps(href, false)).toEqual({ forceRedirectUrl: href }); + expect(resolveClerkSignInProps(href, false)).toEqual({ + forceRedirectUrl: href, + signUpForceRedirectUrl: href, + }); }); it("removes a Clerk virtual pathname and callback params while preserving the desktop route", () => { diff --git a/apps/web/src/components/clerk/authRedirect.ts b/apps/web/src/components/clerk/authRedirect.ts index 251c5ee3650..e0b07241c06 100644 --- a/apps/web/src/components/clerk/authRedirect.ts +++ b/apps/web/src/components/clerk/authRedirect.ts @@ -15,5 +15,7 @@ export function resolveClerkSignInProps(href: string, isElectron: boolean): Cler signUpForceRedirectUrl: redirectUrl.toString(), }; } - return { forceRedirectUrl: href }; + // The sign-in modal can switch to sign-up, which follows its own redirect + // target; without one Clerk falls back to the URL the modal was opened from. + return { forceRedirectUrl: href, signUpForceRedirectUrl: href }; } diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index e47d8ddf7f7..5d5c280bb81 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -1,9 +1,10 @@ import { useAuth, useClerk, useUser } from "@clerk/react"; import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { buildConnectCliClerkAuthorizeUrl, + connectCliSignInRedirectUrl, readConnectCliAuthState, readConnectCliCallbackResult, rememberConnectCliAuthState, @@ -56,6 +57,21 @@ export function ConnectCliAuthorizeSurface() { const signInOpened = useRef(false); const redirecting = useRef(false); + const openSignIn = useCallback(() => { + if (!request) { + return; + } + // Clerk redirects to the authorize endpoint itself once sign-in completes, + // so the callback's state check has to be armed before handing off. + rememberConnectCliAuthState(request.state); + clerk.openSignIn( + resolveClerkSignInProps( + connectCliSignInRedirectUrl(request, window.location.href), + isElectron, + ), + ); + }, [clerk, request]); + useEffect(() => { if (!request || !isLoaded || redirecting.current) { return; @@ -63,7 +79,7 @@ export function ConnectCliAuthorizeSurface() { if (!isSignedIn) { if (!signInOpened.current) { signInOpened.current = true; - clerk.openSignIn(resolveClerkSignInProps(window.location.href, isElectron)); + openSignIn(); } return; } @@ -74,7 +90,7 @@ export function ConnectCliAuthorizeSurface() { redirecting.current = true; rememberConnectCliAuthState(request.state); window.location.assign(authorizeUrl); - }, [clerk, isLoaded, isSignedIn, request]); + }, [isLoaded, isSignedIn, openSignIn, request]); if (!request) { return ( @@ -101,12 +117,7 @@ export function ConnectCliAuthorizeSurface() { /> {isLoaded && !isSignedIn ? (
    -
    From 7afa184a99b266d466cc9517c147a75c3d839ad7 Mon Sep 17 00:00:00 2001 From: BootesVoid <78485654+AMohamedAakhil@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:31:11 +0530 Subject: [PATCH 041/113] fix(web): keep send reachable while a turn is running on mobile (#4781) Co-authored-by: AMohamedAakhil Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/components/chat/ChatComposer.tsx | 3 ++ ...est.ts => ComposerPrimaryActions.test.tsx} | 45 +++++++++++++++++++ .../chat/ComposerPrimaryActions.tsx | 27 ++++++++--- 3 files changed, 69 insertions(+), 6 deletions(-) rename apps/web/src/components/chat/{ComposerPrimaryActions.test.ts => ComposerPrimaryActions.test.tsx} (80%) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5072c5870a7..afba1e086b8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -407,6 +407,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -435,6 +436,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} + showSendWhileRunning={props.showSendWhileRunning ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} @@ -3166,6 +3168,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} + showSendWhileRunning={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx similarity index 80% rename from apps/web/src/components/chat/ComposerPrimaryActions.test.ts rename to apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 3dbcd39e9d1..c48f029f7f9 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -65,6 +65,28 @@ function renderStandaloneStop() { ); } +function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: true, + showPlanFollowUpPrompt: false, + promptHasText: hasSendableContent, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent, + showSendWhileRunning, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + function renderSendButton() { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { @@ -215,4 +237,27 @@ describe("ComposerPrimaryActions", () => { expect(markup).not.toContain("stage-nightly"); expect(markup).toContain("bg-message-action text-message-action-foreground"); }); + + it("only renders stop while running when Enter-to-send is available", () => { + const markup = renderRunningActions(false, true); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); + + it("renders send alongside stop while running when Enter-to-send is unavailable", () => { + const markup = renderRunningActions(true, true); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).toContain('aria-label="Send message"'); + expect(markup).toContain('type="submit"'); + expect(markup).toContain("size-9 sm:size-8"); + }); + + it("keeps stop as the only action while running with an empty composer", () => { + const markup = renderRunningActions(true, false); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index d8626496ae7..2a27796d92a 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -28,6 +28,9 @@ interface ComposerPrimaryActionsProps { isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + /** Enter-to-send is disabled on mobile viewports, where stop would otherwise + * be the only primary action and a running turn could not be steered. */ + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -68,6 +71,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, + showSendWhileRunning = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, @@ -86,7 +90,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ type="button" className={cn( "flex cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none", - insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8", + insidePendingAction + ? "size-8 sm:size-7" + : showSendWhileRunning && hasSendableContent + ? "size-9 sm:size-8" + : "size-8 sm:h-8 sm:w-8", )} {...pointerFocusProps} onClick={onInterrupt} @@ -153,10 +161,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - if (isRunning) { - return renderStopGenerationButton(false); - } - if (showPlanFollowUpPrompt) { if (promptHasText) { return ( @@ -214,7 +218,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - return ( + const sendButton = (
    ) : null} {terminalStatus ? ( @@ -867,6 +885,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; const driverKind = providerEntry?.driverKind ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); @@ -884,7 +905,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} environmentLabel={props.environmentLabel} - driverKind={driverKind} + providerEntry={providerEntry} + showInstanceBadge={showInstanceBadge} modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} @@ -1481,11 +1503,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} {driverKind ? ( - + ) : null} @@ -1542,7 +1572,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { }); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const driverKind = providerEntry?.driverKind ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); @@ -1600,7 +1632,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} environmentLabel={props.environmentLabel} - driverKind={driverKind} + providerEntry={providerEntry} + showInstanceBadge={showInstanceBadge} modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 05b44dcb732..df35cbd90e5 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -1,10 +1,14 @@ import { type ProviderInstanceId } from "@t3tools/contracts"; -import { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { memo, useLayoutEffect, useRef, useState } from "react"; import { SparklesIcon, StarIcon } from "lucide-react"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; +import { + isProviderInstancePickerReady, + shouldShowInstanceBadge, + type ProviderInstanceEntry, +} from "../../providerInstances"; /** * Build the hover tooltip for an instance button. Mirrors the old @@ -65,14 +69,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { const [hoveredInstanceId, setHoveredInstanceId] = useState(null); const sidebarContentRef = useRef(null); const [selectedIndicatorTop, setSelectedIndicatorTop] = useState(null); - const duplicateDriverCounts = useMemo(() => { - const counts = new Map(); - for (const entry of props.instanceEntries) { - counts.set(entry.driverKind, (counts.get(entry.driverKind) ?? 0) + 1); - } - return counts; - }, [props.instanceEntries]); - useLayoutEffect(() => { const content = sidebarContentRef.current; if (!content) { @@ -143,8 +139,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { const isSelected = props.selectedInstanceId === entry.instanceId; const isHovered = hoveredInstanceId === entry.instanceId; const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; - const showInstanceBadge = - Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; + const showInstanceBadge = shouldShowInstanceBadge(entry, props.instanceEntries); const tooltip = isUnavailable ? describeUnavailableInstance(entry) diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index a9b3a398115..bd374a0fd6f 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -16,7 +16,7 @@ import { getTriggerDisplayModelLabel, getTriggerDisplayModelName, } from "./providerIconUtils"; -import type { ProviderInstanceEntry } from "../../providerInstances"; +import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, ComposerControlChevron } from "./ComposerControl"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { @@ -67,10 +67,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { selectedInstanceOptions[0]; const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; - const duplicateDriverCount = props.instanceEntries.filter( - (entry) => activeEntry !== null && entry.driverKind === activeEntry.driverKind, - ).length; - const showInstanceBadge = Boolean(activeEntry?.accentColor) || duplicateDriverCount > 1; + const showInstanceBadge = + activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); const setIsMenuOpen = (open: boolean) => { props.onOpenChange?.(open); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 337e68d44d0..fd4ca7da92d 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -109,6 +109,23 @@ function driverKindLabel(driverKind: ProviderDriverKind): string { return PROVIDER_DISPLAY_NAMES[driverKind] ?? formatProviderDriverKindLabel(driverKind); } +/** + * Whether an instance's icon carries the account badge: accent color set, or + * several instances sharing a driver so the brand glyph alone is ambiguous. + * Shared by the composer trigger, the picker rail, and sidebar rows. + */ +export function shouldShowInstanceBadge( + entry: ProviderInstanceEntry, + entries: Iterable, +): boolean { + if (entry.accentColor) return true; + let sharedDriverCount = 0; + for (const candidate of entries) { + if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true; + } + return false; +} + export function normalizeProviderAccentColor(value: string | undefined): string | undefined { const trimmed = value?.trim(); if (!trimmed) return undefined; From c0f9d917c1ab08d30f2b3715dd25d2175a6d2ecf Mon Sep 17 00:00:00 2001 From: Ostap <33957189+ostapondo@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:06:53 +0200 Subject: [PATCH 063/113] fix(server): wait for concurrent SQLite writers instead of failing with SQLITE_BUSY (#5134) --- .../src/persistence/Layers/Sqlite.test.ts | 66 +++++++++++++++++++ apps/server/src/persistence/Layers/Sqlite.ts | 2 + 2 files changed, 68 insertions(+) create mode 100644 apps/server/src/persistence/Layers/Sqlite.test.ts diff --git a/apps/server/src/persistence/Layers/Sqlite.test.ts b/apps/server/src/persistence/Layers/Sqlite.test.ts new file mode 100644 index 00000000000..0b64e4f7fdc --- /dev/null +++ b/apps/server/src/persistence/Layers/Sqlite.test.ts @@ -0,0 +1,66 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory, makeSqlitePersistenceLive } from "./Sqlite.ts"; + +const lockHolderSource = ` +const { DatabaseSync } = require("node:sqlite"); +const db = new DatabaseSync(process.argv[1]); +db.exec("BEGIN IMMEDIATE"); +process.stdout.write("locked\\n"); +setTimeout(() => { + db.exec("COMMIT"); + db.close(); +}, Number(process.argv[2])); +`; + +const spawnWriteLockHolder = (dbPath: string, holdMs: number) => + Effect.promise( + () => + new Promise((resolve, reject) => { + const holder = NodeChildProcess.spawn( + process.execPath, + ["-e", lockHolderSource, dbPath, String(holdMs)], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + holder.stdout.once("data", () => resolve()); + holder.on("error", reject); + holder.on("exit", () => + reject(new Error("lock holder exited before acquiring the write lock")), + ); + }), + ); + +it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`; + yield* spawnWriteLockHolder(dbPath, 300); + yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`; + const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`; + assert.deepEqual([...rows], [{ id: 1 }]); + }).pipe( + Effect.provide(makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + +it.effect("applies busy_timeout in the shared persistence setup", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`; + assert.equal(rows[0]?.timeout, 5000); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index d1e00250126..ec1ffdefac0 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -33,6 +33,8 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* ( const setup = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY. + yield* sql`PRAGMA busy_timeout = 5000;`; yield* sql`PRAGMA foreign_keys = ON;`; yield* sql`PRAGMA journal_mode = WAL;`; yield* runMigrations(); From 7c55e86320aac9c68ae53a7bc15682b7e14f98bf Mon Sep 17 00:00:00 2001 From: Naveed Iqbal Date: Sat, 15 Aug 2026 17:07:07 +0500 Subject: [PATCH 064/113] fix(web): reject oversized prompts before provider turn start (#6602) --- apps/web/src/components/ChatView.tsx | 57 +++--- apps/web/src/components/chat/ChatComposer.tsx | 67 ++++++- .../ComposerPromptLengthValidation.test.tsx | 23 +++ .../chat/ComposerPromptLengthValidation.tsx | 13 ++ .../chat/composerSubmission.test.ts | 170 ++++++++++++++++++ .../src/components/chat/composerSubmission.ts | 44 +++++ docs/user/composer.md | 5 + 7 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx create mode 100644 apps/web/src/components/chat/ComposerPromptLengthValidation.tsx create mode 100644 apps/web/src/components/chat/composerSubmission.test.ts create mode 100644 apps/web/src/components/chat/composerSubmission.ts create mode 100644 docs/user/composer.md diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5bde6345c..cb79f1f7e21 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4994,6 +4994,16 @@ function ChatViewContent(props: ChatViewProps) { draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); + const outgoingFollowUpText = formatOutgoingPrompt({ + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, + text: followUp.text.trim(), + }); + if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { + return; + } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -5063,24 +5073,6 @@ function ChatViewContent(props: ChatViewProps) { return; } - sendInFlightRef.current = true; - if (isDraftHeroState && activeThreadKey) { - let resolveDockStarted: (() => void) | undefined; - const dockStarted = new Promise((resolve) => { - resolveDockStarted = resolve; - }); - const dockTransition = runMobileComposerTransition(() => { - flushSync(() => { - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); - }); - resolveDockStarted?.(); - }); - void dockTransition.catch(() => resolveDockStarted?.()); - await dockStarted; - } - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); - const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; @@ -5098,8 +5090,6 @@ function ChatViewContent(props: ChatViewProps) { messageTextWithPreviewAnnotations, composerReviewCommentsSnapshot, ); - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -5107,6 +5097,30 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); + if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { + return; + } + + sendInFlightRef.current = true; + if (isDraftHeroState && activeThreadKey) { + let resolveDockStarted: (() => void) | undefined; + const dockStarted = new Promise((resolve) => { + resolveDockStarted = resolve; + }); + const dockTransition = runMobileComposerTransition(() => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }); + void dockTransition.catch(() => resolveDockStarted?.()); + await dockStarted; + } + beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); + + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -5723,6 +5737,9 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: implementationPrompt, }); + if (composerRef.current?.validateProviderInput(outgoingImplementationPrompt) === false) { + return; + } const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5a01c5c7643..293767a7390 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -107,6 +107,12 @@ import { buildExpandedImagePreview, type ExpandedImagePreview } from "./Expanded import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; +import { + getComposerPromptLengthValidationMessage, + getComposerSubmissionValidationMessage, + submitComposerDraft, +} from "./composerSubmission"; +import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; type ComposerCommandMenuPosition = { bottom: number; @@ -488,6 +494,8 @@ export interface ChatComposerHandle { selectedModel: string; selectedProviderModels: ReadonlyArray; }; + /** Validate the fully composed text immediately before a provider turn starts. */ + validateProviderInput: (providerInput: string) => boolean; } // -------------------------------------------------------------------------- @@ -951,6 +959,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [composerSubmissionError, setComposerSubmissionError] = useState(null); + const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( + null, + ); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ @@ -967,6 +979,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); + const providerInputRejectedRef = useRef(false); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); @@ -1309,6 +1322,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt, promptRef]); + useEffect(() => { + if (composerSubmissionError === null) return; + const nextError = getComposerPromptLengthValidationMessage(prompt); + if (nextError !== composerSubmissionError) { + setComposerSubmissionError(nextError); + } + }, [composerSubmissionError, prompt]); + + useEffect(() => { + setProviderInputSubmissionError(null); + }, [ + composerElementContexts, + composerPreviewAnnotations, + composerReviewComments, + composerTerminalContexts, + prompt, + selectedModel, + selectedPromptEffort, + selectedProvider, + ]); + useEffect(() => { composerImagesRef.current = composerImages; }, [composerImages, composerImagesRef]); @@ -1400,6 +1434,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ useEffect(() => { setComposerHighlightedItemId(null); + setComposerSubmissionError(null); + setProviderInputSubmissionError(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); setIsDragOverComposer(false); @@ -1826,17 +1862,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } - onSend(event); + const submission = submitComposerDraft({ + prompt: promptRef.current, + submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", + event, + onSend: (sendEvent) => { + // ChatView reports its final composed-input preflight through the + // composer handle before its first asynchronous send step. + providerInputRejectedRef.current = false; + onSend(sendEvent); + return !providerInputRejectedRef.current; + }, + }); + setComposerSubmissionError(submission.validationMessage); + if (!submission.didDispatch) return; if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, [ activeThreadId, + activePendingProgress, blurMobileComposerAfterSend, isSendDisabled, noProviderAvailable, onSend, + promptRef, shouldBlurMobileComposerOnSubmit, ], ); @@ -2590,6 +2641,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedProviderModels, }), + validateProviderInput: (providerInput: string) => { + const validationMessage = getComposerSubmissionValidationMessage({ + prompt: promptRef.current, + providerInput, + submissionTarget: "provider-turn", + }); + providerInputRejectedRef.current = validationMessage !== null; + setProviderInputSubmissionError(validationMessage); + return validationMessage === null; + }, }), [ activeThread, @@ -3058,6 +3119,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
+ + {/* Bottom toolbar */} {isComposerCollapsedMobile ? null : activePendingApproval ? (
diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx new file mode 100644 index 00000000000..3ffb4fa9c20 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx @@ -0,0 +1,23 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { getComposerPromptLengthValidationMessage } from "./composerSubmission"; +import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; + +describe("ComposerPromptLengthValidation", () => { + it("renders oversized prompt feedback as an actionable composer alert", () => { + const message = getComposerPromptLengthValidationMessage( + "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1), + ); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('role="alert"'); + expect(markup).toContain('data-chat-composer-validation="prompt-length"'); + expect(markup).toContain( + "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", + ); + expect(markup).not.toContain("ProviderValidationError"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx new file mode 100644 index 00000000000..88e4c3b813e --- /dev/null +++ b/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx @@ -0,0 +1,13 @@ +export function ComposerPromptLengthValidation({ message }: { message: string | null }) { + if (!message) return null; + + return ( +

+ {message} +

+ ); +} diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts new file mode 100644 index 00000000000..239db28a600 --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -0,0 +1,170 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { submitComposerDraft } from "./composerSubmission"; + +describe("submitComposerDraft", () => { + it("keeps an oversized draft editable and sends a corrected follow-up", () => { + let draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + let validationMessage: string | null = null; + const dispatchedDrafts: string[] = []; + const preventDefault = vi.fn(); + + const submit = () => { + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend: () => { + dispatchedDrafts.push(draft); + }, + }); + validationMessage = result.validationMessage; + }; + + submit(); + + expect(dispatchedDrafts).toEqual([]); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + expect(validationMessage).toBe( + "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", + ); + expect(preventDefault).toHaveBeenCalledOnce(); + + draft = "Corrected prompt"; + submit(); + + expect(dispatchedDrafts).toEqual(["Corrected prompt"]); + expect(validationMessage).toBeNull(); + }); + + it("allows a draft at the shared character limit through the normal send path", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("blocks when appended context pushes the provider input over the shared limit", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + providerInput: `${draft}\n\nTerminal context`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ + validationMessage: + "Prompt is 18 characters over the 120,000-character limit. Shorten or split it before sending.", + didDispatch: false, + }); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + expect(onSend).not.toHaveBeenCalled(); + + const correctedResult = submitComposerDraft({ + prompt: "Corrected prompt", + providerInput: "Corrected prompt\n\nShort terminal context", + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(correctedResult).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("does not finish submission when the send boundary rejects composed provider input", () => { + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Sendable raw draft", + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend: () => false, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: false }); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("allows fully composed provider input at the shared character limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Short draft", + providerInput: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("blocks a generated plan follow-up that exceeds the shared limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "", + providerInput: `PLEASE IMPLEMENT THIS PLAN:\n${"x".repeat( + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + )}`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result.didDispatch).toBe(false); + expect(result.validationMessage).toContain("over the 120,000-character limit"); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("allows surrounding whitespace that the provider turn contract trims", () => { + const draft = ` ${"x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)} `; + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("dispatches pending user input answers on their separate response path", () => { + const answer = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: answer, + submissionTarget: "pending-user-input", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts new file mode 100644 index 00000000000..528ac75bcab --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -0,0 +1,44 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; + +type ComposerSubmitEvent = { preventDefault: () => void }; + +type ComposerSubmissionInput = { + prompt: string; + providerInput?: string; + submissionTarget: "provider-turn" | "pending-user-input"; +}; + +export function getComposerPromptLengthValidationMessage(prompt: string): string | null { + const excessCharacters = prompt.trim().length - PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + if (excessCharacters <= 0) return null; + + const characterLabel = excessCharacters === 1 ? "character" : "characters"; + return `Prompt is ${excessCharacters.toLocaleString("en-US")} ${characterLabel} over the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS.toLocaleString("en-US")}-character limit. Shorten or split it before sending.`; +} + +export function getComposerSubmissionValidationMessage( + options: ComposerSubmissionInput, +): string | null { + return options.submissionTarget === "provider-turn" + ? getComposerPromptLengthValidationMessage(options.providerInput ?? options.prompt) + : null; +} + +export function submitComposerDraft( + options: ComposerSubmissionInput & { + event: ComposerSubmitEvent | undefined; + onSend: (event?: ComposerSubmitEvent) => boolean | void; + }, +): { validationMessage: string | null; didDispatch: boolean } { + const validationMessage = getComposerSubmissionValidationMessage(options); + if (validationMessage) { + options.event?.preventDefault(); + return { validationMessage, didDispatch: false }; + } + + if (options.onSend(options.event) === false) { + options.event?.preventDefault(); + return { validationMessage: null, didDispatch: false }; + } + return { validationMessage: null, didDispatch: true }; +} diff --git a/docs/user/composer.md b/docs/user/composer.md new file mode 100644 index 00000000000..d2e49db247b --- /dev/null +++ b/docs/user/composer.md @@ -0,0 +1,5 @@ +# Message composer + +Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the +composer and shows how many characters need to be removed. Shorten the draft or split it into +multiple messages, then send again in the same thread. From 40ab7bf32a81a66b20571ed280dc238c2276dc61 Mon Sep 17 00:00:00 2001 From: Jaroslav Brtis <6890442+Jardo-51@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:07:09 +0200 Subject: [PATCH 065/113] feat(web): collapse the question prompt from its header (#6773) Co-authored-by: Claude Opus 5 --- .../ComposerPendingUserInputPanel.test.tsx | 61 ++++++ .../chat/ComposerPendingUserInputPanel.tsx | 196 +++++++++++------- 2 files changed, 186 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx new file mode 100644 index 00000000000..817182190b7 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx @@ -0,0 +1,61 @@ +import { ApprovalRequestId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; +import type { PendingUserInput } from "../../session-logic"; + +const prompt: PendingUserInput = { + requestId: ApprovalRequestId.make("request-1"), + createdAt: "2026-08-15T00:00:00.000Z", + questions: [ + { + id: "question-1", + header: "Approach", + question: "Which approach should the migration take?", + options: [ + { label: "Incremental", description: "Move one module at a time" }, + { label: "Big bang", description: "Move everything in one release" }, + ], + multiSelect: false, + }, + ], +}; + +function renderPanel() { + return renderToStaticMarkup( + {}} + onAdvance={() => {}} + />, + ); +} + +describe("ComposerPendingUserInputPanel", () => { + it("renders the header as a disclosure control for the question body", () => { + const markup = renderPanel(); + + const toggle = markup.match(/]*data-pending-user-input-toggle="[^"]*"[^>]*>/)?.[0]; + expect(toggle).toBeDefined(); + expect(toggle).toContain('data-pending-user-input-toggle="expanded"'); + expect(toggle).toContain('aria-expanded="true"'); + expect(toggle).toContain('type="button"'); + + const controlledId = toggle?.match(/aria-controls="([^"]+)"/)?.[1]; + expect(controlledId).toBeDefined(); + expect(markup).toMatch(new RegExp(`]*\\sid="${controlledId}"`)); + }); + + it("starts expanded so the question and its options are visible", () => { + const markup = renderPanel(); + + expect(markup).toContain("Approach"); + expect(markup).toContain("Which approach should the migration take?"); + expect(markup).toContain("Incremental"); + expect(markup).toContain("Big bang"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index ceac45c9411..75dc5a6f547 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -5,7 +5,8 @@ import { derivePendingUserInputProgress, type PendingUserInputDraftAnswer, } from "../../pendingUserInput"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { cn } from "~/lib/utils"; interface PendingUserInputPanelProps { @@ -65,6 +66,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionId: string; optionLabel: string; } | null>(null); + // Collapsing hides everything but the header so a tall prompt stops covering + // the thread the user is trying to read. Scoped to a single question: the card + // is keyed by request id so the next prompt starts expanded, and storing the + // collapsed question's id (rather than a bare flag) reopens the card when the + // prompt advances to its next question, which can happen without a click — + // sending from the composer advances the active question. + const [collapsedQuestionId, setCollapsedQuestionId] = useState(null); + const isCollapsed = collapsedQuestionId !== null && collapsedQuestionId === activeQuestion?.id; useEffect(() => { onAdvanceRef.current = onAdvance; @@ -118,9 +127,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( // Keyboard shortcut: number keys 1-9 select corresponding options when focus is // outside editable fields. Multi-select prompts toggle options in place; single- - // select prompts keep the existing auto-advance behavior. + // select prompts keep the existing auto-advance behavior. Collapsed prompts opt + // out, since the numbers they refer to are not on screen. useEffect(() => { - if (!activeQuestion || isResponding) return; + if (!activeQuestion || isResponding || isCollapsed) return; const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; @@ -144,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, isResponding]); + }, [activeQuestion, isCollapsed, isResponding]); if (!activeQuestion) { return null; @@ -153,75 +163,119 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const customAnswerActive = progress.customAnswer.trim().length > 0; return ( -
-
- - {activeQuestion.header} - - {prompt.questions.length > 1 ? ( - - {questionIndex + 1}/{prompt.questions.length} + { + setCollapsedQuestionId(open ? null : activeQuestion.id); + }} + > + {/* The trigger's wrapper is inset less than the card's text column, and + the trigger pays the difference back as padding: the hover background + and focus ring bleed 10px past that column on both sides, while the + header label and the chevron still line up with the left and right + edges of the question text below. The negative block margin keeps the + taller hit area from pushing the panel down. */} +
+ + + {activeQuestion.header} - ) : null} + {prompt.questions.length > 1 ? ( + + {questionIndex + 1}/{prompt.questions.length} + + ) : null} + {/* Collapsed, the header is otherwise just a section label and a + counter, so the question itself is echoed here as a one-line + reminder of what is being asked. */} + {isCollapsed ? ( + + {activeQuestion.question} + + ) : null} + {/* The chevron points at the body: down while it is open below the + header, up while it is collapsed into it. */} +
-

{activeQuestion.question}

- {activeQuestion.multiSelect ? ( -

Select one or more options.

- ) : null} -
- {activeQuestion.options.map((option, index) => { - const isOptimisticallySelected = - optimisticSingleSelect?.questionId === activeQuestion.id && - optimisticSingleSelect.optionLabel === option.label; - const isSelected = - isOptimisticallySelected || - (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); - const shortcutKey = index < 9 ? index + 1 : null; - const className = cn( - "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25", - isSelected - ? "border-primary/30 bg-primary/8 text-foreground" - : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", - isResponding && "opacity-50 cursor-not-allowed", - !isResponding && "cursor-pointer", - ); - const content = ( - <> -
- {option.label} - {option.description && option.description !== option.label ? ( - {option.description} - ) : null} -
- {isSelected ? ( - - ) : shortcutKey !== null ? ( - +
+

{activeQuestion.question}

+ {activeQuestion.multiSelect ? ( +

Select one or more options.

+ ) : null} +
+ {activeQuestion.options.map((option, index) => { + const isOptimisticallySelected = + optimisticSingleSelect?.questionId === activeQuestion.id && + optimisticSingleSelect.optionLabel === option.label; + const isSelected = + isOptimisticallySelected || + (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); + const shortcutKey = index < 9 ? index + 1 : null; + const className = cn( + "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25", + isSelected + ? "border-primary/30 bg-primary/8 text-foreground" + : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", + isResponding && "opacity-50 cursor-not-allowed", + !isResponding && "cursor-pointer", + ); + const content = ( + <> +
+ {option.label} + {option.description && option.description !== option.label ? ( + {option.description} + ) : null} +
+ {isSelected ? ( + + ) : shortcutKey !== null ? ( + + {shortcutKey} + + ) : null} + + ); + return ( + - ); - })} -
-
+ {content} + + ); + })} +
+
+ + ); }); From 684d703b0a8a0632a18c8453277f7e5e6312b200 Mon Sep 17 00:00:00 2001 From: Rishet11 <154429365+Rishet11@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:37:24 +0530 Subject: [PATCH 066/113] fix(shared): degrade an unknown system time zone to UTC in usage windows (#6670) --- packages/shared/src/usageFormat.test.ts | 18 ++++++++++++++++- packages/shared/src/usageFormat.ts | 26 ++++++++++++++++++------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index cecc07c6e67..fb231fbacb2 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics globalDate:off -- A fixed instant keeps calendar-window assertions deterministic. -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { enumerateHourStarts, @@ -54,4 +54,20 @@ describe("hourly usage formatting", () => { expect(window.sinceTime).toBe("2026-08-10T12:37:00.000Z"); expect(window.untilTime).toBe("2026-08-11T12:37:00.000Z"); }); + + it("degrades an unknown resolved zone to UTC instead of crashing", () => { + const resolved = new Intl.DateTimeFormat().resolvedOptions(); + const resolvedOptions = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockReturnValue({ ...resolved, timeZone: "Etc/Unknown" }); + + try { + const now = new Date("2026-08-11T12:37:42.123Z"); + + expect(makeWindow(1, now, "hour").timeZone).toBe("UTC"); + expect(makeWindow(30, now).timeZone).toBe("UTC"); + } finally { + resolvedOptions.mockRestore(); + } + }); }); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index ef2b2bcf21a..bd751829dd8 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -179,13 +179,25 @@ export function makeWindow( now = new Date(), resolution: UsageResolution = "day", ): UsageSummaryInput { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const format = new Intl.DateTimeFormat("en-CA", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); + let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch { + // An unknown zone should degrade to UTC rather than crash the page. + timeZone = "UTC"; + format = new Intl.DateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } const untilDay = format.format(now); if (resolution === "hour") { // Minute-aligned bounds keep labels readable while still representing an From ad47d2347c6917f7db33e6e3902e1e8e5d5281ec Mon Sep 17 00:00:00 2001 From: Roshan Mhatre Date: Sat, 15 Aug 2026 17:37:31 +0530 Subject: [PATCH 067/113] fix(claude): discover repo-local .agents/skills in skill discovery (#5488) --- .../src/provider/Drivers/ClaudeSkills.test.ts | 99 +++++++++++++++++++ .../src/provider/Drivers/ClaudeSkills.ts | 31 +++--- docs/user/providers-claude.md | 7 ++ 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 1ad843d7573..60db1d0c5e2 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -66,6 +66,105 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("discovers project skills from the workspace .agents directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "review", + ["---", "name: review", "description: Review the changes.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "review", + path: path.join(workspace, ".agents", "skills", "review", "SKILL.md"), + enabled: true, + scope: "project", + description: "Review the changes.", + }, + ]); + }), + ); + + it.effect("prefers workspace .claude skills on three-way name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "deploy", + ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Claude deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "deploy", + path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "project", + description: "Claude deploy.", + }, + ]); + }), + ); + + it.effect("prefers workspace .agents skills over user skills on name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "deploy", + ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "deploy", + path: path.join(workspace, ".agents", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "project", + description: "Agents deploy.", + }, + ]); + }), + ); + it.effect("prefers project skills over user skills on name collisions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 335c3d4681d..5c33fba0b9e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -1,12 +1,13 @@ /** * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. * - * Claude Code loads skills from `/skills` (user scope) and - * `/.claude/skills` (project scope), one directory per skill with a - * `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake surfaces - * skills only as slash commands without their filesystem paths, so the - * provider snapshot scans the same locations directly, mirroring how the - * Codex app-server reports its skills. + * Claude Code loads skills from `/skills` (user scope), then + * `/.agents/skills` and `/.claude/skills` (project scope), one + * directory per skill with a `SKILL.md` carrying YAML frontmatter. Later roots + * win on name collisions, so precedence is user, `.agents`, then `.claude`. + * The Agent SDK init handshake surfaces skills only as slash commands without + * their filesystem paths, so the provider snapshot scans the same locations + * directly, mirroring how the Codex app-server reports its skills. * * @module provider/Drivers/ClaudeSkills */ @@ -84,11 +85,12 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct }); /** - * Enumerate Claude Code skills from the user config dir and the workspace. - * Discovery is best-effort: unreadable roots and malformed skill entries are - * skipped so a broken skill never degrades the provider snapshot. On name - * collisions the project-scoped skill wins, matching Claude Code's - * most-specific-wins resolution. + * Enumerate Claude Code skills from the user config dir, workspace + * `.agents/skills`, and workspace `.claude/skills`, in that order. Discovery + * is best-effort: unreadable roots and malformed skill entries are skipped so + * a broken skill never degrades the provider snapshot. On name collisions, + * later roots win: `.agents` beats user and `.claude` beats `.agents`, matching + * Claude Code's resolution. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick, @@ -101,7 +103,12 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, - ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), + ...(cwd + ? [ + { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, + { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }, + ] + : []), ]; const skillsByName = new Map(); diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 79f1211cf40..f9699388b7d 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -34,6 +34,13 @@ When you set this field, T3 Code points Claude Code at that directory with the `CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and the rest of your environment stay as they are. +## Where Claude Skills Are Loaded + +T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then +`/.agents/skills`, then `/.claude/skills`. + +If the same skill name exists in more than one folder, the later folder wins. + ## I Want Work And Personal Claude Accounts Use a different Claude config directory for each account. From d715c2e56bb718d2225cc0f07cc65e6c637dc229 Mon Sep 17 00:00:00 2001 From: Carlos Jimenez Date: Sat, 15 Aug 2026 05:07:38 -0700 Subject: [PATCH 068/113] fix(server): let slow provider CLIs raise their discovery probe budget (#6223) Co-authored-by: Julius Marminge --- .../AzureDevOpsSourceControlProvider.ts | 4 ++++ .../SourceControlProviderDiscovery.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index bf2ac982927..2f147452f9e 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -45,6 +45,10 @@ export const discovery = { executable: "az", versionArgs: ["--version"], authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], + // `az` boots a fresh Python interpreter on every invocation, so even `az --version` + // takes ~6s on Windows and overruns the default budget, leaving the provider reported + // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s. + probeTimeoutMs: 20_000, parseAuth: parseAzureAuth, installHint: "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb20..b2b9e451337 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -33,6 +33,7 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly executable: string; readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; + readonly probeTimeoutMs?: number; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, @@ -52,6 +53,14 @@ type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { readonly refineUnknownRemote: NonNullable; }; +// Most provider CLIs answer `--version` in well under a second, so a short budget keeps +// discovery snappy. Specs whose CLI is known to be slower can raise it via probeTimeoutMs. +const DEFAULT_PROBE_TIMEOUT_MS = 5_000; + +function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { + return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; +} + interface DiscoveryProbeResult { readonly kind: SourceControlProviderKind; readonly label: string; @@ -167,7 +176,7 @@ function probeCli(input: { command: input.spec.executable, args: input.spec.versionArgs, cwd: input.cwd, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(input.spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -244,7 +253,7 @@ export function probeSourceControlProvider(input: { args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -287,7 +296,7 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) From d5465aebf2746b8d5f327be3b2424d9412a29075 Mon Sep 17 00:00:00 2001 From: sebbonit <36650750+sebbonit@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:07:51 +0200 Subject: [PATCH 069/113] fix(web): retain terminal PR badges after checkout switch (#4755) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/components/ChatView.tsx | 11 +- apps/web/src/components/Sidebar.tsx | 81 ++-- .../components/ThreadStatusIndicators.test.ts | 388 +++++++++++++++++- .../src/components/ThreadStatusIndicators.tsx | 174 ++++++++ 4 files changed, 618 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cb79f1f7e21..d2d3e908c1c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -276,7 +276,10 @@ import { shouldShowThreadErrorBanner, ThreadErrorBanner, } from "./chat/ThreadErrorBanner"; -import { resolveThreadPr } from "./ThreadStatusIndicators"; +import { + resolveDisplayedThreadPr, + threadChangeRequestSnapshotsAtom, +} from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { @@ -1596,6 +1599,7 @@ function ChatViewContent(props: ChatViewProps) { [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -4124,9 +4128,11 @@ function ChatViewContent(props: ChatViewProps) { const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); - const activeThreadPr = resolveThreadPr({ + const activeThreadPr = resolveDisplayedThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, + snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, + retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, }); // The right panel offers the thread's own change request, so it can only offer it once the // branch has one; until then the picker says so rather than opening an empty panel. @@ -4208,6 +4214,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadShell, autoSettleAfterDays, autoSettleOnMerge, + changeRequestSnapshotByKey, nowMinute, supportsSettlement, ]); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 016a4c68275..5ae583b66bb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -143,10 +143,15 @@ import { import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ThreadWorktreeIndicator, + nextThreadChangeRequestSnapshot, prStatusIndicator, - resolveThreadPr, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, + setThreadChangeRequestSnapshot, settledPrHoverColorClass, terminalStatusFromRunningIds, + threadChangeRequestSnapshotsAtom, + type ThreadChangeRequestSnapshot, type TerminalStatusIndicator, } from "./ThreadStatusIndicators"; import { @@ -729,11 +734,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; + changeRequestSnapshot: ThreadChangeRequestSnapshot | null; + onChangeRequestSnapshot: ( + threadKey: string, + snapshot: ThreadChangeRequestSnapshot | null, + ) => void; }) { const { isRenaming, - onChangeRequestState, + changeRequestSnapshot, + onChangeRequestSnapshot, onCancelRename, onCommitRename, onContextMenu, @@ -778,9 +788,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }) : null, ); - const pr = resolveThreadPr({ + const retainTerminalOnBranchMismatch = thread.worktreePath === null; + const pr = resolveDisplayedThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, }); const prState = pr?.state ?? null; @@ -874,13 +887,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const prProvider = resolveDisplayedThreadPrProvider({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, + }); + const prStatus = prStatusIndicator(pr, prProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; - // Report the PR state so the parent can apply the configured merge rule - // and the always-on close rule during partitioning. useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + const nextSnapshot = nextThreadChangeRequestSnapshot({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, + }); + if (nextSnapshot === undefined) return; + onChangeRequestSnapshot(threadKey, nextSnapshot); + }, [ + changeRequestSnapshot, + gitStatus.data, + onChangeRequestSnapshot, + retainTerminalOnBranchMismatch, + thread.branch, + threadKey, + ]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1858,26 +1889,7 @@ export default function Sidebar() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule. - const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; - const next = new Map(current); - if (state === null) { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); - }, - [], - ); + const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. @@ -1993,7 +2005,11 @@ export default function Sidebar() { const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + const snapshot = changeRequestSnapshotByKey.get(threadKey); + const changeRequestState = + snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) + ? snapshot.pr.state + : null; // Snooze outranks everything, including a pin: "hide until Tuesday" // temporarily suspends "keep on top". The pin survives underneath — // and so does its pinOrderKey, so on wake the thread reappears at @@ -2051,7 +2067,7 @@ export default function Sidebar() { }, [ autoSettleAfterDays, autoSettleOnMerge, - changeRequestStateByKey, + changeRequestSnapshotByKey, nowMinute, scopedProjectKeys, serverConfigs, @@ -3686,7 +3702,8 @@ export default function Sidebar() { onUnsnooze={attemptUnsnooze} onUnpin={attemptUnpin} onAcknowledgeWoke={acknowledgeWoke} - onChangeRequestState={handleChangeRequestState} + changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} + onChangeRequestSnapshot={setThreadChangeRequestSnapshot} /> ); }; diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3eb8e4f710f..f77959d9f42 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,10 +1,19 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { + nextThreadChangeRequestSnapshot, prStatusIndicator, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + threadChangeRequestSnapshotsAtom, + type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; function status(overrides: Partial = {}): VcsStatusResult { @@ -30,6 +39,25 @@ function status(overrides: Partial = {}): VcsStatusResult { }; } +function mergedFeaturePr(): NonNullable { + return { + number: 42, + title: "Feature PR", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRef: "main", + headRef: "feature/current", + state: "merged", + }; +} + +function snapshotFor( + branch: string, + pr: NonNullable, + sourceControlProvider?: VcsStatusResult["sourceControlProvider"], +): ThreadChangeRequestSnapshot { + return { branch, pr, sourceControlProvider }; +} + describe("resolveThreadPr", () => { it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { expect( @@ -70,6 +98,362 @@ describe("resolveThreadPr", () => { }); }); +describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { + const featureBranch = "feature/current"; + const mergedPr = mergedFeaturePr(); + const provider = { + kind: "github" as const, + name: "GitHub", + baseUrl: "https://github.com", + }; + + it("returns the live merged PR when the checkout matches the feature branch", () => { + const gitStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBe(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(provider); + }); + + it("after caching a merged PR, resolves main status back to the cached feature PR", () => { + const matchingStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + const cached = nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: matchingStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }); + expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); + + const mainStatus = status({ + refName: "main", + isDefaultRef: true, + pr: { + number: 99, + title: "Unrelated main PR", + url: "https://github.com/pingdotgg/t3code/pull/99", + baseRef: "main", + headRef: "main", + state: "open", + }, + sourceControlProvider: provider, + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(provider); + }); + + it("never attaches a PR reported by main to the feature thread", () => { + const mainPr = { + number: 99, + title: "Unrelated main PR", + url: "https://github.com/pingdotgg/t3code/pull/99", + baseRef: "develop", + headRef: "main", + state: "merged" as const, + }; + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: mainPr }), + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: mainPr }), + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("does not show a cached open PR across a branch mismatch", () => { + const openSnapshot = snapshotFor(featureBranch, { + ...mergedPr, + state: "open", + title: "Still open", + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("retains a cached closed PR across a branch mismatch", () => { + const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; + const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: closedSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(closedPr); + }); + + it("does not retain or display a terminal PR when a worktree switches branches", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + const mismatchedStatus = status({ refName: "feature/other", pr: null }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeUndefined(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeNull(); + }); + + it("retains a local terminal snapshot when thread metadata follows the new branch", () => { + const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: otherBranchSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + expect( + resolveDisplayedThreadPr({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("clears an open snapshot when a local thread moves to a branch with no PR", () => { + const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("clears an open snapshot when a local checkout moves to a different branch", () => { + const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("clears a retained snapshot when the thread branch is cleared", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPr({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + }); + + it("does not erase a terminal snapshot when VCS data is missing", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: null, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: null, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => { + const matchingStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + const cached = nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: matchingStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }); + expect(cached).not.toBeNull(); + expect(cached).not.toBeUndefined(); + + const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); + const displayed = resolveDisplayedThreadPr({ + threadBranch: "main", + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }); + expect(displayed?.state).toBe("merged"); + + const shell = { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Feature thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + session: null, + createdAt: "2026-04-09T00:00:00.000Z", + updatedAt: "2026-04-09T00:00:00.000Z", + archivedAt: null, + settledAt: null, + settledOverride: null, + latestUserMessageAt: "2026-04-09T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } as OrchestrationThreadShell; + + expect( + effectiveSettled(shell, { + now: "2026-04-10T00:00:00.000Z", + autoSettleAfterDays: null, + changeRequestState: displayed?.state ?? null, + }), + ).toBe(true); + }); +}); + +describe("threadChangeRequestSnapshotsAtom", () => { + it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => + Effect.gen(function* () { + const registry = AtomRegistry.make(); + const threadKey = "environment-1:thread-1"; + const snapshot = snapshotFor("feature/current", mergedFeaturePr()); + + const unmount = registry.mount(threadChangeRequestSnapshotsAtom); + registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); + unmount(); + + yield* Effect.yieldNow; + + const remount = registry.mount(threadChangeRequestSnapshotsAtom); + expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); + + remount(); + registry.dispose(); + }), + ); +}); + describe("prStatusIndicator", () => { it("formats PR tooltips with number, uppercase status, and title", () => { expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index af53d1a78b2..a6ea2e7fd96 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -4,8 +4,10 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import type { VcsStatusResult } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -126,6 +128,178 @@ export function resolveThreadPr(input: { return gitStatus.pr ?? null; } +/** + * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement + * partitions move them, so terminal PR metadata must live above the row. + */ +export interface ThreadChangeRequestSnapshot { + readonly branch: string; + readonly pr: NonNullable; + readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; +} + +export const threadChangeRequestSnapshotsAtom = Atom.make< + ReadonlyMap +>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); + +function isTerminalChangeRequestState( + state: NonNullable["state"], +): state is "merged" | "closed" { + return state === "merged" || state === "closed"; +} + +function sourceControlProvidersEqual( + left: VcsStatusResult["sourceControlProvider"] | undefined, + right: VcsStatusResult["sourceControlProvider"] | undefined, +): boolean { + if (left === right) return true; + if (left == null || right == null) return left == null && right == null; + return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; +} + +export function threadChangeRequestSnapshotsEqual( + left: ThreadChangeRequestSnapshot, + right: ThreadChangeRequestSnapshot, +): boolean { + return ( + left.branch === right.branch && + left.pr.number === right.pr.number && + left.pr.title === right.pr.title && + left.pr.url === right.pr.url && + left.pr.baseRef === right.pr.baseRef && + left.pr.headRef === right.pr.headRef && + left.pr.state === right.pr.state && + sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) + ); +} + +export function setThreadChangeRequestSnapshot( + threadKey: string, + snapshot: ThreadChangeRequestSnapshot | null, +): void { + appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { + const existing = current.get(threadKey); + if (snapshot === null) { + if (existing === undefined) return [false, current]; + const next = new Map(current); + next.delete(threadKey); + return [true, next]; + } + if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { + return [false, current]; + } + const next = new Map(current); + next.set(threadKey, snapshot); + return [true, next]; + }); +} + +/** + * Authoritative snapshot update from live VCS status. + * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone + * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear + * - snapshot: matching branch reports a PR — store/replace + */ +export function nextThreadChangeRequestSnapshot(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): ThreadChangeRequestSnapshot | null | undefined { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if (gitStatus === null) { + return undefined; + } + if (threadBranch === null) { + return null; + } + if (gitStatus.refName !== threadBranch) { + return retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ? undefined + : null; + } + if (gitStatus.pr == null) { + if ( + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return undefined; + } + return null; + } + return { + branch: threadBranch, + pr: gitStatus.pr, + sourceControlProvider: gitStatus.sourceControlProvider, + }; +} + +/** + * Live PR when the checkout matches the thread branch; otherwise, for local + * checkouts only, a cached merged/closed PR for the thread. Local thread + * metadata follows the shared checkout, so the cached branch intentionally + * survives that metadata changing to the newly checked-out branch. Open PRs + * are never retained — their state can still change. + */ +export function resolveDisplayedThreadPr(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): ThreadPr | null { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if ( + threadBranch !== null && + gitStatus !== null && + gitStatus.refName === threadBranch && + gitStatus.pr != null + ) { + return gitStatus.pr; + } + + if ( + threadBranch !== null && + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return snapshot.pr; + } + + return null; +} + +export function resolveDisplayedThreadPrProvider(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): VcsStatusResult["sourceControlProvider"] | undefined { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if ( + threadBranch !== null && + gitStatus !== null && + gitStatus.refName === threadBranch && + gitStatus.pr != null + ) { + return gitStatus.sourceControlProvider; + } + + if ( + threadBranch !== null && + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return snapshot.sourceControlProvider; + } + + return undefined; +} + export function terminalStatusFromRunningIds( runningTerminalIds: ReadonlyArray, ): TerminalStatusIndicator | null { From ca37b19cf8d3882f0b4eee1b9e49050494f30422 Mon Sep 17 00:00:00 2001 From: nqrwhal <81386789+nqrwhal@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:07:53 -0700 Subject: [PATCH 070/113] fix(web): show selected model in context window tooltip (#4772) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/components/chat/ChatComposer.tsx | 28 ++++----- .../chat/ContextWindowMeter.logic.test.ts | 58 +++++++++++++++++++ .../chat/ContextWindowMeter.logic.ts | 25 ++++++++ .../components/chat/ContextWindowMeter.tsx | 7 ++- 4 files changed, 97 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/chat/ContextWindowMeter.logic.test.ts create mode 100644 apps/web/src/components/chat/ContextWindowMeter.logic.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 293767a7390..a92bf439ae1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -103,6 +103,7 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; +import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; @@ -210,7 +211,7 @@ import { XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; -import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; +import { getProviderInteractionModeToggle } from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -225,10 +226,7 @@ import type { UnifiedSettings } from "@t3tools/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; -import { - deriveLatestContextWindowSnapshot, - formatProviderDisplayName, -} from "../../lib/contextWindow"; +import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; @@ -396,7 +394,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ReturnType; - activeThreadProviderDisplayName: string | null; + activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { questionIndex: number; @@ -424,7 +422,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( {props.activeContextWindow ? ( ) : null} {props.isPreparingWorktree ? ( @@ -930,16 +928,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), [activeThreadActivities], ); - const activeThreadProviderDisplayName = useMemo(() => { - if (!activeThreadModelSelection) return null; - const entry = providerStatuses.find( - (p) => p.instanceId === activeThreadModelSelection.instanceId, - ); - if (entry) { - return getProviderDisplayName(providerStatuses, entry.driver); - } - return formatProviderDisplayName(activeThreadModelSelection.instanceId); - }, [providerStatuses, activeThreadModelSelection]); + const activeThreadModelDisplayName = useMemo( + () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), + [activeThreadModelSelection, modelOptionsByInstance], + ); // ------------------------------------------------------------------ // Composer-local state @@ -3222,7 +3214,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { + it("uses the selected model from the exact provider instance", () => { + const primaryInstanceId = ProviderInstanceId.make("codex"); + const selectedInstanceId = ProviderInstanceId.make("codex-work"); + const modelOptionsByInstance = new Map([ + [ + primaryInstanceId, + [{ slug: "gpt-5.6-sol", name: "Primary profile model", shortName: "Primary" }], + ], + [selectedInstanceId, [{ slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", shortName: "5.6 Sol" }]], + ]); + + expect( + resolveContextWindowModelDisplayName( + { + instanceId: selectedInstanceId, + model: "gpt-5.6-sol", + }, + modelOptionsByInstance, + ), + ).toBe("5.6 Sol"); + }); + + it("falls back to the selected model slug when model metadata is unavailable", () => { + const selectedInstanceId = ProviderInstanceId.make("codex-work"); + + expect( + resolveContextWindowModelDisplayName( + { + instanceId: selectedInstanceId, + model: "custom-model", + }, + new Map(), + ), + ).toBe("custom-model"); + }); +}); + +describe("formatContextWindowCompactionMessage", () => { + it("describes compaction in terms of the selected model", () => { + expect(formatContextWindowCompactionMessage("GPT-5.6 Sol")).toBe( + "Context for GPT-5.6 Sol compacts automatically when needed.", + ); + }); + + it("uses neutral copy when the model is unavailable", () => { + expect(formatContextWindowCompactionMessage(null)).toBe( + "Context compacts automatically when needed.", + ); + }); +}); diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts new file mode 100644 index 00000000000..c87170ffe61 --- /dev/null +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -0,0 +1,25 @@ +import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; + +export function resolveContextWindowModelDisplayName( + selection: ModelSelection | null | undefined, + modelOptionsByInstance: ReadonlyMap>, +): string | null { + if (!selection) { + return null; + } + + const selectedModel = modelOptionsByInstance + .get(selection.instanceId) + ?.find((model) => model.slug === selection.model); + + return selectedModel ? getTriggerDisplayModelName(selectedModel) : selection.model; +} + +export function formatContextWindowCompactionMessage( + modelDisplayName: string | null | undefined, +): string { + return modelDisplayName + ? `Context for ${modelDisplayName} compacts automatically when needed.` + : "Context compacts automatically when needed."; +} diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index f377c893ae2..6e42dcadd8b 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,6 +1,7 @@ import { Button } from "../ui/button"; import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; function formatPercentage(value: number | null): string | null { if (value === null || !Number.isFinite(value)) { @@ -14,9 +15,9 @@ function formatPercentage(value: number | null): string | null { export function ContextWindowMeter(props: { usage: ContextWindowSnapshot; - providerDisplayName?: string | null; + modelDisplayName?: string | null; }) { - const { usage, providerDisplayName } = props; + const { usage, modelDisplayName } = props; const usedPercentage = formatPercentage(usage.usedPercentage); const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0)); const radius = 9.75; @@ -127,7 +128,7 @@ export function ContextWindowMeter(props: { ) : null} {usage.compactsAutomatically ? (
- {providerDisplayName ?? "It"} automatically compacts its context when needed. + {formatContextWindowCompactionMessage(modelDisplayName)}
) : null}
From 5e147371527154be385f28e57339a71521e528c6 Mon Sep 17 00:00:00 2001 From: CursedApple <36764254+Serendeep@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:08:10 +0200 Subject: [PATCH 071/113] fix(web): scale command details with code font (#6510) --- .github/pr-assets/6424-after.svg | 1 + .github/pr-assets/6424-before.svg | 1 + apps/web/src/components/chat/MessagesTimeline.test.tsx | 8 +++++++- apps/web/src/components/chat/MessagesTimeline.tsx | 7 ++++--- 4 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .github/pr-assets/6424-after.svg create mode 100644 .github/pr-assets/6424-before.svg diff --git a/.github/pr-assets/6424-after.svg b/.github/pr-assets/6424-after.svg new file mode 100644 index 00000000000..dbeb594a09d --- /dev/null +++ b/.github/pr-assets/6424-after.svg @@ -0,0 +1 @@ + diff --git a/.github/pr-assets/6424-before.svg b/.github/pr-assets/6424-before.svg new file mode 100644 index 00000000000..6b365bad6e6 --- /dev/null +++ b/.github/pr-assets/6424-before.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 194edc0bd5b..dfdfd116965 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -134,6 +134,7 @@ function matchMedia() { } let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; +let toolCallExpandedBodyClassName: typeof import("./MessagesTimeline").toolCallExpandedBodyClassName; beforeAll(async () => { const classList = { @@ -167,7 +168,7 @@ beforeAll(async () => { }, }); - ({ MessagesTimeline } = await import("./MessagesTimeline")); + ({ MessagesTimeline, toolCallExpandedBodyClassName } = await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -226,6 +227,11 @@ function buildUserTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("sizes expanded tool details with the configured code font size", () => { + expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code"); + expect(toolCallExpandedBodyClassName).not.toContain("text-[11px]"); + }); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f5c529ff315..9fe392d76a2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2059,6 +2059,9 @@ function buildToolCallExpandedBody( return blocks.length > 0 ? blocks.join("\n\n") : null; } +export const toolCallExpandedBodyClassName = + "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[length:var(--font-size-code,0.6875rem)] leading-relaxed select-text"; + function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || @@ -2369,9 +2372,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { onClick={stopRowToggle} onPointerDown={stopRowToggle} > -
-            {expandedBody}
-          
+
{expandedBody}
) : null}
From cf7bfd1c93974428262ab1419d11c972d01d65fa Mon Sep 17 00:00:00 2001 From: John Surles Date: Sat, 15 Aug 2026 08:08:29 -0400 Subject: [PATCH 072/113] fix(web): preserve XML-like tags in user messages (#4133) Co-authored-by: codex Co-authored-by: Julius Marminge --- apps/web/src/components/ChatMarkdown.tsx | 14 +- .../components/chat/MessagesTimeline.test.tsx | 148 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 4 + 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ec88bc912f0..c4548540e2c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -117,6 +117,8 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + /** Parse sanitized raw HTML instead of displaying its source text. */ + parseRawHtml?: boolean; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -1360,6 +1362,7 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + parseRawHtml = true, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -1622,7 +1625,7 @@ function ChatMarkdown({ /> ); }, - a({ node, href, children, ...props }) { + a({ node, href, children, title: _title, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { @@ -1707,6 +1710,9 @@ function ChatMarkdown({ props.className, ); }, + img({ node: _node, title: _title, ...props }) { + return ; + }, code({ node, children, className, ...props }) { if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); @@ -1777,6 +1783,9 @@ function ChatMarkdown({ ]); /* eslint-enable react/no-unstable-nested-components */ + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. + // Keep that behavior explicit because literal mode depends on escaping the + // complete source token instead of dropping it from the rendered message. return (
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index dfdfd116965..e51095bb00a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -226,6 +226,17 @@ function buildUserTimelineEntry(text: string) { }; } +function buildAssistantTimelineEntry(text: string) { + const entry = buildUserTimelineEntry(text); + return { + ...entry, + message: { + ...entry.message, + role: "assistant" as const, + }, + }; +} + describe("MessagesTimeline", () => { it("sizes expanded tool details with the configured code font size", () => { expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code"); @@ -470,7 +481,142 @@ describe("MessagesTimeline", () => { expect(markup).toContain("rounded-2xl bg-message p-3"); }); - it("renders inline terminal labels with the composer chip UI", () => { + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + 'Before inside after', + " in your context?", + "Comparison: 2 < 3 and 5 > 4.", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain("<global-agent-instructions scope="workspace">"); + expect(markup).toContain( + "Before <nested data-value="a&b">inside</nested> after", + ); + expect(markup).toContain("</global-agent-instructions> in your context?"); + expect(markup).toContain("Comparison: 2 < 3 and 5 > 4."); + }); + + it("preserves XML-like source inside user code spans and fences", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + `', + "", + "```xml", + '', + "```", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain('<tag attr="x">'); + expect(markup).toContain("<root><child enabled="true" /></root>"); + }); + + it("does not render markdown title attributes in user messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('href="https://example.com"'); + expect(markup).toContain('src="https://example.com/image.png"'); + expect(markup).not.toContain('title="link tip"'); + expect(markup).not.toContain('title="image tip"'); + }); + + it("renders unsafe user HTML as inert source text", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + globalThis.__t3Xss = 1', + ), + ]} + />, + ); + + expect(markup).toContain("<script>globalThis.__t3Xss = 1</script>"); + expect(markup).toContain( + "<img src="x" onerror="globalThis.__t3Xss = 2">", + ); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toMatch(/)/i); + }); + + it("continues to render sanitized raw HTML in assistant messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + MoreDetails"), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("More"); + expect(markup).not.toContain("<details>"); + }); + + it("sanitizes executable HTML while preserving supported assistant markup", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + "Safe details", + "", + '', + 'Unsafe link', + "", + ].join(""), + ), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("Safe details"); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toContain("onclick="); + expect(markup).not.toContain("onerror="); + expect(markup).not.toContain("javascript:"); + expect(markup).not.toContain("globalThis.__t3Xss"); + }); + + it("renders inline terminal labels with the composer chip UI", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( ) : null} {trailingWhitespace ? : null} @@ -1714,6 +1715,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} />
) : null @@ -1802,6 +1804,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} />, ); } else if (inlinePrefix.length === 0) { @@ -1827,6 +1830,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} /> ); }); From 7c8848ebb054c1f4c1279f634633cddc37cb1fac Mon Sep 17 00:00:00 2001 From: Akos Balogh Date: Sat, 15 Aug 2026 14:10:21 +0200 Subject: [PATCH 073/113] fix(desktop): route mouse thumb buttons to the in-app browser (#4459) Co-authored-by: Claude Opus 4.8 --- apps/desktop/src/preview/GuestProtocol.ts | 1 + apps/desktop/src/preview/Manager.test.ts | 63 +++++++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 19 +++++++ apps/desktop/src/preview/PickPreload.ts | 35 +++++++++++++ 4 files changed, 118 insertions(+) diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 00616c6a476..e63597b71ef 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; +export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 5c336eec8da..c4297a69c26 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2239,6 +2239,69 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => + withManager((manager) => + Effect.gen(function* () { + let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined; + const goBack = vi.fn(); + const goForward = vi.fn(); + let canGoBack = true; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof mouseNavigate) => { + if (channel === "preview:mouse-navigate") mouseNavigate = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { + canGoBack: () => canGoBack, + canGoForward: () => true, + goBack, + goForward, + }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_nav"); + yield* manager.registerWebview("tab_nav", 42); + expect(mouseNavigate).toBeDefined(); + + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + + mouseNavigate?.({}, { direction: "forward" }); + yield* Effect.yieldNow; + expect(goForward).toHaveBeenCalledOnce(); + + // Ignores unknown payloads and never navigates when history is exhausted. + mouseNavigate?.({}, { direction: "sideways" }); + canGoBack = false; + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("reveals only files inside the configured browser artifact directory", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index d48b1303739..e5a08e7da8c 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -58,6 +58,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; @@ -1506,6 +1507,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; + const mouseNavigate = (_event: unknown, payload?: unknown): void => { + const direction = + typeof payload === "object" && payload !== null && "direction" in payload + ? (payload as { direction?: unknown }).direction + : undefined; + if (direction !== "back" && direction !== "forward") return; + runFork( + attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { + if (direction === "back") { + if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); + } else if (wc.navigationHistory.canGoForward()) { + wc.navigationHistory.goForward(); + } + }).pipe(Effect.ignore), + ); + }; const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( event: Electron.Event, input: Electron.Input, @@ -1552,6 +1569,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("did-fail-load", failed as never); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { @@ -1565,6 +1583,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler(({ url }) => { runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index d03673400ab..f315bdcec73 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -22,6 +22,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; @@ -102,6 +103,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); +// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. +const MOUSE_BUTTON_BACK = 3; +const MOUSE_BUTTON_FORWARD = 4; + +const navigationDirectionForButton = (button: number): "back" | "forward" | null => { + if (button === MOUSE_BUTTON_BACK) return "back"; + if (button === MOUSE_BUTTON_FORWARD) return "forward"; + return null; +}; + +// Chromium routes thumb-button history navigation to the *focused* WebContents, +// so hovering this guest without focusing it sends the host app's router back +// instead of the preview. Suppress Chromium's default here and drive this tab's +// history explicitly so the buttons always navigate the browser the pointer is +// over — never the host app. +const suppressNavigationButton = (event: MouseEvent): void => { + if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); +}; + +const requestNavigationForButton = (event: MouseEvent): void => { + if (!event.isTrusted) return; + const direction = navigationDirectionForButton(event.button); + if (direction === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); + ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction }); +}; + +window.addEventListener("mousedown", suppressNavigationButton, true); +window.addEventListener("mouseup", requestNavigationForButton, true); +window.addEventListener("auxclick", suppressNavigationButton, true); + const nextId = (prefix: string): string => { idSequence += 1; return `${prefix}_${idSequence.toString(36)}`; From f915320914d1bc446e60cbcfe4cd7d75bad4dc2a Mon Sep 17 00:00:00 2001 From: jorvarea <47249803+jorvarea@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:10:32 +0200 Subject: [PATCH 074/113] fix(web): keep the final segment of directory paths with a trailing separator (#5460) Co-authored-by: jorvarea --- apps/web/src/markdown-links.test.ts | 25 +++++++++++++++++++++++++ apps/web/src/markdown-links.ts | 8 ++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc29613867..f7c507c178f 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -273,3 +273,28 @@ describe("resolveInlineCodeFileLinkMeta", () => { expect(resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md")).toBeNull(); }); }); + +describe("directory paths with a trailing separator", () => { + it("keeps the final segment for a POSIX directory path", () => { + expect(resolveMarkdownFileLinkMeta("/tmp/favicons/", "/repo/project")).toMatchObject({ + basename: "favicons", + }); + }); + + it("keeps the final segment for a Windows directory path", () => { + expect( + resolveMarkdownFileLinkMeta("C:\\Users\\kelchm\\.claude\\", "/repo/project"), + ).toMatchObject({ basename: ".claude" }); + }); + + it("matches the label of the same path without a trailing separator", () => { + const withSlash = resolveMarkdownFileLinkMeta("/tmp/favicons/", "/repo/project"); + const withoutSlash = resolveMarkdownFileLinkMeta("/tmp/favicons", "/repo/project"); + expect(withSlash?.basename).toBe(withoutSlash?.basename); + }); + + it("does not produce an empty label for the filesystem root", () => { + const meta = resolveMarkdownFileLinkMeta("/tmp/", "/repo/project"); + expect(meta?.basename).not.toBe(""); + }); +}); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b8a..e74bd170117 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -359,8 +359,12 @@ export function resolveInlineCodeFileLinkMeta( } function basenameOfPath(path: string): string { - const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; + // A trailing separator is a valid way to write a directory, so trim it before + // taking the final segment. Without this the segment reads as empty and the + // chip renders with no label at all. + const trimmed = path.replace(/[/\\]+$/, "") || path; + const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; } function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null { From 7083bce26aa89fedfc482ad44cf61c5508a58db7 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:42:15 +0530 Subject: [PATCH 075/113] Keep block code plain when copying from rendered markdown (#4468) --- apps/web/src/markdown-clipboard.test.ts | 95 +++++++++++++++++++++++++ apps/web/src/markdown-clipboard.ts | 22 +++++- 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/markdown-clipboard.test.ts diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts new file mode 100644 index 00000000000..7265e8b6043 --- /dev/null +++ b/apps/web/src/markdown-clipboard.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; + +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; + +class FakeText { + readonly nodeType = TEXT_NODE; + readonly childNodes: ReadonlyArray = []; + + constructor(readonly textContent: string) {} +} + +class FakeElement { + readonly nodeType = ELEMENT_NODE; + readonly childNodes: Array = []; + readonly classList = { + contains: (name: string) => this.classNames.includes(name), + }; + + constructor( + readonly tagName: string, + private readonly classNames: ReadonlyArray = [], + ) {} + + get localName(): string { + return this.tagName.toLowerCase(); + } + + get textContent(): string { + return this.childNodes.map((child) => child.textContent).join(""); + } + + append(...children: Array): this { + this.childNodes.push(...children); + return this; + } + + getAttribute(): string | null { + return null; + } + + hasAttribute(): boolean { + return false; + } +} + +function asNode(element: FakeElement): Node { + return element as unknown as Node; +} + +function shikiCodeLine(text: string): FakeElement { + const token = new FakeElement("SPAN").append(new FakeText(text)); + return new FakeElement("SPAN", ["line"]).append(token); +} + +describe("serializeRenderedMarkdownFragment", () => { + beforeEach(() => { + vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("wraps inline code in backticks", () => { + const paragraph = new FakeElement("P").append( + new FakeText("run "), + new FakeElement("CODE").append(new FakeText("git status")), + new FakeText(" first"), + ); + const container = new FakeElement("DIV").append(paragraph); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first"); + }); + + it("keeps a highlighted block code selection plain when its pre wrapper is outside the range", () => { + const code = new FakeElement("CODE").append( + shikiCodeLine("git show-ref --verify refs/remotes/origin/opt/deploy/dev"), + ); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "git show-ref --verify refs/remotes/origin/opt/deploy/dev", + ); + }); + + it("keeps a multi-line code selection plain instead of inline-wrapping it", () => { + const code = new FakeElement("CODE").append(new FakeText("first line\nsecond line")); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); + }); +}); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index f56b3a4920e..069d161a188 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -37,6 +37,22 @@ function wrapInlineMarker(content: string, marker: string): string { return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`; } +/** + * A code element whose pre wrapper fell outside the copied range is still + * block code, recognizable by its highlighter line spans or embedded + * newlines. Wrapping it like inline code produces backtick-surrounded + * shell commands on paste. + */ +function isBlockCodeElement(element: Element, content: string): boolean { + if (content.includes("\n")) return true; + for (const child of element.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) { + return true; + } + } + return false; +} + function wrapInlineCode(code: string): string { const longestRun = [...(code.match(/`+/g) ?? [])].reduce( (max, run) => Math.max(max, run.length), @@ -201,8 +217,10 @@ function serializeNode(node: Node): string { return `${serializeChildren(element).trim()}\n\n`; case "PRE": return serializeCodeBlock(element); - case "CODE": - return wrapInlineCode(element.textContent ?? ""); + case "CODE": { + const content = element.textContent ?? ""; + return isBlockCodeElement(element, content) ? content : wrapInlineCode(content); + } case "STRONG": case "B": return wrapInlineMarker(serializeChildren(element), "**"); From 21b6fb528d6b2d3b3e333b2bd4455d6cdf7d7a41 Mon Sep 17 00:00:00 2001 From: Alex Brodsky <122503996+Albro3459@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:12:42 -0500 Subject: [PATCH 076/113] fix(web): add web app manifest so installed app keeps its scope (#4306) --- apps/web/index.html | 1 + apps/web/public/manifest.webmanifest | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 apps/web/public/manifest.webmanifest diff --git a/apps/web/index.html b/apps/web/index.html index 8f49fd32c82..8aef3a4286f 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,6 +9,7 @@ +