diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index 77a526076de4d..0f817be60c35d 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -27,6 +27,8 @@ const EXTRA_OWNERS = [ [/^native\/browser-secret\//, ["@t3tools/desktop"]], [/^native\/kde-snap-shot\//, ["@t3tools/desktop"]], [/^native\/libghostty-vt\//, ["@t3tools/web"]], + // apps/server/src/cloud/ConnectReachability.test.ts checks the guide's headings. + [/^docs\/user\/t3-connect-troubleshooting\.md$/, ["t3"]], ]; const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; @@ -92,11 +94,11 @@ function readWorkspace() { // [] means inert; null means unknown and requires every job. function ownersOf(file, packages) { - if (INERT.some((pattern) => pattern.test(file))) return []; + const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); + if (INERT.some((pattern) => pattern.test(file))) return extra; // A manifest can change dependencies or versions other packages read. if (path.posix.basename(file) === "package.json") return null; if (RUNS_EVERYTHING.some((pattern) => pattern.test(file))) return null; - const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); // scripts/build-desktop-artifact.test.ts reads the crates' sources. if (RUST.test(file)) return ["@t3tools/scripts", ...extra]; diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 047dafbdd4be1..905823d1d1a9e 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -44,7 +44,7 @@ The reference for how this codebase wants UI built is the composer banner system - `ComposerBanner.tsx` exports one object of small slot components (`Root`, `Row`, `Icon`, `Content`, `Actions`, `Dismiss`, `Children`, and so on). Each slot owns its own Tailwind classes, exposes a `data-slot` attribute, takes `className` and spreads the rest of its props, and uses `cn` so callers can adjust without overriding. Variants and density are props on `Root`, not ad hoc class strings at call sites. Where a slot needs a real control it renders `Button` from `ui/` rather than a styled `"); const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "same label"); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 88308ed691eed..d573cd6387c8b 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -583,6 +583,7 @@ const capture = async ( }); return { ...page, + url: withoutDataPayload(page.url), // Mask text before truncating so a value crossing the cut cannot leak its prefix. visibleText: mask(page.visibleText).slice(0, MAX_VISIBLE_TEXT_LENGTH), accessibilityTree, diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 3262c555bcf8c..1b724c17010d8 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -5,6 +5,7 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Deferred from "effect/Deferred"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; @@ -714,6 +715,79 @@ it.effect.skipIf(windowsHost)("ignores unusable app bundles and keeps PATH launc }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect( + "memoizes editor discovery and refreshes in the background after the cache window", + () => { + let statCalls = 0; + const rescanned = Deferred.makeUnsafe(); + let initialScanDone = false; + const fileInfo = { type: "File" } as FileSystem.File.Info; + const layerLauncher = ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + stat: () => + Effect.sync(() => { + statCalls += 1; + return fileInfo; + }).pipe( + Effect.tap(() => + initialScanDone ? Deferred.succeed(rescanned, undefined) : Effect.void, + ), + ), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), + ), + ), + ), + ); + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + + const first = yield* launcher.resolveAvailableEditors(); + assert.equal(first.includes("vscode"), true); + const statCallsAfterFirstScan = statCalls; + initialScanDone = true; + assert.isAbove(statCallsAfterFirstScan, 0); + + // Past the shared command-resolution cache TTL (30s) but within the + // discovery cache window: the memoized set is reused without any scan. + yield* TestClock.adjust("31 seconds"); + const second = yield* launcher.resolveAvailableEditors(); + assert.deepEqual([...second], [...first]); + assert.equal(statCalls, statCallsAfterFirstScan); + + // Past the discovery cache window the next call still answers from the + // memoized set and rescans in the background. + yield* TestClock.adjust("30 seconds"); + const third = yield* launcher.resolveAvailableEditors(); + assert.deepEqual([...third], [...first]); + yield* Deferred.await(rescanned); + assert.isAbove(statCalls, statCallsAfterFirstScan); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerLauncher, + Layer.succeed(HostProcess.Platform, "win32"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + PATH: "C:\\t3-editor-discovery-cache-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + TestClock.layer(), + ), + ), + ); + }, +); + it.effect("keeps scanning after the caller is interrupted and shares that scan", () => { const fileInfo = { type: "File" } as FileSystem.File.Info; const release = Deferred.makeUnsafe(); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 6a2eb145162e2..7f4417d1fb67a 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -19,24 +19,20 @@ import { resolveSpawnCommand, withPathDirectoryListings, } from "@t3tools/shared/shell"; -import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Base64 from "effect/encoding/Base64"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { makeStaleWhileRevalidate } from "../environment/staleWhileRevalidate.ts"; export { ExternalLauncherError, ExternalLauncherBrowserSpawnError, @@ -447,13 +443,7 @@ const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileMana }, ); -// Shared scans must survive caller timeouts/disconnects; monotonic expiry prevents clock rollback extending stale entries. -const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; - -interface EditorDiscoveryCacheEntry { - readonly scan: Deferred.Deferred>; - readonly expiresAtNanos: bigint | undefined; -} +const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; export class ExternalLauncher extends Context.Service< ExternalLauncher, @@ -817,69 +807,27 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); - const scope = yield* Scope.Scope; - const editorDiscoveryCache = yield* Ref.make>( - Option.none(), - ); - const runEditorDiscovery = (scan: Deferred.Deferred>) => + const cachedAvailableEditors = yield* makeStaleWhileRevalidate( provideCommandResolutionServices(resolveAvailableEditors()).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.onExit((exit) => - Effect.gen(function* () { - const expiresAtNanos = - (yield* Clock.monotonicTimeNanos) + EDITOR_DISCOVERY_CACHE_TTL_NANOS; - yield* Ref.update(editorDiscoveryCache, (current) => - Option.isNone(current) || current.value.scan !== scan - ? current - : Exit.isSuccess(exit) - ? Option.some({ scan, expiresAtNanos }) - : Option.none(), - ); - yield* Deferred.done(scan, exit); - }), - ), - Effect.interruptible, - Effect.forkIn(scope), - ); - // Claim and start atomically; interruption between them would leave an entry waiting on no scan. - const acquireEditorDiscovery = Effect.gen(function* () { - const nowNanos = yield* Clock.monotonicTimeNanos; - const [scan, isNewScan] = yield* Ref.modify( - editorDiscoveryCache, - ( - current, - ): [ - [EditorDiscoveryCacheEntry["scan"], boolean], - Option.Option, - ] => { - if ( - Option.isSome(current) && - (current.value.expiresAtNanos === undefined || current.value.expiresAtNanos > nowNanos) - ) { - return [[current.value.scan, false], current]; - } - const scan = Deferred.makeUnsafe>(); - return [[scan, true], Option.some({ scan, expiresAtNanos: undefined })]; - }, - ); - if (isNewScan) { - yield* runEditorDiscovery(scan); - } - return scan; - }).pipe(Effect.uninterruptible); - const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await); + ), + EDITOR_DISCOVERY_CACHE_TTL, + ); + const cachedFileManagerRevealKind = yield* makeStaleWhileRevalidate( + provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + EDITOR_DISCOVERY_CACHE_TTL, + ); // Forwarders retain the underlying launcher and discovery spans. return ExternalLauncher.of({ resolveAvailableEditors: Effect.fnUntraced(function* () { return yield* cachedAvailableEditors; }), - resolveFileManagerRevealKind: Effect.fnUntraced( - function* () { - return yield* provideCommandResolutionServices(resolveFileManagerRevealKind()); - }, - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), + resolveFileManagerRevealKind: Effect.fnUntraced(function* () { + return yield* cachedFileManagerRevealKind; + }), launchBrowser: Effect.fnUntraced( function* (target) { return yield* launchBrowser(target); diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 4248e51b55977..0e3a3c67a7666 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -75,6 +75,7 @@ function candidate( model: modelSelection.model, createdAt: timestamp, updatedAt: timestamp, + omittedMessageCount: 0, messages: [ { role: "user" as const, text: "Fix it", createdAt: timestamp }, { role: "assistant" as const, text: "Fixed", createdAt: timestamp }, @@ -273,6 +274,7 @@ it.effect( "claudeAgent", ); const unowned = candidate(CODEX_MODEL_SELECTION, "unowned"); + unowned.thread.omittedMessageCount = 1; const unownedPi = candidate(PI_MODEL_SELECTION, "unowned-pi", "pi"); const importer = yield* makeImporter([ active, @@ -307,9 +309,23 @@ it.effect( const threadId = importId(source); const records = yield* projections.getThreadRecords(threadId, [ "messages", + "turnItems", "providerThreads", ]); expect(records.messages.map((message) => message.text)).toEqual(["Fix it", "Fixed"]); + const notices = records.turnItems.filter((item) => item.type === "system_notice"); + if (source === unowned) { + expect(records.turnItems[0]).toBe(notices[0]); + expect(notices).toMatchObject([ + { + ordinal: 1, + runId: null, + message: expect.stringContaining("1 earlier message was not imported"), + }, + ]); + } else { + expect(notices).toEqual([]); + } expect(records.thread.historyOrigin).toBe("v1_import"); expect(records.thread.activeProviderThreadId).toBe(records.providerThreads[0]?.id); expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe(nativeId(source)); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 038b488635053..a3f587c1c6d49 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -365,6 +365,34 @@ const make = Effect.gen(function* () { occurredAt: createdAt, payload: appThread, }, + ...(thread.omittedMessageCount > 0 + ? [ + { + id: EventId.make(`${IMPORT_EVENT_PREFIX}:history-truncated:${threadId}`), + type: "turn-item.updated" as const, + threadId, + occurredAt: createdAt, + payload: { + id: TurnItemId.make(`${IMPORT_EVENT_PREFIX}:history-truncated:${threadId}`), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed" as const, + title: null, + startedAt: createdAt, + completedAt: createdAt, + updatedAt: createdAt, + type: "system_notice" as const, + message: `${thread.omittedMessageCount} earlier ${thread.omittedMessageCount === 1 ? "message was" : "messages were"} not imported. This thread keeps the first user message and the most recent history. The original session transcript is unchanged.`, + }, + }, + ] + : []), ...thread.messages.flatMap((message, index) => messageEvents({ threadId, index, message }), ), diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 039393eb52a6d..688b974e7658d 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1823,6 +1823,48 @@ describe("parseAgentSessionTranscript", () => { }, ); + it.each([199, 200, 201, 205])( + "counts only omitted conversation messages for a %s-message Claude transcript", + (count) => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + ...Array.from({ length: count }, (_, index) => + encodeTranscriptRecord({ + type: index === 0 ? "user" : "assistant", + sessionId: "claude-session", + message: { content: `Message ${index}` }, + }), + ), + encodeTranscriptRecord({ type: "user", isMeta: true, message: { content: "Setup" } }), + encodeTranscriptRecord({ + type: "user", + isCompactSummary: true, + message: { content: "Summary" }, + }), + encodeTranscriptRecord({ + type: "assistant", + isSidechain: true, + message: { content: "Sidechain" }, + }), + encodeTranscriptRecord({ + type: "assistant", + message: { content: [{ type: "tool_use" }] }, + }), + "malformed", + ].join("\n"), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.omittedMessageCount).toBe(Math.max(0, count - 200)); + expect(thread?.messages).toHaveLength(Math.min(200, count)); + expect(thread?.messages[0]?.text).toBe("Message 0"); + expect(thread?.messages.at(-1)?.text).toBe(`Message ${count - 1}`); + }, + ); + it("keeps Claude text and titles while dropping malformed and tool records", () => { const thread = AgentSessionScanner.parseAgentSessionTranscript({ contents: [ @@ -1974,10 +2016,10 @@ describe("parseAgentSessionTranscript", () => { ]); }); - it("keeps the canonical first prompt after long Codex transcripts are capped", () => { + it.each([199, 200])("keeps the canonical first prompt with %s later Codex messages", (count) => { const canonicalPrompt = "\n Keep the canonical prompt \n"; const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; - const laterAssistantMessages = Array.from({ length: 200 }, (_, index) => + const laterAssistantMessages = Array.from({ length: count }, (_, index) => encodeTranscriptRecord({ type: "response_item", timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, @@ -2013,6 +2055,7 @@ describe("parseAgentSessionTranscript", () => { lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), }); + expect(thread?.omittedMessageCount).toBe(count - 199); expect(thread?.messages).toHaveLength(200); expect(thread?.messages[0]).toMatchObject({ role: "user", @@ -2075,6 +2118,7 @@ describe("parseAgentSessionTranscript", () => { lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), }); + expect(thread?.omittedMessageCount).toBe(1); expect(thread?.messages).toHaveLength(200); expect(thread?.messages[0]).toMatchObject({ role: "user", diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 639241db037a4..bbd3ba5a631e0 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -155,6 +155,7 @@ export interface AgentSessionThread { readonly createdAt: string; readonly updatedAt: string; readonly messages: ReadonlyArray; + readonly omittedMessageCount: number; } export type AgentSessionRecentThread = @@ -449,6 +450,7 @@ function parseAgentSessionRecords( finishCodexTurn(); } + let conversationMessageCount = 0; const retainMessage = ( message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, ) => { @@ -458,6 +460,7 @@ function parseAgentSessionRecords( if (derivedTitle === undefined && message.role === "user") { derivedTitle = deriveTitle(input.source, message.text); } + conversationMessageCount += 1; messages.push(message); if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift(); }; @@ -529,6 +532,7 @@ function parseAgentSessionRecords( if (message?.codexResponseUser === true && message.text.trim() === text.trim()) { if (firstUserMessage === message) firstUserMessage = undefined; messages.splice(index, 1); + conversationMessageCount -= 1; break; } } @@ -583,6 +587,7 @@ function parseAgentSessionRecords( createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, updatedAt: fallbackTimestamp, messages: retainedMessages, + omittedMessageCount: conversationMessageCount - retainedMessages.length, }; } diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 7cd1cc82d6fec..0cef7d96bbdfe 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -156,25 +156,43 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); - it.effect("returns null for non-git folders and repos without remotes", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-non-git-", - }); - const gitDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-no-remote-", - }); + it.effect( + "returns null for non-git, missing and no-remote folders, probing only what can exist", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-non-git-", + }); + const gitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-no-remote-", + }); + + yield* git(gitDir, ["init"]); + + const runner = yield* ProcessRunner.ProcessRunner; + const runs: Array> = []; + const resolver = yield* RepositoryIdentityResolver.make().pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.suspend(() => { + runs.push(input.args); + return runner.run(input); + }), + }), + ); + const bareProbes = () => + runs.filter((args) => args.some((arg) => arg.startsWith("--git-dir="))); - yield* git(gitDir, ["init"]); + expect(yield* resolver.resolve(path.join(nonGitDir, "deleted"))).toBeNull(); + expect(runs).toEqual([]); - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const nonGitIdentity = yield* resolver.resolve(nonGitDir); - const noRemoteIdentity = yield* resolver.resolve(gitDir); + expect(yield* resolver.resolve(nonGitDir)).toBeNull(); + expect(bareProbes()).toEqual([]); - expect(nonGitIdentity).toBeNull(); - expect(noRemoteIdentity).toBeNull(); - }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + expect(yield* resolver.resolve(gitDir)).toBeNull(); + }).pipe(Effect.provide(ProcessRunner.layer)), ); it.effect("ignores local-path remotes such as GitButler's gb-local", () => diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 659ace3fd2b49..cdf9dc5e1b309 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -6,7 +6,9 @@ import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as ProcessRunner from "../processRunner.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; @@ -138,8 +140,18 @@ class RepositoryIdentityCacheKey extends Data.Class<{ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const env = explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform); + // `git -C` cannot enter a missing folder or a file, so every probe below would fail. + const isFolder = yield* fileSystem.stat(cwd).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), + Effect.orElseSucceed(() => true), + ); + if (!isFolder) return null; + // git is always a real executable; cmd shell mode would split spaced paths on re-tokenization. const topLevelResult = yield* processRunner .run({ @@ -160,7 +172,25 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. } // Explicit Git directories preserve safe.bareRepository=explicit for both layouts. - for (const gitDir of [".", ".git"]) { + for (const [gitDir, entry] of [ + [".", "HEAD"], + [".git", ".git"], + ] as const) { + // An explicit --git-dir fails unless the folder has this entry; a symlink counts even + // when its target is missing, as git reads a symlinked HEAD's target text. + const target = path.join(cwd, entry); + const hasEntry = yield* fileSystem.exists(target).pipe( + Effect.flatMap((exists) => + exists + ? Effect.succeed(true) + : fileSystem.readLink(target).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), + ), + Effect.orElseSucceed(() => true), + ); + if (!hasEntry) continue; const bareResult = yield* processRunner .run({ command: "git", @@ -224,6 +254,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( options: RepositoryIdentityResolverOptions = {}, ) { const processRunner = yield* ProcessRunner.ProcessRunner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; const refine = options.refine ?? Effect.succeed; const timeToLive = (exit: Exit.Exit) => @@ -237,6 +269,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( (cwd) => resolveRepositoryIdentityCacheKey(cwd).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), ), { capacity: cacheCapacity, timeToLive }, ); diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index bc7c14d3b3c8a..e6835159f9e7f 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -229,6 +229,7 @@ describe("ACP Registry probe", () => { { id: "medium", label: "Medium" }, { id: "high", label: "High" }, ], + role: "reasoning", }, ]); }); diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 4ecc0ad28971b..8139df621f98c 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -129,9 +129,9 @@ describe("buildGrokModelCapabilities", () => { type: "select", currentValue: "medium", options: [ - { id: "xhigh", label: "Extra High Effort", isDefault: true }, - { id: "high", label: "High Effort" }, { id: "medium", label: "Medium Effort" }, + { id: "high", label: "High Effort" }, + { id: "xhigh", label: "Extra High Effort", isDefault: true }, ], }, ]); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 07830f90d0567..3e1e5dc80f702 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 5129, - "codexAdditionalContextBytes": 6859, - "mcpToolListJsonBytes": 46109 + "claudeSystemAppendBytes": 5153, + "codexAdditionalContextBytes": 6883, + "mcpToolListJsonBytes": 47074 } diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 9b7d1ffb35d41..6f3e49e4d4c46 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import { ServerProviderUpdateError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -15,6 +16,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -755,6 +757,41 @@ describe("providerMaintenanceRunner", () => { ), ); + // A native updater such as `claude update` downloads a ~300 MB binary and + // prints nothing while it does. Killed mid-download, it leaves a truncated + // binary behind that later updates mistake for an install. + it.effect.each([ + { runFor: "6 minutes", status: "succeeded", message: "Provider updated." }, + { runFor: "31 minutes", status: "failed", message: "Update timed out after 30 minutes." }, + ] as const)("lets a slow installer run for $runFor", ({ runFor, status, message }) => { + const started = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseNativeCliProvider); + const updater = yield* makeTestRunner(registry); + + const update = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkScoped); + yield* Deferred.await(started); + yield* TestClock.adjust(runFor); + const result = yield* Fiber.join(update); + + assert.strictEqual(result.providers[0]?.updateState?.status, status); + assert.strictEqual(result.providers[0]?.updateState?.message, message); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("1.0.0"), + layerMockSpawner(() => ({ + exitCode: Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.sleep(runFor)), + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + })), + ), + ), + ); + }); + it.effect("prevents concurrent updates for the same provider", () => { const startedLatch: { resolve: () => void } = { resolve: () => {} }; const releaseLatch: { resolve: () => void } = { resolve: () => {} }; @@ -856,17 +893,31 @@ describe("providerMaintenanceRunner", () => { ), ); - it.effect("resolves npm to a .cmd shim and routes through the shell on win32", () => { + it.effect("resolves npm to a .cmd shim from the provider's PATH on win32", () => { const captured: Array<{ readonly command: string; readonly args: ReadonlyArray; readonly shell: boolean | string | undefined; + readonly env: NodeJS.ProcessEnv | undefined; }> = []; return Effect.gen(function* () { const { registry } = yield* makeRegistry(baseProvider, { installedVersionAfterUpdate: "1.0.0", }); - const runner = yield* makeTestRunner(registry); + const runner = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", + env: { PATH: "C:\\fake\\npm" }, + }), + ), + }); const result = yield* runner.updateProvider(CODEX_DRIVER); @@ -879,17 +930,21 @@ describe("providerMaintenanceRunner", () => { ); assert.deepEqual(call.args, []); assert.strictEqual(call.shell, true); + assert.deepStrictEqual(call.env, { PATH: "C:\\fake\\npm" }); assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); }).pipe( Effect.provide( Layer.mergeAll( Layer.succeed(HostProcess.Platform, "win32"), + // Only the provider's PATH has npm. Layer.succeed(HostProcess.Environment, { - PATH: "C:\\fake\\npm", + PATH: "C:\\Windows\\System32", PATHEXT: ".COM;.EXE;.BAT;.CMD", }), - Layer.succeed(SpawnExecutableResolution, (command) => - command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined, + Layer.succeed(SpawnExecutableResolution, (command, _platform, env) => + command === "npm" && env.PATH === "C:\\fake\\npm" + ? "C:\\fake\\npm\\npm.cmd" + : undefined, ), layerLatestVersionHttpClient("1.0.0"), Layer.succeed( @@ -898,12 +953,16 @@ describe("providerMaintenanceRunner", () => { const childProcess = command as unknown as { readonly command: string; readonly args: ReadonlyArray; - readonly options: { readonly shell?: boolean | string | undefined }; + readonly options: { + readonly shell?: boolean | string | undefined; + readonly env?: NodeJS.ProcessEnv | undefined; + }; }; captured.push({ command: childProcess.command, args: childProcess.args, shell: childProcess.options.shell, + env: childProcess.options.env, }); return Effect.succeed(mockHandle({ stdout: "updated" })); }), diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index c2347da003300..ee7709e710eda 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -41,7 +41,11 @@ import type { ProviderMaintenanceCapabilities } from "@t3tools/provider-core/ser import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); -const UPDATE_TIMEOUT_MS = 5 * 60_000; +// Native updaters (`claude update`) download a ~300 MB binary without printing +// progress, and killing one mid-download leaves a truncated binary that the +// next run treats as installed. Allow a slow connection to finish. +const UPDATE_TIMEOUT_MINUTES = 30; +const UPDATE_TIMEOUT_MS = UPDATE_TIMEOUT_MINUTES * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; // Sample installer output because every progress update resends the provider list to all clients. const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1); @@ -98,13 +102,14 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { + const env = input.env ? { env: input.env, extendEnv: true } : {}; // Windows batch shims require shell dispatch through resolveSpawnCommand. - const resolved = yield* resolveSpawnCommand(input.command, input.args); + const resolved = yield* resolveSpawnCommand(input.command, input.args, env); const child = yield* input.spawner .spawn( ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell, - ...(input.env ? { env: input.env, extendEnv: true } : {}), + ...env, }), ) .pipe( @@ -259,7 +264,7 @@ function lastOutputLine(result: ProviderMaintenanceCommandResult): string | null function failureMessage(result: ProviderMaintenanceCommandResult): string { if (result.timedOut) { - return "Update timed out."; + return `Update timed out after ${UPDATE_TIMEOUT_MINUTES} minutes.`; } const summary = result.exitCode !== null && result.exitCode !== 0 diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts index a47743b34263a..92704c51102fe 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.test.ts @@ -152,6 +152,48 @@ describe("normalizeReviewGuide", () => { expect(result.stops).toHaveLength(1); }); + it("merges excerpts that a later excerpt bridges into one", () => { + const result = normalizeReviewGuide( + guide({ + stops: [ + stop({ + excerpts: [ + excerpt("src/a.ts", 40, 41), + { ...excerpt("src/a.ts", 50, 51), caption: "Second" }, + excerpt("src/a.ts", 41, 50), + ], + }), + ], + }), + change, + ); + expect(result.stops[0]?.excerpts).toEqual([ + { ...excerpt("src/a.ts", 40, 51), caption: "Second" }, + ]); + + const tailNote = { path: "src/a.ts", line: 40, title: "Tail", body: "Keep this note." }; + const wide = normalizeReviewGuide( + guide({ + stops: [ + stop({ + excerpts: [ + excerpt("src/a.ts", 1, 25), + { ...excerpt("src/a.ts", 30, 45), caption: "Second" }, + excerpt("src/a.ts", 24, 32), + ], + notes: [tailNote], + }), + ], + }), + parseReviewGuideChange( + "src/a.ts\0", + "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -0,0 +1,60 @@\n+added", + ), + ); + expect(wide.stops[0]?.excerpts).toEqual([{ ...excerpt("src/a.ts", 1, 25), caption: "Second" }]); + expect(wide.stops[0]?.notes).toEqual([]); + }); + it("drops notes outside the stop's remaining excerpts", () => { const result = normalizeReviewGuide( guide({ diff --git a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts index 184908e4bd7f2..82edfc5fb8159 100644 --- a/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts +++ b/apps/server/src/pullRequest/pullRequestReviewGuideNormalize.ts @@ -87,26 +87,29 @@ function clampExcerpt( function mergeExcerpts( excerpts: ReadonlyArray, ): Array { - const merged: Array = []; + let merged: Array = []; for (const excerpt of excerpts) { - const index = merged.findIndex( - (other) => - other.path === excerpt.path && - other.side === excerpt.side && - other.start <= excerpt.end + 1 && - excerpt.start <= other.end + 1, - ); - const other = merged[index]; - if (other === undefined) { + const touches = (other: PullRequestReviewGuideExcerpt) => + other.path === excerpt.path && + other.side === excerpt.side && + other.start <= excerpt.end + 1 && + excerpt.start <= other.end + 1; + // An excerpt can bridge several earlier ones; fold them all into the first. + const group = merged.filter(touches); + const first = group[0]; + if (first === undefined) { merged.push(excerpt); continue; } - merged[index] = { - ...other, - start: Math.min(other.start, excerpt.start), - end: Math.max(other.end, excerpt.end), - caption: other.caption || excerpt.caption, + const combined = { + ...first, + start: Math.min(excerpt.start, ...group.map((other) => other.start)), + end: Math.max(excerpt.end, ...group.map((other) => other.end)), + caption: group.find((other) => other.caption)?.caption ?? excerpt.caption, }; + merged = merged.flatMap((other) => + other === first ? [combined] : touches(other) ? [] : [other], + ); } return merged.map((excerpt) => ({ ...excerpt, diff --git a/apps/server/src/scheduledTasks/Schedule.test.ts b/apps/server/src/scheduledTasks/Schedule.test.ts index f5973969747f7..0941c7392ff94 100644 --- a/apps/server/src/scheduledTasks/Schedule.test.ts +++ b/apps/server/src/scheduledTasks/Schedule.test.ts @@ -27,6 +27,12 @@ describe("scheduled task schedule calculation", () => { }); it("compares schedules structurally", () => { + expect( + isSameSchedule( + { type: "once", runAt: "2026-11-01T03:00:00-05:00" }, + { type: "once", runAt: "2026-11-01T08:00:00.000Z" }, + ), + ).toBe(true); expect( isSameSchedule({ type: "interval", everyMs: 60_000 }, { type: "interval", everyMs: 60_000 }), ).toBe(true); diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index bac32bb913146..f8fb648aaaaa9 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -1,5 +1,6 @@ import { MIN_SCHEDULED_TASK_INTERVAL_MS, type ScheduledTaskSchedule } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; const MINUTE_MS = 60_000; @@ -14,6 +15,13 @@ export function nextScheduledRunAt( from: DateTime.DateTime, ): DateTime.DateTime | null { if (schedule.type === "webhook") return null; + if (schedule.type === "once") { + const runAt = DateTime.make(schedule.runAt); + return Option.isSome(runAt) && + DateTime.toEpochMillis(runAt.value) > DateTime.toEpochMillis(from) + ? runAt.value + : null; + } if (schedule.type === "interval") { // Intervals advance elapsed time, including across daylight-saving changes. return DateTime.addDuration(from, Math.max(schedule.everyMs, MIN_SCHEDULED_TASK_INTERVAL_MS)); @@ -48,6 +56,7 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul return b.type === "interval" && a.everyMs === b.everyMs; } if (a.type === "webhook") return b.type === "webhook"; + if (a.type === "once") return b.type === "once" && Date.parse(a.runAt) === Date.parse(b.runAt); if (b.type !== "fixed_time") return false; // Compare parsed times so padded-hour edits do not reschedule pending runs. const aTime = parseTimeOfDay(a.timeOfDay); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts index 4a1a58505c5cb..c2cc1f382b68c 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.interval.test.ts @@ -4,6 +4,7 @@ import { ScheduledTaskUpsertInput } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; import { afterEach, vi } from "vite-plus/test"; @@ -129,3 +130,185 @@ it.effect.each([ Effect.provide(SqlitePersistence.layerMemory), ), ); + +// Each launch takes two minutes, so a manual run can start before the run time and end after it. +const slowLaunchLayer = ScheduledTaskService.layer.pipe( + Layer.provide( + Layer.merge( + dependencies, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => TestClock.adjust("2 minutes").pipe(Effect.andThen(Effect.die("synthetic"))), + }), + ), + ), +); + +it.effect("runs a one-time schedule once and rejects past times", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00.000Z")); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const decoded = yield* decodeInput({ + ...input, + schedule: { type: "once", runAt: "2026-11-01T03:00:00-05:00" }, + }); + const created = yield* service.upsert(decoded); + expect(created.task.schedule).toEqual({ type: "once", runAt: "2026-11-01T08:00:00.000Z" }); + expect(created.task.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + + // A manual run that starts before the time keeps the scheduled run, even if it ends after. + const early = yield* service.runNow({ id: created.task.id }); + expect(early.task.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + + const due = yield* service.runNow({ id: created.task.id }); + expect(due.task.nextRunAt).toBeNull(); + expect((yield* service.list()).tasks[0]?.nextRunAt).toBeNull(); + + yield* service.setEnabled({ id: created.task.id, enabled: false }); + const resumed = yield* service + .setEnabled({ id: created.task.id, enabled: true }) + .pipe(Effect.flip); + expect(resumed.message).toBe("The run time has passed. Pick a time in the future."); + + const past = yield* service + .upsert({ ...decoded, commandId: undefined, title: "Late" }) + .pipe(Effect.flip); + expect(past.message).toBe("The run time has passed. Pick a time in the future."); + }).pipe(Effect.provide(slowLaunchLayer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +const ONCE = { type: "once", runAt: "2026-11-01T08:00:00Z" } as const; +/** A next run that only the simulated concurrent write produces. */ +const MOVED = "2030-01-01T00:00:00.000Z"; + +const onceInput = { + ...input, + commandId: "once-race", + schedule: { type: "once", runAt: "2026-11-01T08:00:00Z" }, +}; + +it.effect.each([ + { change: "a run completes", saved: ONCE, edited: ONCE, pause: false, expected: MOVED }, + { + change: "a run completes during a padded-time edit", + saved: { type: "fixed_time", timeOfDay: "9:00" }, + edited: { type: "fixed_time", timeOfDay: "09:00" }, + pause: false, + expected: MOVED, + }, + { + change: "another client pauses the task", + saved: ONCE, + edited: ONCE, + pause: true, + expected: "2026-11-01T08:00:00.000Z", + }, +] as const)( + "a title save keeps the stored next run only while nothing else changed: $change", + ({ saved, edited, pause, expected }) => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:00:00.000Z")); + // The project check runs between the save's read and write, so it stands in for + // a concurrent write in that gap. + const writeMidSave = Layer.unwrap( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => + sql`UPDATE scheduled_tasks SET next_run_at = ${MOVED}, enabled = ${pause ? 0 : 1}`.pipe( + Effect.as(false), + Effect.orDie, + ), + }); + }), + ); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide(Layer.merge(dependencies, writeMidSave)), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const worktree = { type: "worktree", baseRef: "main" }; + yield* service.upsert( + yield* decodeInput({ + ...onceInput, + schedule: saved, + workspaceStrategy: { type: "root" }, + }), + ); + const renamed = yield* service.upsert( + yield* decodeInput({ + ...onceInput, + schedule: edited, + workspaceStrategy: worktree, + title: "Renamed", + }), + ); + expect(renamed.task.enabled).toBe(true); + expect(renamed.task.nextRunAt).toBe(expected); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +it.effect("keeps a one-time run after a restart interrupts an earlier manual run", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:59:00.000Z")); + const sql = yield* SqlClient.SqlClient; + const created = yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + return yield* service.upsert(yield* decodeInput(onceInput)); + }).pipe(Effect.provide(serviceLayer)); + yield* sql`UPDATE scheduled_tasks SET last_run_status = 'running', last_run_at = '2026-11-01T07:59:00.000Z'`; + + yield* TestClock.setTime(Date.parse("2026-11-01T08:05:00.000Z")); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const [task] = (yield* service.list()).tasks; + expect(task?.id).toBe(created.task.id); + expect(task?.lastRunStatus).toBe("failed"); + expect(task?.nextRunAt).toBe("2026-11-01T08:00:00.000Z"); + }).pipe(Effect.provide(serviceLayer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); + +it.effect("reports the stored task when the row was replaced during the run", () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-11-01T07:00:00.000Z")); + // The launch stands in for a delete and same-id recreate while the run is out. + const replaceRowDuringLaunch = Layer.unwrap( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return Layer.mock(ThreadLaunchService.ThreadLaunchService)({ + launch: () => + sql`UPDATE scheduled_tasks SET last_run_status = 'never', last_run_at = NULL, run_count = 0`.pipe( + Effect.orDie, + Effect.andThen(Effect.die("synthetic dispatch failure")), + ), + }); + }), + ); + const layer = ScheduledTaskService.layer.pipe( + Layer.provide(Layer.merge(dependencies, replaceRowDuringLaunch)), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const created = yield* service.upsert(yield* decodeInput(onceInput)); + const ran = yield* service.runNow({ id: created.task.id }); + expect(ran.task.runCount).toBe(0); + expect(ran.task.lastRunStatus).toBe("never"); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.provideService(ServerActivation, Effect.never), + Effect.provide(SqlitePersistence.layerMemory), + ), +); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index c41f0a952ab7c..c12474a6fb800 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -298,6 +298,35 @@ function nextRunAt( } } +/** + * Next run after an interrupted run. A one-time task counts from the run's start + * (`lastRunAt`), so a manual run that began before its time does not use it up. + */ +function nextRunAfterRun( + task: Pick, + now: DateTime.DateTime, +): string | null { + const startedAt = + task.schedule.type === "once" && task.lastRunAt !== null + ? DateTime.make(task.lastRunAt) + : Option.none(); + return nextRunAt( + task, + Option.getOrElse(startedAt, () => now), + ); +} + +/** An enabled one-time task with a past time would never run, so saving it is an error. */ +function requireFutureOnce( + task: Pick, + now: DateTime.DateTime, + taskId: ScheduledTaskId, +) { + return task.enabled && task.schedule.type === "once" && nextRunAt(task, now) === null + ? taskError("The run time has passed. Pick a time in the future.", { taskId }) + : Effect.void; +} + function errorMessage(error: unknown): string { if (Cause.isCause(error)) return Cause.pretty(error); if (error instanceof Error) return error.message; @@ -542,6 +571,8 @@ export const layer = Layer.effect( const saveTask = ( task: ScheduledTask, requireExisting: boolean, + /** The schedule this save read, when it left the schedule unchanged. */ + keptSchedule: ScheduledTask["schedule"] | null, webhook: { readonly token: string | null; readonly secret: string | null; @@ -612,7 +643,18 @@ export const layer = Layer.effect( interaction_mode = excluded.interaction_mode, creation_source = excluded.creation_source, updated_at = excluded.updated_at, - next_run_at = excluded.next_run_at, + -- Keep a next_run_at that a finishing run moved during this save, unless + -- another edit changed the schedule or enabled state meanwhile. + next_run_at = CASE + WHEN ${keptSchedule === null ? 0 : 1} = 1 + AND scheduled_tasks.schedule_json IN ( + excluded.schedule_json, + ${keptSchedule === null ? null : JSON.stringify(keptSchedule)} + ) + AND scheduled_tasks.enabled = excluded.enabled + THEN scheduled_tasks.next_run_at + ELSE excluded.next_run_at + END, -- Only rotate changes a live token, so a save racing a rotation -- cannot bring the old URL back. webhook_token = CASE @@ -685,7 +727,9 @@ export const layer = Layer.effect( WHERE task_id = ${input.id} AND last_run_status = 'running' AND last_run_at = ${input.startedAtIso} + RETURNING task_id `.pipe( + Effect.map((rows) => rows.length > 0), Effect.mapError((cause) => taskError("Could not record schedule task run.", { taskId: input.id, cause }), ), @@ -695,7 +739,12 @@ export const layer = Layer.effect( // runDueTasks skips 'running' rows forever, and the dispatch may already have gone out, // so next_run_at must advance. Pending webhook attempts count only with their completion. const releaseStuckRun = Effect.fn("ScheduledTaskService.releaseStuckRun")( - function* (task: ScheduledTask, message: string, pendingDeliveryId?: string) { + function* ( + task: ScheduledTask, + startedAtIso: string, + message: string, + pendingDeliveryId?: string, + ) { const now = yield* localNow; const reread = yield* Effect.result(findTask(task.id)); if (Result.isSuccess(reread) && reread.success === null) return; @@ -704,7 +753,7 @@ export const layer = Layer.effect( UPDATE scheduled_tasks SET last_run_status = 'failed', last_run_error = ${message}, - next_run_at = ${nextRunAt(source, now)}, + next_run_at = ${nextRunAfterRun({ ...source, lastRunAt: startedAtIso }, now)}, updated_at = ${iso(now)}, run_count = run_count + CASE WHEN EXISTS ( SELECT 1 FROM scheduled_task_webhook_pending @@ -714,7 +763,7 @@ export const layer = Layer.effect( `; yield* notifyChanged; }, - (effect, task, _message, _pendingDeliveryId) => + (effect, task, _startedAtIso, _message, _pendingDeliveryId) => effect.pipe( Effect.catch((cause) => Effect.logWarning("Could not release stuck schedule task run", { @@ -734,6 +783,9 @@ export const layer = Layer.effect( readonly dispatched: Ref.Ref; }, ) { + // Read before reserving: nothing between the reservation and its release may fail. + const startedAt = yield* localNow; + const startedAtIso = iso(startedAt); const reserved = yield* Ref.modify(activeRuns, (active) => { if (active.has(task.id)) return [false, active] as const; const next = new Set(active); @@ -744,13 +796,10 @@ export const layer = Layer.effect( if (trigger !== "scheduled") { return yield* taskError("Schedule task is already running.", { taskId: task.id }); } - return { task, threadId: null }; + return { task, threadId: null, dispatchFailed: false }; } return yield* Effect.gen(function* () { - const startedAt = yield* localNow; - const startedAtIso = iso(startedAt); - // Re-read stale poll snapshots so deleted, paused or postponed tasks cannot fire. const active = yield* findTask(task.id); if (active === null) { @@ -762,7 +811,7 @@ export const layer = Layer.effect( if (trigger !== "scheduled") { return yield* taskError("Schedule task not found.", { taskId: task.id }); } - return { task, threadId: null }; + return { task, threadId: null, dispatchFailed: false }; } if ( webhook !== undefined && @@ -770,7 +819,11 @@ export const layer = Layer.effect( NodeSqliteClient.readOnly, )).length > 0 ) - return { task: { ...active, lastRunStatus: "succeeded" as const }, threadId: null }; + return { + task: { ...active, lastRunStatus: "succeeded" as const }, + threadId: null, + dispatchFailed: false, + }; const parsedNextRunAt = active.nextRunAt === null ? Option.none() : DateTime.make(active.nextRunAt); if ( @@ -779,7 +832,7 @@ export const layer = Layer.effect( Option.isNone(parsedNextRunAt) || DateTime.toEpochMillis(parsedNextRunAt.value) > DateTime.toEpochMillis(startedAt)) ) { - return { task: active, threadId: null }; + return { task: active, threadId: null, dispatchFailed: false }; } // Reject queued deliveries after pause, same-id recreation or trigger changes. if (webhook !== undefined) { @@ -895,18 +948,28 @@ export const layer = Layer.effect( const lastRunStatus = runSucceeded ? ("succeeded" as const) : ("failed" as const); const lastRunError = runSucceeded ? null : errorMessage(result.cause); const threadId = runSucceeded ? result.value : null; - const current = yield* findTask(task.id); - const scheduleSource = current ?? task; - const completed: ScheduledTask = { - ...scheduleSource, - updatedAt: iso(completedAt), - lastRunAt: startedAtIso, - nextRunAt: nextRunAt(scheduleSource, completedAt), - lastRunStatus, - lastRunError, - runCount: scheduleSource.runCount + 1, - }; - if (current !== null) { + // One transaction, so an edit cannot land between reading the schedule and + // writing the next run computed from it. The origin is read first: it reads + // files, and the transaction holds SQLite's writer lock. + const origin = yield* readWebhookOrigin; + const record = Effect.gen(function* () { + const [row] = yield* getRows(task.id); + const current = row === undefined ? null : yield* decodeRow(row, origin); + const scheduleSource = current ?? task; + const completed: ScheduledTask = { + ...scheduleSource, + updatedAt: iso(completedAt), + lastRunAt: startedAtIso, + // A manual run that starts before a one-time run must not use it up. + nextRunAt: nextRunAt( + scheduleSource, + scheduleSource.schedule.type === "once" ? startedAt : completedAt, + ), + lastRunStatus, + lastRunError, + runCount: scheduleSource.runCount + 1, + }; + if (current === null) return { recorded: completed, written: false }; // startedAtIso in the guard ensures this writes only to the row this // run marked as running — a task deleted mid-run and recreated with // the same id (idempotent commandId replay) must not be stamped. @@ -918,32 +981,43 @@ export const layer = Layer.effect( error: lastRunError, startedAtIso, }); - if (webhook === undefined) yield* completion; - else - yield* sql - .withTransaction( - Effect.gen(function* () { - const pending = - yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; - if (pending.length === 0) yield* completion; - else { - const counted = - yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} - WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; - if (counted.length > 0) yield* completion; - } - }), - ) - .pipe( - Effect.mapError((cause) => - taskError("Could not record webhook completion.", { taskId: task.id, cause }), - ), - ); - yield* notifyChanged; - } - return { task: completed, threadId }; + let written = false; + if (webhook === undefined) written = yield* completion; + else { + const pending = + yield* sql`SELECT 1 FROM scheduled_task_webhook_pending WHERE delivery_id = ${webhook.deliveryId}`; + if (pending.length === 0) written = yield* completion; + else { + const counted = + yield* sql`UPDATE scheduled_task_webhook_pending SET state = ${runSucceeded ? "completed" : "failed"} + WHERE delivery_id = ${webhook.deliveryId} AND state NOT IN ('completed', 'failed') RETURNING delivery_id`; + if (counted.length > 0) written = yield* completion; + } + } + // A row recreated mid-run is not this run's, so report what is stored. + return { recorded: written ? completed : current, written }; + }); + const { recorded, written } = yield* sql + .withTransaction(record) + .pipe( + Effect.mapError((cause) => + isScheduledTaskError(cause) + ? cause + : taskError( + webhook === undefined + ? "Could not record schedule task run." + : "Could not record webhook completion.", + { taskId: task.id, cause }, + ), + ), + ); + if (written) yield* notifyChanged; + // The stored row can belong to a replacement, so report this dispatch separately. + return { task: recorded, threadId, dispatchFailed: !runSucceeded }; }).pipe( - Effect.onError((cause) => releaseStuckRun(task, errorMessage(cause), webhook?.deliveryId)), + Effect.onError((cause) => + releaseStuckRun(task, startedAtIso, errorMessage(cause), webhook?.deliveryId), + ), Effect.ensuring( Ref.update(activeRuns, (active) => { const next = new Set(active); @@ -1039,7 +1113,7 @@ export const layer = Layer.effect( UPDATE scheduled_tasks SET last_run_status = 'failed', last_run_error = 'Run was interrupted by a server restart.', - next_run_at = ${nextRunAt(decoded.success, now)}, + next_run_at = ${nextRunAfterRun(decoded.success, now)}, updated_at = ${iso(now)}, run_count = run_count + CASE WHEN EXISTS ( SELECT 1 FROM scheduled_task_webhook_pending @@ -1150,7 +1224,9 @@ export const layer = Layer.effect( : undefined), ), } - : input.schedule; + : input.schedule.type === "once" + ? { type: "once", runAt: iso(DateTime.makeUnsafe(input.schedule.runAt)) } + : input.schedule; const webhook = input.schedule.type === "webhook" ? yield* Effect.gen(function* () { @@ -1196,6 +1272,8 @@ export const layer = Layer.effect( existingTask !== null && existingTask.enabled === input.enabled && isSameSchedule(existingTask.schedule, schedule); + if (!scheduleUnchanged) + yield* requireFutureOnce({ enabled: input.enabled, schedule }, now, id); const task: ScheduledTask = { id, title: input.title, @@ -1220,7 +1298,12 @@ export const layer = Layer.effect( lastRunError: existingTask?.lastRunError ?? null, runCount: existingTask?.runCount ?? 0, }; - yield* saveTask(task, input.requireExisting === true, webhook); + yield* saveTask( + task, + input.requireExisting === true, + scheduleUnchanged ? existingTask.schedule : null, + webhook, + ); yield* notifyChanged; return { task: (yield* findTask(id)) ?? task }; }); @@ -1231,6 +1314,11 @@ export const layer = Layer.effect( const existing = yield* loadTask(input.id); if (existing.enabled === input.enabled) return { task: existing }; const now = yield* localNow; + yield* requireFutureOnce( + { enabled: input.enabled, schedule: existing.schedule }, + now, + input.id, + ); const next = nextRunAt({ enabled: input.enabled, schedule: existing.schedule }, now); // RETURNING so a task deleted between the load and this UPDATE is a // visible not-found error, not a false success. @@ -1668,8 +1756,8 @@ export const layer = Layer.effect( ]); const dispatched = yield* Ref.make(false); return yield* runTask(task, "webhook", { deliveryId, prompt, dispatched }).pipe( - Effect.flatMap(({ task: completed }) => - completed.lastRunStatus === "failed" + Effect.flatMap(({ dispatchFailed }) => + dispatchFailed ? runOutcome("failed").pipe( Effect.andThen(markDeliveryFailed(deliveryId, "The run failed to start.")), Effect.andThen(releaseFailedDelivery(deliveryId)), diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts index cdd4e60e62d76..3bc3fe506e78f 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.webhook.test.ts @@ -1199,6 +1199,32 @@ it.effect("start failures release both replay keys and relay claims for redelive ), ); +it.effect("a start failure releases the relay claim when the task row is replaced mid-run", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + yield* withService( + ({ service, sql, launches }) => + Effect.gen(function* () { + const { task } = yield* service.upsert(yield* webhookTaskInput()); + const request = requestFor(task, { relayDeliveryId: "replaced-mid-run" }); + const first = yield* waitForSpan( + service.triggerWebhook(request), + "ScheduledTaskService.runWebhookDelivery", + ).pipe(Effect.forkChild); + yield* Queue.take(launches); + // A same-id recreate while the launch is out: the stored row is no longer this run's. + yield* sql`UPDATE scheduled_tasks SET last_run_status = 'never', last_run_at = NULL`; + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(first); + const retry = yield* service.triggerWebhook(request); + assert.equal(retry._tag === "accepted" && retry.outcome, "accepted"); + yield* Queue.take(launches); + }), + { gate, failLaunches: { remaining: 1 } }, + ); + }), +); + it.effect("main retention keeps fifty runs and preserves filtered and dedupe buckets", () => withService(({ service, sql, launches }) => Effect.gen(function* () { diff --git a/apps/server/src/scheduledTasks/webhookFilter.test.ts b/apps/server/src/scheduledTasks/webhookFilter.test.ts index 1194a1aed5177..2c7f2cc4062e8 100644 --- a/apps/server/src/scheduledTasks/webhookFilter.test.ts +++ b/apps/server/src/scheduledTasks/webhookFilter.test.ts @@ -16,6 +16,28 @@ const issueComment: WebhookRequest = { }; describe("findFailedWebhookFilter", () => { + it("matches exists on wildcard object values", () => { + assert.isNull( + findFailedWebhookFilter([{ path: "body.issue.labels[]", op: "exists" }], issueComment), + ); + assert.isNull( + findFailedWebhookFilter([{ path: "body.issues[].labels", op: "exists" }], { + ...issueComment, + bodyText: JSON.stringify({ issues: [{ labels: [] }] }), + }), + ); + }); + + it("rejects exists when wildcard values are all null or missing", () => { + assert.deepEqual( + findFailedWebhookFilter([{ path: "body.issues[].labels", op: "exists" }], { + ...issueComment, + bodyText: JSON.stringify({ issues: [{ labels: null }, {}] }), + }), + { index: 0, path: "body.issues[].labels" }, + ); + }); + it("matches a wildcard path when any element matches", () => { assert.isNull( findFailedWebhookFilter( diff --git a/apps/server/src/scheduledTasks/webhookFilter.ts b/apps/server/src/scheduledTasks/webhookFilter.ts index 882f300364288..2ad0e0237e079 100644 --- a/apps/server/src/scheduledTasks/webhookFilter.ts +++ b/apps/server/src/scheduledTasks/webhookFilter.ts @@ -36,6 +36,8 @@ function matchesScalar(filter: ScheduledTaskWebhookFilter, text: string): boolea */ function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean { if (Array.isArray(value) && /\[\]/.test(filter.path)) { + if (filter.op === "exists") + return value.some((element) => element !== null && element !== undefined); const texts = value.map(scalarText).filter((text): text is string => text !== undefined); if (filter.op === "notEquals") return texts.every((text) => text !== filter.value); return texts.some((text) => matchesScalar(filter, text)); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 8e1a2d2f7ec3e..2b08e4f51e5a5 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -765,6 +765,30 @@ it.layer( }), ); + it.effect("labels a running command by its whole process name, spaces included", () => + Effect.gen(function* () { + const { manager } = yield* createManager(5, { + subprocessPollIntervalMs: 1_000, + processKillGraceMs: 0, + processTable: Effect.succeed([ + { pid: 9000, ppid: 1, name: "zsh" }, + { pid: 200, ppid: 9000, name: "Google Chrome" }, + ]), + }).pipe(Effect.provide(layerWithHostPlatform("darwin"))); + const label = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "activity" + ? Deferred.succeed(label, event.label).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + yield* manager.open(openInput()); + yield* TestClock.adjust("1 second"); + + expect(yield* Deferred.await(label)).toBe("Google Chrome"); + }).pipe(Effect.provide(TestClock.layer())), + ); + // Answers process-table queries after `queryDuration`, honoring the caller's // timeout the way ProcessRunner's `timedOutResult` mode does. const slowProcessTable = ( diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 9d3942c6cb62b..c881dbd7b19a8 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -365,10 +365,9 @@ function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): stri ) { trimmed = trimmed.slice(1, -1).trim(); } - const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); - if (firstToken.length === 0) return null; + // Process names and macOS `comm` paths can contain spaces ("Google Chrome"). const separators = platform === "win32" ? /[\\/]/ : /\//; - const base = firstToken.split(separators).at(-1) ?? firstToken; + const base = (trimmed.split(separators).at(-1) ?? trimmed).trim(); const withoutExe = platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; return withoutExe.length > 0 ? withoutExe : null; diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 3fcafcc151789..7e1f1446d1a55 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -5,6 +5,7 @@ import { ProviderInstanceId, ProviderSetupError, } from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -60,6 +61,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* readonly startError?: AcpError; readonly rejectAdmission?: boolean; readonly sessionId?: string; + readonly startupSkills?: boolean; } = {}, ) { const fs = yield* FileSystem.FileSystem; @@ -135,6 +137,13 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* start: () => Effect.gen(function* () { if (options.startError) return yield* options.startError; + if (options.startupSkills) { + for (const directory of [".agents", ".gemini"]) { + yield* fs + .makeDirectory(path.join(cwd, directory, "skills"), { recursive: true }) + .pipe(Effect.orDie); + } + } yield* fs.makeDirectory(brainDirectory, { recursive: true }).pipe(Effect.orDie); yield* fs.writeFileString(`${sessionBase}.db`, "helper session").pipe(Effect.orDie); yield* fs.writeFileString(`${sessionBase}.db-wal`, "helper journal").pipe(Effect.orDie); @@ -279,11 +288,12 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* }); it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { - it.effect( - "generates all helper types in empty workspaces and removes only owned session files", - () => + it.effect.each([false, true])( + "generates all helper types and cleans up with startupSkills=%s", + (startupSkills) => Effect.gen(function* () { const fixture = yield* makeFixture({ + startupSkills, outputs: [ '{"subject":" Repair Google login.\\nExtra line","body":" Keep the remote callback. ","branch":"Repair Login"}', '```json\n{"title":" Repair Google login\\nExtra line","body":" ## Summary\\nSupport remote callbacks. "}\n```', @@ -442,29 +452,51 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("rejects a helper that writes files in its temporary workspace", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const fixture = yield* makeFixture({ - prompt: ({ cwd, emit }) => - Effect.gen(function* () { - yield* fs - .writeFileString(path.join(cwd, "unexpected.txt"), "unexpected") - .pipe(Effect.orDie); - yield* emit({ - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: '{"title":"Ignored output"}' }, - }); - return { stopReason: "end_turn" }; - }), - }); - const error = yield* fixture.textGeneration - .generateThreadTitle(fixture.titleInput) - .pipe(Effect.flip); - expect(error.detail).toContain("wrote files"); - yield* fixture.assertCleaned; - }).pipe(Effect.scoped), + it.effect.each([ + { relativePath: "unexpected.txt", kind: "file" }, + { relativePath: ".agents/skills/unexpected.txt", kind: "file" }, + { relativePath: ".gemini/unexpected", kind: "directory" }, + { relativePath: ".agents/skills", kind: "file" }, + ...(symlinksSupported ? [{ relativePath: ".gemini/skills", kind: "symlink" }] : []), + ])( + "rejects an unexpected $kind at $relativePath in its temporary workspace", + ({ relativePath, kind }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = + kind === "symlink" + ? yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-link-test-" }) + : undefined; + const fixture = yield* makeFixture({ + startupSkills: true, + prompt: ({ cwd, emit }) => + Effect.gen(function* () { + const entry = path.join(cwd, relativePath); + if (yield* fs.exists(entry).pipe(Effect.orDie)) { + yield* fs.remove(entry, { recursive: true }).pipe(Effect.orDie); + } + yield* ( + target !== undefined + ? fs.symlink(target, entry) + : kind === "directory" + ? fs.makeDirectory(entry) + : fs.writeFileString(entry, "unexpected") + ).pipe(Effect.orDie); + yield* emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: '{"title":"Ignored output"}' }, + }); + return { stopReason: "end_turn" }; + }), + }); + const error = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.flip); + expect(error.detail).toContain("wrote files"); + if (target !== undefined) expect(yield* fs.exists(target)).toBe(true); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), ); it.effect("uses the native default without sending T3's default selection as a model ID", () => @@ -576,3 +608,15 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); }); + +it.effect("retains filesystem services supplied only during construction", () => + Effect.gen(function* () { + const fixture = yield* makeFixture({ startupSkills: true }).pipe( + Effect.provide(NodeServices.layer), + ); + expect(yield* fixture.textGeneration.generateThreadTitle(fixture.titleInput)).toEqual({ + title: "Repair login", + }); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 921a2f118e34a..22fce172b1836 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -94,6 +94,34 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const hasOnlyRuntimeDirectories = Effect.fn( + "AntigravityTextGeneration.hasOnlyRuntimeDirectories", + )(function* (cwd: string) { + const root = yield* fs.realPath(cwd); + // ACP creates these empty skill directories before the first prompt. + const allowed = new Set([ + path.join(root, ".agents"), + path.join(root, ".agents", "skills"), + path.join(root, ".gemini"), + path.join(root, ".gemini", "skills"), + ]); + const directories = [root]; + for (const directory of directories) { + for (const name of yield* fs.readDirectory(directory)) { + const entry = path.join(directory, name); + if ( + !allowed.has(entry) || + (yield* fs.realPath(entry)) !== entry || + (yield* fs.stat(entry)).type !== "Directory" + ) { + return false; + } + directories.push(entry); + } + } + return true; + }); + const available = isAntigravityTextGenerationAvailable(options.profileDirectory).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -253,7 +281,7 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener ); }).pipe(Effect.scoped); - if ((yield* fs.readDirectory(cwd)).length > 0) { + if (!(yield* hasOnlyRuntimeDirectories(cwd))) { return yield* new TextGenerationError({ operation, detail: "Antigravity wrote files during text generation.", diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9600f0a45adfb..07a78dc6d50de 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1462,6 +1462,37 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.effect("ignores a stale macOS Keychain login when Cursor is not installed", () => + Effect.gen(function* () { + const { settings, home } = yield* setup; + const cursor = makeFakeCursor(); + cursor.state.error = "Sign in to Cursor again to read account usage."; + const readCursorSource = Effect.gen(function* () { + const service = yield* makeWithCursor(cursor.read); + const summary = yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); + return cursorSource(summary); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-cursor-keychain-uninstalled", + home, + settings: { ...settings, cursorKeychainUsageEnabled: true }, + platform: "darwin", + environment: {}, + }), + ), + ); + + assert.isUndefined(yield* readCursorSource); + assert.lengthOf(cursor.state.calls, 0); + + yield* Effect.promise(() => NodeFSP.mkdir(NodePath.join(home, ".cursor"))); + const installed = yield* readCursorSource; + assert.strictEqual(installed?.message, "Sign in to Cursor again to read account usage."); + assert.lengthOf(cursor.state.calls, 1); + }).pipe(Effect.scoped), + ); + it.live("ignores stale Cursor file logins when the active credential store differs", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/apps/server/src/usage/cliproxyApi.test.ts b/apps/server/src/usage/cliproxyApi.test.ts index 0119adfd940f3..4a76d32c15dea 100644 --- a/apps/server/src/usage/cliproxyApi.test.ts +++ b/apps/server/src/usage/cliproxyApi.test.ts @@ -345,6 +345,70 @@ describe("CLIProxyAPI built-in management API", () => { }), ); + it.effect.each([ + { + name: "spend budget", + budget: { + spend: { + enabled: true, + used: { amount_minor: 4_631, currency: "USD", exponent: 2 }, + limit: { amount_minor: 50_000, currency: "USD", exponent: 2 }, + }, + }, + }, + { + name: "extra usage", + budget: { + extra_usage: { + is_enabled: true, + used_credits: 4_631, + monthly_limit: 50_000, + utilization: 9, + }, + }, + }, + ])("keeps Claude monthly spend from $name beside rolling windows", ({ budget }) => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: () => ({ + status: 200, + body: { five_hour: { utilization: 10, resets_at: null }, ...budget }, + }), + }); + const result = yield* (yield* test.api).readAccounts(config); + expect( + result[0]?.usageLimits.windows.map((window) => [window.id, window.usedPercent]), + ).toEqual([ + ["five_hour", 10], + ["monthly_spend", expect.closeTo(9.262, 10)], + ]); + }), + ); + + it.effect("omits disabled Claude budgets without losing rolling windows", () => + Effect.gen(function* () { + const test = fixture({ + accounts: [{ ...accounts[0]!, provider: "claude" }], + upstream: () => ({ + status: 200, + body: { + five_hour: { utilization: 10, resets_at: null }, + spend: { enabled: false, used: null, limit: null }, + extra_usage: { + is_enabled: false, + used_credits: null, + monthly_limit: null, + utilization: null, + }, + }, + }), + }); + const result = yield* (yield* test.api).readAccounts(config); + expect(result[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["five_hour"]); + }), + ); + it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () => Effect.gen(function* () { const test = fixture(); diff --git a/apps/server/src/usage/cliproxyApi.ts b/apps/server/src/usage/cliproxyApi.ts index 91504d8002295..eb956a67a31d0 100644 --- a/apps/server/src/usage/cliproxyApi.ts +++ b/apps/server/src/usage/cliproxyApi.ts @@ -55,6 +55,17 @@ const ClaudeWindow = Schema.Struct({ const ClaudeUsage = Schema.Struct({ five_hour: Schema.optional(Schema.NullOr(ClaudeWindow)), seven_day: Schema.optional(Schema.NullOr(ClaudeWindow)), + spend: Schema.optional(Schema.Unknown), + extra_usage: Schema.optional( + Schema.NullOr( + Schema.Struct({ + is_enabled: Schema.Boolean, + monthly_limit: Schema.NullOr(Schema.Number), + used_credits: Schema.NullOr(Schema.Number), + utilization: Schema.NullOr(Schema.Number), + }), + ), + ), limits: Schema.optional( Schema.Array( Schema.Struct({ @@ -279,6 +290,13 @@ export const makeCliproxyApi = Effect.gen(function* () { ] : [], ); + const rateLimits = { + five_hour: usage.five_hour ?? null, + seven_day: usage.seven_day ?? null, + model_scoped, + spend: usage.spend, + extra_usage: usage.extra_usage ?? null, + }; return { ...base, plan: "Claude Subscription", @@ -286,11 +304,7 @@ export const makeCliproxyApi = Effect.gen(function* () { checkedAt, response: { rate_limits_available: true, - rate_limits: { - five_hour: usage.five_hour ?? null, - seven_day: usage.seven_day ?? null, - model_scoped, - }, + rate_limits: rateLimits, }, }).limits, }; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 8363e16c32476..d9a48457f8a38 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2577,6 +2577,39 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reads range patches from source without running textconv", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["config", "diff.probe.textconv", "touch textconv-ran; echo converted"]); + yield* git(cwd, ["checkout", "-b", "feature/range"]); + yield* writeTextFile(cwd, ".gitattributes", "*.doc diff=probe\n"); + yield* writeTextFile(cwd, "notes.doc", "source line\n"); + yield* writeTextFile(cwd, "plain.txt", "plain line\n"); + yield* writeTextFile(cwd, "binary.dat", "binary\0data"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "range files"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + + const context = yield* driver.readRangeContext(cwd, initialBranch); + + assert.include(context.diffPatch, "+source line"); + assert.notInclude(context.diffPatch, "converted"); + assert.include(context.diffPatch, "+plain line"); + assert.include(context.diffPatch, "Binary files /dev/null and b/binary.dat differ"); + assert.include(context.diffSummary, "notes.doc"); + assert.isFalse(yield* fileSystem.exists(`${cwd}/textconv-ran`)); + + yield* writeTextFile(cwd, "notes.doc", "updated source line\n"); + const staged = yield* driver.prepareCommitContext(cwd, ["notes.doc"]); + assert.include(staged?.stagedPatch ?? "", "+updated source line"); + assert.notInclude(staged?.stagedPatch ?? "", "converted"); + assert.include(staged?.stagedSummary ?? "", "notes.doc"); + assert.isFalse(yield* fileSystem.exists(`${cwd}/textconv-ran`)); + }), + ); + it.effect("loads new and deleted files without reading their missing side", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3662,6 +3695,47 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("flattens a generated name when a branch at its parent path blocks it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + // A local `t3` branch is why launches start on the flat `t3-` temporary branch. + yield* git(cwd, ["branch", "t3"]); + yield* git(cwd, ["checkout", "-b", "t3-abcd1234"]); + yield* git(cwd, ["branch", "t3-fix-login"]); + + const renamed = yield* driver.renameBranch({ + cwd, + oldBranch: "t3-abcd1234", + newBranch: "t3/fix-login", + }); + + assert.equal(renamed.branch, "t3-fix-login-1"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "t3-fix-login-1"); + }), + ); + + it.effect("skips a flattened name that a nested branch blocks", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "t3"]); + yield* git(cwd, ["checkout", "-b", "t3-abcd1234"]); + yield* git(cwd, ["branch", "t3-fix-login/child"]); + + const renamed = yield* driver.renameBranch({ + cwd, + oldBranch: "t3-abcd1234", + newBranch: "t3/fix-login", + }); + + assert.equal(renamed.branch, "t3-fix-login-1"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "t3-fix-login-1"); + }), + ); + it.effect("rejects a missing branch without restoring a matching dirty file", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4777,6 +4851,29 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("commit context", () => { + it.effect("prepares source patches without running textconv", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["config", "diff.probe.textconv", "touch textconv-ran; echo converted"]); + yield* writeTextFile(cwd, ".gitattributes", "*.doc diff=probe\n"); + yield* writeTextFile(cwd, "notes.doc", "original source\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "source document"]); + yield* writeTextFile(cwd, "notes.doc", "updated source\n"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + + const context = yield* driver.prepareCommitContext(cwd, ["notes.doc"]); + + assert.include(context?.stagedPatch ?? "", "-original source"); + assert.include(context?.stagedPatch ?? "", "+updated source"); + assert.notInclude(context?.stagedPatch ?? "", "converted"); + assert.include(context?.stagedSummary ?? "", "notes.doc"); + assert.isFalse(yield* fileSystem.exists(`${cwd}/textconv-ran`)); + }), + ); + it.effect("can omit the patch while preserving change detection and the real index", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 71945b0b6f71c..11580f4c26e18 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1247,16 +1247,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( cwd: string, - desiredBranch: string, + requestedBranch: string, ) { - const isDesiredTaken = yield* branchExists(cwd, desiredBranch); + // A branch at a parent path (such as `t3`) blocks every ref nested under it, so no suffix + // would help; flatten to a sibling the way temporary worktree branches already do. + const segments = requestedBranch.split("/"); + let blockedByParent = false; + for (let index = 1; index < segments.length && !blockedByParent; index += 1) { + blockedByParent = yield* branchExists(cwd, segments.slice(0, index).join("/")); + } + const desiredBranch = blockedByParent ? segments.join("-") : requestedBranch; + // A branch nested under the name (`t3-fix/child`) blocks it as surely as one with the name. + const isBranchNameTaken = (name: string) => + Effect.zipWith( + branchExists(cwd, name), + runGitStdout( + "GitVcsDriver.branchExists", + cwd, + ["for-each-ref", "--count=1", "--format=%(refname)", `refs/heads/${name}/`], + true, + ), + (exists, nested) => exists || nested.trim().length > 0, + ); + + const isDesiredTaken = yield* isBranchNameTaken(desiredBranch); if (!isDesiredTaken) { return desiredBranch; } for (let suffix = 1; suffix <= 100; suffix += 1) { const candidate = `${desiredBranch}-${suffix}`; - const isCandidateTaken = yield* branchExists(cwd, candidate); + const isCandidateTaken = yield* isBranchNameTaken(candidate); if (!isCandidateTaken) { return candidate; } @@ -2502,7 +2523,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const stagedPatch = yield* runGitStdoutWithOptions( "GitVcsDriver.prepareCommitContext.stagedPatch", cwd, - ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], + ["diff", "--no-ext-diff", "--no-textconv", "--cached", "--patch", "--minimal"], { env, maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, @@ -2913,7 +2934,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffPatch", cwd, - ["diff", "--no-ext-diff", "--patch", "--minimal", "--end-of-options", diffRange], + [ + "diff", + "--no-ext-diff", + "--no-textconv", + "--patch", + "--minimal", + "--end-of-options", + diffRange, + ], { maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 6bef5f04263c6..d861935fe17cc 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { FileFinder } from "@ff-labs/fff-node"; import { it, afterEach, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -673,6 +674,53 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(match.lineContent.slice(range.start, range.end)).toBe("wörld"); }), ); + + it.effect("keeps answering from the live index when a refresh outlasts the scan wait", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-refresh-" }); + yield* writeTextFile(cwd, "src/notes.ts", "const needle = 1;\n"); + let scansOutlastWait = false; + let finders = 0; + let rescans = 0; + const createFinder = FileFinder.create.bind(FileFinder); + vi.spyOn(FileFinder, "create").mockImplementation((options) => { + const created = createFinder(options); + if (created.ok) { + const finder = created.value; + finders += 1; + const scanFiles = finder.scanFiles.bind(finder); + finder.scanFiles = () => { + rescans += 1; + return scanFiles(); + }; + const waitForIndexReady = finder.waitForIndexReady.bind(finder); + finder.waitForIndexReady = (timeoutMs) => + scansOutlastWait + ? Promise.resolve({ ok: true, value: false }) + : waitForIndexReady(timeoutMs); + } + return created; + }); + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searchNeedle = workspaceEntries.searchContents({ + cwd, + query: "needle", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + yield* searchNeedle; + + scansOutlastWait = true; + yield* workspaceEntries.refresh(cwd); + const result = yield* searchNeedle; + + expect(result.matches.map((match) => match.path)).toEqual(["src/notes.ts"]); + // The second search ran on the rescanned live finder, not a rebuild. + expect({ finders, rescans }).toEqual({ finders: 1, rescans: 1 }); + }), + ); }); describe("browse", () => { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8f2b64cf26a34..d5539e2f4e718 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -117,10 +117,7 @@ export class WorkspaceSearchIndex extends Context.Service< readonly searchContents: ( input: Omit, ) => Effect.Effect; - readonly refresh: () => Effect.Effect< - void, - WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut - >; + readonly refresh: () => Effect.Effect; } >()("t3/workspace/WorkspaceSearchIndex") {} @@ -419,6 +416,8 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( reason: result.error, }); } + // A rescan that outlasts the wait keeps running, and the finder answers + // from its previous index until it finishes, so the index stays usable. yield* waitForIndexReady( cwd, finder, @@ -428,7 +427,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( reason, cause, }), - ); + ).pipe(Effect.catchTags({ WorkspaceSearchIndexScanTimedOut: () => Effect.void })); }); const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index dfd365ae7eeaa..8483720c9c403 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -124,6 +124,7 @@ import * as ProjectEnrichment from "./project/ProjectEnrichmentService.ts"; import * as ProjectService from "./project/ProjectService.ts"; import { layerWsRpc, + resolveOpenDiscoveryForConfig, subscribeOrchestrationV2Shell, subscribeOrchestrationV2Thread, WS_RPC_SERVER_OPTIONS, @@ -189,6 +190,38 @@ it.effect("resends a file manager reveal kind that missed the snapshot", () => }), ); +it.effect("runs editor, remote open target and direct endpoint discovery side by side", () => + Effect.gen(function* () { + // Each discovery finishes only once the other two have started, so a + // config that ran them one after another would never resolve. + const editorsStarted = yield* Deferred.make(); + const targetsStarted = yield* Deferred.make(); + const endpointsStarted = yield* Deferred.make(); + const startThenAwait = (started: Deferred.Deferred, value: A) => + Deferred.succeed(started, undefined).pipe( + Effect.andThen( + Effect.forEach([editorsStarted, targetsStarted, endpointsStarted], Deferred.await), + ), + Effect.as(value), + ); + + const discovery = yield* resolveOpenDiscoveryForConfig({ + editors: startThenAwait(editorsStarted, ["file-manager" as const]), + fileManagerRevealKind: Effect.succeed("finder" as const), + remoteOpenTargets: startThenAwait(targetsStarted, []), + directEndpoints: startThenAwait(endpointsStarted, []), + }); + + assert.deepEqual(discovery, { + availableEditors: ["file-manager"], + shellRevealInFileManager: true, + shellRevealInFileManagerKind: "finder", + remoteOpenTargets: [], + directEndpoints: [], + }); + }), +); + const ReplayTestLayer = OrchestrationEventStore.layer.pipe( Layer.provideMerge(SqlitePersistence.layerMemory), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1476ce14ae1b5..621e898b99bc9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -325,7 +325,8 @@ export const withLateEditorConfig = ( editors.join() !== config.availableEditors.join() || (editors.includes("file-manager") && config.shellRevealInFileManagerKind === undefined), ), - // Leave the unshared reveal probe unbounded so timeouts cannot cancel every attempt on slow hosts. + // Unbounded, unlike the snapshot: this exists to deliver a probe that + // outlasts the snapshot's timeout. Stream.mapEffect((editors) => resolveEditorConfig(editors, launcher.resolveFileManagerRevealKind()), ), @@ -367,6 +368,31 @@ export const withLateEditorConfig = ( ); }; +// Discoveries run side by side so the slowest one, not their sum, bounds the +// server config snapshot. Each degrades on timeout rather than failing it. +export const resolveOpenDiscoveryForConfig = (discovery: { + readonly editors: Effect.Effect, E1, R1>; + readonly fileManagerRevealKind: Effect.Effect; + readonly remoteOpenTargets: Effect.Effect, E3, R3>; + readonly directEndpoints: Effect.Effect, E4, R4>; +}) => + Effect.all( + { + editorConfig: resolveAvailableEditorsForConfig(discovery.editors).pipe( + Effect.flatMap((availableEditors) => + resolveEditorConfig( + availableEditors, + resolveFileManagerRevealKindForConfig(discovery.fileManagerRevealKind), + ), + ), + ), + // Same discovery-with-timeout treatment as editors: a slow probe must + // not stall server.getConfig, so it degrades to an empty list. + remoteOpenTargets: resolveAvailableEditorsForConfig(discovery.remoteOpenTargets), + directEndpoints: resolveAvailableEditorsForConfig(discovery.directEndpoints), + }, + { concurrency: "unbounded" }, + ).pipe(Effect.map(({ editorConfig, ...endpoints }) => ({ ...editorConfig, ...endpoints }))); function withoutWorkspaceSnapshots(providers: ReadonlyArray): ServerProvider[] { return providers.map(({ workspaceSnapshots: _workspaceSnapshots, ...provider }) => provider); } @@ -1745,10 +1771,12 @@ export const layerWsRpc = ( const auth = yield* serverAuth.getDescriptor(); const scratchWorkspaceRoot = yield* managedFolders.scratchRoot; const lastEventLoopStall = yield* eventLoopMonitor.latestNoticeableStall; - const editorConfig = yield* resolveEditorConfig( - yield* resolveAvailableEditorsForConfig(externalLauncher.resolveAvailableEditors()), - resolveFileManagerRevealKindForConfig(externalLauncher.resolveFileManagerRevealKind()), - ); + const discovery = yield* resolveOpenDiscoveryForConfig({ + editors: externalLauncher.resolveAvailableEditors(), + fileManagerRevealKind: externalLauncher.resolveFileManagerRevealKind(), + remoteOpenTargets: remoteOpenTargets.resolveTargets(), + directEndpoints: directEndpoints.resolve(), + }); return { environment, @@ -1758,11 +1786,7 @@ export const layerWsRpc = ( keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, providers, - ...editorConfig, - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - directEndpoints: yield* resolveAvailableEditorsForConfig(directEndpoints.resolve()), + ...discovery, observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/apps/web/package.json b/apps/web/package.json index a73c33ce03633..4cb028c22f3bf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,12 +43,14 @@ "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", "@tiptap/extension-blockquote": "^3.31.3", + "@tiptap/extension-bold": "^3.31.3", "@tiptap/extension-code": "^3.31.3", "@tiptap/extension-code-block": "^3.31.3", "@tiptap/extension-heading": "^3.31.3", "@tiptap/extension-horizontal-rule": "^3.31.3", "@tiptap/extension-italic": "^3.31.3", "@tiptap/extension-list": "^3.31.3", + "@tiptap/extension-strike": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 95848c209d8c5..8f6ffdf0a119e 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -726,11 +726,7 @@ export function BranchToolbarBranchSelector({ )} /> - + diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 41bd2b008be89..ca511de43642c 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -73,7 +73,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked || forceNewWorktree) { const lockedRow = ( {activeWorktreePath ? ( diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 4dfe1ae50f024..66232885fb280 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -68,7 +68,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir if (envLocked || (onEnvironmentChange === undefined && onAddToMachine === undefined)) { const lockedRow = ( expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })} + onExpand={(src) => + expandMedia({ + images: [{ src, name: "Mermaid diagram", mimeType: "image/svg+xml" }], + index: 0, + }) + } > {highlightedCode} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 14125c684045d..e7781653f8e99 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -193,6 +193,40 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(false); }); + it.each([ + ["queued", "message-queued", true], + ["queued", "message-before-send", false], + ["preparing", "message-queued", false], + ["starting", "message-queued", false], + ] as const)("acknowledges %s with latest message %s: %s", (status, messageId, acknowledged) => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestRun: completedTurn, runtime: readySession }), + { latestUserMessageId: MessageId.make("message-before-send") }, + ); + // The shell hides the silent compaction run, so its runtime reports the queued message's run. + const queuedRun = { + ...completedTurn, + runId: RunId.make("turn-queued"), + status: "queued" as const, + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: null, + completedAt: null, + }; + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestRun: queuedRun, + latestUserMessageId: MessageId.make(messageId), + runtime: { ...readySession, status }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(acknowledged); + }); + it("waits for the matching running turn before acknowledging", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestRun: completedTurn, runtime: readySession }), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 49fe3a441fbb8..c289976d3b039 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1101,14 +1101,15 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } + const latestUserMessageChanged = + input.localDispatch.latestUserMessageId !== (input.latestUserMessageId ?? null); if (input.phase === "connecting" || derivePhase(input.runtime ?? null) === "connecting") { - return false; + // A message queued behind other work, such as an idle compaction the shell hides, has landed. + return input.runtime?.status === "queued" && latestUserMessageChanged; } const latestRun = input.latestRun ?? null; const runtime = input.runtime ?? null; - const latestUserMessageChanged = - input.localDispatch.latestUserMessageId !== (input.latestUserMessageId ?? null); const latestRunChanged = input.localDispatch.latestRunId !== (latestRun?.runId ?? null) || input.localDispatch.latestRunRequestedAt !== (latestRun?.requestedAt ?? null) || diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e45b4272aaae6..020963f23310e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -43,10 +43,6 @@ import { RightPanelSurfaceContent } from "./chat/RightPanelSurfaceContent"; import { usePendingThreadRequests } from "./chat/usePendingThreadRequests"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; -import { - latestExecutedRun, - latestRootProviderFailure, -} from "@t3tools/shared/orchestrationV2ThreadError"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; import { feedbackBannerItem } from "./chat/ComposerFeedback"; import { useUsageLimitsPanel } from "./chat/useUsageLimitsPanel"; @@ -78,6 +74,7 @@ import { import { deriveProviderSubagentStatus, deriveReportedModelSelection, + deriveResumableRunId, formatModelSelectionEffort, deriveRunlessWorkStartedAt, deriveThreadActivityRun, @@ -816,16 +813,10 @@ export default function ChatView(props: ChatViewProps) { isServerThread && serverProjection?.runs.some((run) => run.status === "queued" && run.queueHeld === true) === true; - const resumableRunId = (() => { - if (!isServerThread || serverProjection === null) return null; - const run = latestExecutedRun(serverProjection.runs); - if (run?.status === "interrupted") return run.id; - return run?.status === "failed" && - serverRuntime?.lastErrorClass === "usage_limit" && - latestRootProviderFailure(run, serverProjection.turnItems)?.class === "usage_limit" - ? run.id - : null; - })(); + const resumableRunId = + !isServerThread || serverProjection === null + ? null + : deriveResumableRunId(serverProjection, serverRuntime); const parentSubagentThreadId = activeThread?.lineage.relationshipToParent === "subagent" ? activeThread.lineage.parentThreadId @@ -3187,6 +3178,11 @@ export default function ChatView(props: ChatViewProps) { (folders: Parameters[0]) => composerRef.current?.addDroppedFolders(folders), ); + // Above the early return, where React Compiler can cache it for the side chat pane. + const sideChatParentPanel = { + openToolDiff: onOpenToolDiffStable, + runShellCommand: runSideChatShellCommandStable, + }; if (!activeThread) { return embedded ? null : ; @@ -3206,10 +3202,7 @@ export default function ChatView(props: ChatViewProps) { getShortcutContext={getShortcutContextStable} onSendAnnotation={sendPreviewAnnotationStable} onCloseSurface={closeRightPanelSurface} - sideChatParentPanel={{ - openToolDiff: onOpenToolDiffStable, - runShellCommand: runSideChatShellCommandStable, - }} + sideChatParentPanel={sideChatParentPanel} terminal={{ launchContext: activeTerminalLaunchContext ?? null, keybindings, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 377ceecbd94bc..6eddefe7d7d54 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -32,6 +32,7 @@ import { createBrowseNavigationCoordinator, filterFilesystemBrowseEntries, getFilesystemBrowsePath, + getFilesystemBrowseTabCompletion, } from "@t3tools/client-runtime/state/filesystem"; import { isAtomCommandInterrupted, @@ -2518,6 +2519,30 @@ function OpenCommandPaletteDialog(props: { } if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) return; + + if (event.key === "Tab" && isBrowsing && !relativePathNeedsActiveProject) { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + if (highlightedItemValue === "browse:up") { + void browseUp(); + return; + } + const highlightedEntry = visibleBrowseEntries.find( + (entry) => `browse:${entry.fullPath}` === highlightedItemValue, + ); + const completion = highlightedEntry + ? { name: highlightedEntry.name, isComplete: true } + : getFilesystemBrowseTabCompletion(visibleBrowseEntries, browsePath.filterQuery); + if (completion?.isComplete) { + void browseTo(completion.name); + } else if (completion && !pinnedCloneDirectoryName) { + setHighlightedItemValue(null); + setQuery(`${getBrowseDirectoryPath(query)}${completion.name}`); + } + return; + } + const firstItemValue = autoHighlightsFirstRow ? resultRows.itemValues[0] : undefined; if ( event.key === "ArrowDown" && diff --git a/apps/web/src/components/ComposerContextLabel.tsx b/apps/web/src/components/ComposerContextLabel.tsx index 30567ac355f8a..89b54d5419821 100644 --- a/apps/web/src/components/ComposerContextLabel.tsx +++ b/apps/web/src/components/ComposerContextLabel.tsx @@ -24,7 +24,7 @@ export function ComposerContextLabel({ data-composer-label-motion className={cn( "block w-full min-w-0 truncate", - displayMode === "panel" && THREAD_DETAILS_PANEL_LABEL_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, displayMode === "toolbar" && "max-w-[240px] transition-opacity duration-180 ease-drawer group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none", )} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index abc9549ee3d00..fe78eaacbe850 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -73,7 +73,9 @@ import { ComposerBoldSelectionOnlyExtension, convertBulletItemToTask, ComposerCodeExtension, + ComposerBoldExtension, ComposerItalicExtension, + ComposerStrikeExtension, ComposerTaskItemExtension, ComposerTaskListExtension, flatToMarkdown, @@ -831,7 +833,8 @@ function createComposerExtensions(richText: boolean) { listKeymap: false, undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY }, italic: false, - ...(richText ? {} : { bold: false, strike: false }), + bold: false, + strike: false, }), ComposerUndoGroupingExtension, ComposerTextDirectionExtension, @@ -844,6 +847,8 @@ function createComposerExtensions(richText: boolean) { ? [ ComposerBoldSelectionOnlyExtension, ComposerItalicExtension, + ComposerBoldExtension, + ComposerStrikeExtension, ComposerCodeExtension, ComposerCodeBlockExtension.extend({ addNodeView() { diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 43c466184bf2c..91d4a54fd9e64 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -124,7 +124,7 @@ function CustomSnoozeDialog() {
{mode === "date" ? (
-
+
= Atom.Atom>; + +const state = vi.hoisted(() => ({ + thread: null as ReturnType | null, + threadRef: null as ScopedThreadRef | null, + previews: new Map>(), + config: null as unknown as Atom.Atom, + status: null as unknown as ResultAtom, + localRefs: null as unknown as ResultAtom, + remoteRefs: null as unknown as ResultAtom, + commits: null as unknown as ResultAtom, +})); + +vi.mock("~/state/server", () => ({ + serverEnvironment: { configValueAtom: () => state.config }, +})); +vi.mock("~/state/review", () => ({ + reviewEnvironment: { + diffPreview: ({ input }: { input: { baseRef?: string } }) => + state.previews.get(input.baseRef ?? null)!, + branchCommits: () => state.commits, + diffFileContents: {}, + }, +})); +vi.mock("~/state/vcs", () => ({ + vcsEnvironment: { + status: () => state.status, + listRefs: ({ input }: { input: { refKind: "local" | "remote" } }) => + input.refKind === "local" ? state.localRefs : state.remoteRefs, + }, +})); +vi.mock("~/state/entities", () => ({ + useQuietThreadShell: () => state.thread, + useProject: () => ({ workspaceRoot: "/project", repositoryIdentity: null }), + useThreadCheckpointSummaries: () => [], + useQuietThreadVisibleTurnItems: () => [], +})); +vi.mock("~/state/filesystem", () => ({ + useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), +})); +vi.mock("~/hooks/useThreadRouteTarget", () => ({ useThreadRouteRef: () => state.threadRef })); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (select: (settings: ClientSettings) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), + updateClientSettings: async () => {}, +})); +vi.mock("~/hooks/useTheme", () => ({ useRenderedAppearance: () => "dark" })); +vi.mock("~/state/queries", () => ({ + useCheckpointDiff: () => ({ data: null, error: null, isPending: false }), + useTurnItemDetail: () => ({ data: null, error: null, isPending: false }), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => {} })); +vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => async () => {} })); +vi.mock("~/fileContextMenu", () => ({ useFileContextMenuHandler: () => () => {} })); +vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => async () => {} })); +// The test drives comparison controls, without mounting virtualized diff viewers or their workers. +vi.mock("./diffs/AnnotatableCodeView", () => ({ AnnotatableCodeView: () => null })); +vi.mock("./diffs/DiffFileTree", () => ({ DiffFileTree: () => null })); +vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null })); + +import DiffPanel from "./DiffPanel"; +import { useDiffPanelSessionStore, useDiffPanelStore } from "~/diffPanelStore"; + +const cwd = "/project-worktree"; +const errorMessage = "Could not find a common commit between 'origin/orphan' and HEAD."; + +function preview(): ReviewDiffPreviewResult { + return { + cwd, + generatedAt: DateTime.makeUnsafe("2026-10-30T00:00:00Z"), + sources: [ + { + id: "branch", + kind: "branch-range", + title: "Changes", + baseRef: "main", + headRef: "dev", + diff: "", + diffHash: "empty", + truncated: false, + }, + ], + }; +} + +function setup(failedBaseRef: string | null) { + const reset = () => { + useDiffPanelStore.setState({ + byThreadKey: {}, + branchBaseRefByThreadKey: {}, + keepSelectionOnOpenThreadKey: null, + }); + useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} }); + }; + reset(); + state.threadRef = { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + }; + state.thread = makeThreadFixture({ + ...state.threadRef, + id: state.threadRef.threadId, + projectId: ProjectId.make("project"), + branch: "dev", + worktreePath: cwd, + }); + state.previews.clear(); + state.previews.set(null, Atom.make(AsyncResult.success(preview()))); + state.previews.set("main", Atom.make(AsyncResult.success(preview()))); + state.previews.set(failedBaseRef, Atom.make(AsyncResult.fail(new Error(errorMessage)))); + state.config = Atom.make(null); + state.status = Atom.make( + AsyncResult.success({ + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "dev", + hasWorkingTreeChanges: false, + workingTree: { rootPath: cwd, files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ); + const refs = (names: string[], isRemote: boolean): VcsListRefsResult => ({ + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: names.length, + refs: names.map((name) => ({ + name, + isRemote, + ...(isRemote ? { remoteName: "origin" } : {}), + current: name === "dev", + isDefault: name === "main", + worktreePath: null, + })), + }); + state.localRefs = Atom.make(AsyncResult.success(refs(["dev", "main"], false))); + state.remoteRefs = Atom.make(AsyncResult.success(refs(["origin/orphan"], true))); + state.commits = Atom.make(AsyncResult.success({ commits: [], truncated: false })); + useDiffPanelStore.getState().selectBranchBaseRef(state.threadRef, failedBaseRef); + const registry = AtomRegistry.make(); + return { + registry, + threadRef: state.threadRef, + [Symbol.dispose]() { + registry.dispose(); + reset(); + }, + }; +} + +it.each([ + { + name: "a saved unrelated branch", + failedBaseRef: "origin/orphan", + recovery: "Automatic", + baseRef: null, + }, + { + name: "an automatic base with unrelated history", + failedBaseRef: null, + recovery: "main", + baseRef: "main", + }, +])( + "recovers from $name without leaving the diff panel", + async ({ failedBaseRef, recovery, baseRef }) => { + using context = setup(failedBaseRef); + await using screen = await render( + + + , + ); + await expect.element(screen.getByText(errorMessage, { exact: true })).toBeVisible(); + const selector = screen.getByRole("combobox", { name: /Change comparison target/ }); + await vi.waitFor(() => expect(selector.query()).not.toBeNull()); + await userEvent.click(selector); + await expect.element(page.getByRole("option", { name: "main", exact: true })).toBeVisible(); + await userEvent.click(page.getByRole("option", { name: recovery, exact: true })); + await expect.element(screen.getByText("No net changes in this selection.")).toBeVisible(); + await expect.element(screen.getByText(errorMessage, { exact: true })).not.toBeInTheDocument(); + const key = scopedThreadKey(context.threadRef); + await vi.waitFor(() => { + expect(useDiffPanelStore.getState().byThreadKey[key]).toEqual({ kind: "branch", baseRef }); + expect(useDiffPanelStore.getState().branchBaseRefByThreadKey[key]).toBe(baseRef); + }); + }, +); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ef95c24d578a2..0f1d1e9e28379 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -67,6 +67,7 @@ import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./Dif import { DiffStatLabel } from "./chat/DiffStatLabel"; import { AnnotatableCodeView } from "./diffs/AnnotatableCodeView"; import { DiffFileTree } from "./diffs/DiffFileTree"; +import { FileExplorerPane } from "./files/FileExplorerPane"; import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; import { Button } from "./ui/button"; import { MorphIcon } from "~/components/MorphIcon"; @@ -403,6 +404,8 @@ export default function DiffPanel({ ? "commit" : "branch-range"), ); + const comparisonHeadRef = selectedGitSource?.headRef ?? gitStatusQuery.data?.refName ?? "HEAD"; + const comparisonBaseRef = selectedGitSource?.baseRef ?? selectedBaseRef ?? "Automatic"; const selectedCommitSubject = selectedCommitSha !== null && selectedGitSource?.headRef === selectedCommitSha ? selectedGitSource.commitSubject @@ -518,12 +521,14 @@ export default function DiffPanel({ } return currentLoadDiffFiles(fileDiff); }); + const canSelectBranchBase = canReadFiles && canRefreshGitDiff && selectedGitScope === "branch"; + const branchRefsCwd = branchDiffPreview.data?.cwd ?? activeCwd; const localBranchRefs = useEnvironmentQuery( - isGitSelection && selectedGitScope === "branch" && activeThread && branchDiffPreview.data?.cwd + canSelectBranchBase && activeThread && branchRefsCwd ? vcsEnvironment.listRefs({ environmentId: activeThread.environmentId, input: { - cwd: branchDiffPreview.data.cwd, + cwd: branchRefsCwd, includeMatchingRemoteRefs: true, refKind: "local", ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}), @@ -533,11 +538,11 @@ export default function DiffPanel({ : null, ); const remoteBranchRefs = useEnvironmentQuery( - isGitSelection && selectedGitScope === "branch" && activeThread && branchDiffPreview.data?.cwd + canSelectBranchBase && activeThread && branchRefsCwd ? vcsEnvironment.listRefs({ environmentId: activeThread.environmentId, input: { - cwd: branchDiffPreview.data.cwd, + cwd: branchRefsCwd, includeMatchingRemoteRefs: true, refKind: "remote", ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}), @@ -547,7 +552,7 @@ export default function DiffPanel({ : null, ); const baseRefChoices = buildBaseRefChoices( - localBranchRefs.data?.refs.filter((ref) => ref.name !== selectedGitSource?.headRef) ?? [], + localBranchRefs.data?.refs.filter((ref) => ref.name !== comparisonHeadRef) ?? [], remoteBranchRefs.data?.refs ?? [], ); const matchingBaseRefChoices = filterBaseRefChoices(baseRefChoices, baseRefQuery); @@ -949,10 +954,10 @@ export default function DiffPanel({ - {isGitSelection && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( + {canSelectBranchBase && (
} > - - {selectedGitSource.headRef ?? "HEAD"} - + {comparisonHeadRef} - {`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`} + {`${comparisonHeadRef} → ${comparisonBaseRef}`} @@ -978,7 +981,7 @@ export default function DiffPanel({ vs - {`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`} + {`${comparisonHeadRef} → ${comparisonBaseRef}`} } className="min-w-9 max-w-48 shrink-0" - aria-label={`Change comparison target. Currently ${selectedGitSource.baseRef}`} + aria-label={`Change comparison target. Currently ${comparisonBaseRef}`} > - {selectedGitSource.baseRef} + {comparisonBaseRef}
{fileTreeOpen ? ( - + ) : null}
) : ( diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx new file mode 100644 index 0000000000000..b2e6fcaaaf402 --- /dev/null +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; + +const navigateHistory = vi.hoisted(() => vi.fn()); +vi.mock("../navigationHistory", () => ({ canNavigateHistory: () => true, navigateHistory })); + +function swipe(target: Element, deltaX: number) { + for (const delta of [deltaX, 0]) { + target.dispatchEvent(new WheelEvent("wheel", { deltaX: delta, bubbles: true, composed: true })); + } +} + +describe("HistorySwipeNavigation", () => { + it.each([ + ["scrolling left inside a shadow-root scroller keeps the thread", "shadow", 100, -130, false], + ["scrolling right inside a shadow-root scroller keeps the thread", "shadow", 100, 130, false], + [ + "scrolling after a shadow scroller remount keeps the thread", + "shadow-remount", + 100, + 130, + false, + ], + ["scrolling left inside a scroller keeps the thread", "light", 100, -130, false], + ["swiping back at a shadow-root scroller's left edge navigates", "shadow", 0, -130, true], + ["swiping back over a panel without overflow navigates", "none", 0, -130, true], + ["a swipe that started in a closed dialog keeps the thread", "closed-dialog", 0, -130, false], + ] as const)("%s", async (_name, scroller, scrollLeft, deltaX, navigates) => { + navigateHistory.mockReset(); + await using screen = await render(); + const panel = screen.container.appendChild(document.createElement("div")); + const root = scroller.startsWith("shadow") ? panel.attachShadow({ mode: "open" }) : panel; + const viewport = root.appendChild(document.createElement("div")); + const noOverflow = scroller === "none" || scroller === "closed-dialog"; + viewport.style.cssText = `width: 200px; overflow-x: ${noOverflow ? "visible" : "auto"}`; + const content = viewport.appendChild(document.createElement("div")); + content.style.cssText = `height: 20px; width: ${noOverflow ? 100 : 1000}px`; + viewport.scrollLeft = scrollLeft; + + if (scroller === "closed-dialog") { + panel.setAttribute("role", "dialog"); + content.dispatchEvent(new WheelEvent("wheel", { deltaX: -1, bubbles: true, composed: true })); + panel.remove(); + screen.container.appendChild(viewport); + } + + if (scroller === "shadow-remount") { + content.dispatchEvent(new WheelEvent("wheel", { deltaX: 1, bubbles: true, composed: true })); + const replacement = viewport.cloneNode(true) as HTMLDivElement; + viewport.replaceWith(replacement); + replacement.scrollLeft = scrollLeft; + swipe(replacement, deltaX); + } else { + swipe(content, deltaX); + } + + expect(navigateHistory).toHaveBeenCalledTimes(navigates ? 1 : 0); + }); +}); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index c020878b25363..767b4285c696f 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -11,7 +11,7 @@ const MOMENTUM_SAMPLE_SIZE = 5; type SwipeBounds = { left: number; top: number; width: number; height: number }; type SwipeGesture = - | { kind: "pending"; target: EventTarget | null; y: number; sumX: number; sumY: number } + | { kind: "pending"; path: EventTarget[]; y: number; sumX: number; sumY: number } | { kind: "tracking"; direction: HistoryDirection; @@ -37,13 +37,10 @@ function readMainContentBounds(): SwipeBounds { return { left, top, width, height }; } -function canScrollHorizontally(target: EventTarget | null, deltaX: number): boolean { - for ( - let element = target instanceof Element ? target : null; - element; - element = element.parentElement - ) { - if (element.scrollWidth <= element.clientWidth) continue; +// `event.target` stops at a shadow host; the composed path also reaches scrollers inside it. +function canScrollHorizontally(path: readonly EventTarget[], deltaX: number): boolean { + for (const element of path) { + if (!(element instanceof Element) || element.scrollWidth <= element.clientWidth) continue; const { overflowX } = getComputedStyle(element); if (overflowX !== "auto" && overflowX !== "scroll") continue; const canScroll = @@ -58,10 +55,12 @@ function canScrollHorizontally(target: EventTarget | null, deltaX: number): bool function lockSwipeAxis( pending: Extract, ): Exclude { - const { target, sumX, sumY } = pending; + const { path, sumX, sumY } = pending; if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" }; - if (target instanceof Element && target.closest('[role="dialog"]')) return { kind: "ignored" }; - if (canScrollHorizontally(target, sumX)) return { kind: "ignored" }; + if (path.some((node) => node instanceof Element && node.matches('[role="dialog"]'))) { + return { kind: "ignored" }; + } + if (canScrollHorizontally(path, sumX)) return { kind: "ignored" }; const direction = sumX < 0 ? "back" : "forward"; if (!canNavigateHistory(direction)) return { kind: "ignored" }; return { @@ -119,14 +118,14 @@ export function HistorySwipeNavigation() { gesture = event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL ? { kind: "ignored" } - : { kind: "pending", target: event.target, y: event.clientY, sumX: 0, sumY: 0 }; + : { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 }; } if (gesture.kind === "ignored") return; if (gesture.kind === "pending") { gesture.sumX += event.deltaX; gesture.sumY += event.deltaY; if (Math.abs(gesture.sumX) + Math.abs(gesture.sumY) < SWIPE_AXIS_LOCK_DISTANCE) return; - gesture = lockSwipeAxis(gesture); + gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...event.composedPath()] }); if (gesture.kind === "ignored") return; } else { const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; diff --git a/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx new file mode 100644 index 0000000000000..d6ca540686f0e --- /dev/null +++ b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx @@ -0,0 +1,153 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + AuthProvidersManageScope, + type AuthSessionState, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { useEffect } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; + +const state = vi.hoisted(() => ({ + environments: [] as { + environmentId: EnvironmentId; + label: string; + connection: { phase: "connected" | "disconnected" }; + }[], + providers: new Map>(), + requests: [] as EnvironmentId[], + sessions: new Map>>(), +})); + +vi.mock(import("~/state/environments"), () => ({ + useEnvironments: (() => ({ environments: state.environments })) as never, + useEnvironmentProviders: () => state.providers, +})); +vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => { + const actual = await original(); + return { + ...actual, + createEnvironmentSessionAtoms: (( + ...args: Parameters + ) => ({ + ...actual.createEnvironmentSessionAtoms(...args), + sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, + })) as typeof actual.createEnvironmentSessionAtoms, + }; +}); +vi.mock(import("~/state/server"), () => ({ + serverEnvironment: { updateProvider: {} } as never, +})); +vi.mock(import("~/state/use-atom-command"), () => ({ + useAtomCommand: (() => + async ({ environmentId }: { environmentId: EnvironmentId }) => { + state.requests.push(environmentId); + return AsyncResult.success(undefined); + }) as never, +})); + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { useProviderUpdates } from "./ProviderUpdatesAction"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-21T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateCommand: "npm install -g @openai/codex", + canUpdate: true, + checkedAt: "2026-10-21T00:00:00.000Z", + message: "Update available.", + }, +}; + +function session( + scopes: NonNullable, +): AsyncResult.AsyncResult { + return AsyncResult.success({ + authenticated: true, + scopes, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + sessionCookieName: "t3_session", + }, + }); +} + +function setup() { + state.environments = [ + { environmentId: EnvironmentId.make("read-only"), label: "Read only" }, + { environmentId: EnvironmentId.make("writable"), label: "Writable" }, + { environmentId: EnvironmentId.make("unresolved"), label: "Unresolved" }, + ].map((environment) => ({ ...environment, connection: { phase: "connected" } })); + state.providers = new Map( + state.environments.map(({ environmentId }) => [environmentId, [provider]]), + ); + state.requests = []; + const sessions = new Map>>( + [ + [state.environments[0]!.environmentId, Atom.make(session(["orchestration:read"]))], + [state.environments[1]!.environmentId, Atom.make(session([AuthProvidersManageScope]))], + [ + state.environments[2]!.environmentId, + Atom.make>( + AsyncResult.initial(), + ), + ], + ], + ); + state.sessions = sessions; + return { sessions }; +} + +it("bulk provider updates exclude connections without a confirmed management grant", async () => { + const fixture = setup(); + let updates!: ReturnType; + function Probe() { + const nextUpdates = useProviderUpdates(); + useEffect(() => { + updates = nextUpdates; + }, [nextUpdates]); + return null; + } + await using _screen = await render( + + + , + ); + + const writableId = state.environments[1]!.environmentId; + expect(updates.machines.map(({ environmentId }) => environmentId)).toEqual([writableId]); + await act(async () => { + await updates.updateAll({ quiet: true }); + }); + expect(state.requests).toEqual([writableId]); + + await act(() => { + appAtomRegistry.set(fixture.sessions.get(writableId)!, session(["orchestration:read"])); + }); + expect(updates.machines).toEqual([]); + state.requests = []; + await act(async () => { + await updates.updateAll({ quiet: true }); + }); + expect(state.requests).toEqual([]); +}); diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx index 8d45d66b71213..57573806e45db 100644 --- a/apps/web/src/components/ProviderUpdatesAction.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.tsx @@ -1,9 +1,10 @@ -import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; +import { AuthProvidersManageScope, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { useRef, useState } from "react"; import { useEnvironmentProviders, useEnvironments } from "~/state/environments"; import { serverEnvironment } from "~/state/server"; +import { useEnvironmentsWithScope } from "~/state/session"; import { useAtomCommand } from "~/state/use-atom-command"; import { canOneClickUpdateProviderCandidate, @@ -17,13 +18,17 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export function useProviderUpdates() { const { environments } = useEnvironments(); + const manageableIds = useEnvironmentsWithScope(environments, AuthProvidersManageScope); const providersByEnvironment = useEnvironmentProviders(); const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false, }); const machines = environments.flatMap((environment) => { const providers = providersByEnvironment.get(environment.environmentId) ?? []; - if (environment.connection.phase !== "connected") { + if ( + environment.connection.phase !== "connected" || + !manageableIds.has(environment.environmentId) + ) { return []; } const candidates = collectProviderUpdateCandidates(providers).filter((candidate) => diff --git a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx index c388ec33af0b5..5dc65e57f1ca3 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx @@ -265,4 +265,11 @@ describe("root reopen shortcut", () => { expect(useClosedViewStore.getState().entries).toEqual([]); }, ); + it("forwards address-bar focus from a focused desktop page", async () => { + setup(); + await using _shortcut = await renderShortcut(); + expect(state.setShortcuts).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ command: "preview.focusUrl" })]), + ); + }); }); diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index ce187f5db5e96..77cc7b3d3068d 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -43,12 +43,13 @@ import { } from "../threadRoutes"; import { toastManager } from "./ui/toast"; -const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [ +const PREVIEW_FORWARDED_COMMANDS = [ "workspace.focusNext", "workspace.focusPrevious", "sidebar.toggle", "rightPanel.toggle", "rightPanel.toggleMaximized", + "preview.focusUrl", ] as const; const isGlobalPullRequests = (ref: ScopedThreadRef) => @@ -205,7 +206,7 @@ export function ReopenClosedViewShortcut() { // Guest browser processes miss app keydown listeners; desktop forwards the chords as menu actions. const commands = [ ...(hasHistory ? (["view.reopenClosed"] as const) : []), - ...PREVIEW_FORWARDED_LAYOUT_COMMANDS, + ...PREVIEW_FORWARDED_COMMANDS, ]; void preview .setForwardedShortcuts( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index f6e66a1ac6506..fde82414afa79 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -12,6 +12,8 @@ import { getFallbackThreadIdAfterDelete, hasUnseenCompletion, resolveSidebarRowThreadId, + belongsInSidebarWorkingShelf, + isSidebarEnvironmentDisconnected, orderItemsByPreferredIds, pinOrderKeyBetween, resolveProjectStatusIndicator, @@ -1089,6 +1091,38 @@ describe("Working shelf (beta)", () => { ).toBe(false); }); + it.each([ + ["connected", true, false], + ["connected", false, true], + ["connecting", false, false], + ["reconnecting", false, true], + ["offline", false, true], + ["error", false, true], + ["unsupported", false, true], + ["available", false, true], + ] as const)( + "lists threads outside the shelf on a %s environment: %s", + (phase, shellIsLive, disconnected) => { + expect(isSidebarEnvironmentDisconnected(phase, shellIsLive)).toBe(disconnected); + }, + ); + + it("lists a running thread outside the shelf while its environment is disconnected", () => { + const thread = { ...idle, runtime, environmentId: localEnvironmentId }; + const shelve = (disconnected: ReadonlyArray, enabled = true) => + belongsInSidebarWorkingShelf(thread, { + enabled, + disconnectedEnvironmentIds: new Set(disconnected), + isWorking: isSidebarThreadWorking, + }); + + expect(shelve([])).toBe(true); + expect(shelve([localEnvironmentId])).toBe(false); + expect(shelve([])).toBe(true); + expect(shelve([EnvironmentId.make("environment-other")])).toBe(true); + expect(shelve([], false)).toBe(false); + }); + describe("sortInboxThreadsByReturn", () => { const thread = ( id: string, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 730a3508bb8cc..0f51d231d333b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,6 +16,7 @@ import { type ThreadSearchShell, } from "@t3tools/client-runtime/state/thread-search"; import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { isListedThreadLineage, type ContextMenuItem, @@ -184,6 +185,29 @@ export function resolveSidebarThreadSection(input: { return "active"; } +// Keep cached placement on the first connection attempt, matching mobile's threadRouteIsHydrating. +export function isSidebarEnvironmentDisconnected( + phase: EnvironmentConnectionPhase, + shellIsLive: boolean, +): boolean { + return phase !== "connecting" && (phase !== "connected" || !shellIsLive); +} + +export function belongsInSidebarWorkingShelf( + thread: T, + input: { + readonly enabled: boolean; + readonly disconnectedEnvironmentIds: ReadonlySet; + readonly isWorking: (thread: T) => boolean; + }, +): boolean { + return ( + input.enabled && + !input.disconnectedEnvironmentIds.has(thread.environmentId) && + input.isWorking(thread) + ); +} + const SIDEBAR_MARKER_PREFIX = "sidebar-marker-"; export type SidebarListMarker = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e6f23149dfedb..4fa3f06e71757 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { liveShellEnvironmentIdsAtom } from "../state/shell"; import { attachSidebarTree, SIDEBAR_TREE_PROPS } from "../lib/sidebarTree"; import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; @@ -138,6 +139,7 @@ import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironmentIdentities, useConnectedEnvironmentIds, + useEnvironmentConnectionSummaries, useEnvironmentMachines, usePrimaryEnvironmentId, } from "../state/environments"; @@ -184,6 +186,8 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, firstValidTimestampMs, + belongsInSidebarWorkingShelf, + isSidebarEnvironmentDisconnected, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -351,12 +355,17 @@ interface SidebarThreadPartitionInput { readonly workingShelfEnabled: boolean; /** Boring mode: folders list active threads by the user's latest message. */ readonly sortActiveBySend: boolean; + readonly disconnectedEnvironmentIds: ReadonlySet; // Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend // on the clock, so the minute tick, the precise snooze-wake tick and the inbox // tracker's expiry version are inputs: a new key repartitions. readonly clockKey: string; } +function scrollSidebarToTop(content: HTMLElement | null) { + content?.closest("[data-slot=scroll-area-viewport]")?.scrollTo({ top: 0 }); +} + function threadShellKey(thread: Pick): string { return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); } @@ -432,6 +441,18 @@ function useSidebarThreads(input: { // Folders already separate the work, so boring mode has no Working shelf. const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled) && folderGroups === null; + const environmentConnections = useEnvironmentConnectionSummaries(); + const liveShellEnvironmentIds = useAtomValue(liveShellEnvironmentIdsAtom); + const disconnectedEnvironmentIds = new Set( + environmentConnections + .filter((environment) => + isSidebarEnvironmentDisconnected( + environment.connectionState, + liveShellEnvironmentIds.has(environment.environmentId), + ), + ) + .map((environment) => environment.environmentId), + ); const inboxReturnsVersion = useSyncExternalStore( inboxReturns.subscribe, inboxReturns.getSnapshot, @@ -476,6 +497,7 @@ function useSidebarThreads(input: { optimisticDrop, workingShelfEnabled, sortActiveBySend: folderGroups !== null, + disconnectedEnvironmentIds, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); @@ -611,6 +633,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { optimisticDrop, workingShelfEnabled, sortActiveBySend, + disconnectedEnvironmentIds, } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); @@ -619,8 +642,11 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => - workingShelfEnabled && - (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) + belongsInSidebarWorkingShelf(thread, { + enabled: workingShelfEnabled, + disconnectedEnvironmentIds, + isWorking: manuallyMoved ? isThreadWorking : inboxObservation.isWorking, + }) ? working : active; const snoozed: EnvironmentThreadShell[] = []; @@ -1032,6 +1058,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const [renamingTitle, setRenamingTitle] = useState(""); const settlingThreadKeysRef = useRef(new Set()); const threadListRef = useRef(null); + const sidebarContentRef = useRef(null); const dragLabelOffsetRef = useRef(0); const listMotionRef = useRef | null>(null); const [dragState, setDragState] = useState<{ @@ -1057,9 +1084,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; // A selection made under one scope can hold threads the next scope hides. + // The viewport outlives the scope and does not anchor, so a new list starts at the top. const setProjectScopeKeys = (keys: readonly string[]) => { clearSelection(); setPersistedProjectScopeKeys(keys); + scrollSidebarToTop(sidebarContentRef.current); }; // A scope whose project is gone reads as all projects once every snapshot has // loaded. The stored keys are left alone, so the scope returns with its project. @@ -1968,10 +1997,17 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve dragState === null && sidebarListItems.length + visibleDraftSessionCount > 0; // Cache on both fields so layout-key changes trigger the effect even when animate is unchanged. const listMotionPass = { layoutKey: listMotionLayoutKey, animate: listMotionAnimate }; + const shelfExpansionKey = [workingShelfExpanded, snoozedShelfExpanded, settledShelfExpanded].join( + ":", + ); + const previousShelfExpansionKeyRef = useRef(shelfExpansionKey); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells the list motion controller to animate or not after each layout-affecting commit useLayoutEffect(() => { - listMotionRef.current?.update(listMotionPass.animate); - }, [listMotionPass]); + // Shelf rows would travel over stationary headers below the bottom-anchored shelf. + const shelvesUnchanged = previousShelfExpansionKeyRef.current === shelfExpansionKey; + previousShelfExpansionKeyRef.current = shelfExpansionKey; + listMotionRef.current?.update(listMotionPass.animate && shelvesUnchanged); + }, [listMotionPass, shelfExpansionKey]); const handleThreadDragOver = (event: DragOverEvent) => { const target = event.over ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id)) @@ -2948,6 +2984,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index ff029d4da112d..3af74e1d34139 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -119,6 +119,8 @@ export function startThreadNotifications( ) : status === "approval" ? ( + ) : status === "limited" ? ( + ) : status === "failed" ? ( ) : ( diff --git a/apps/web/src/components/ThreadStatusIndicators.linkedPr.browser.test.tsx b/apps/web/src/components/ThreadStatusIndicators.linkedPr.browser.test.tsx new file mode 100644 index 0000000000000..2f690d88f5b9d --- /dev/null +++ b/apps/web/src/components/ThreadStatusIndicators.linkedPr.browser.test.tsx @@ -0,0 +1,66 @@ +import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { makeThreadFixture } from "../test-fixtures"; +import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; + +const environmentId = EnvironmentId.make("environment-test"); + +vi.mock(import("../state/entities"), async (original) => ({ + ...(await original()), + useProject: () => null, + useServerConfigs: (() => + new Map([ + [environmentId, { environment: { capabilities: { threadPullRequests: true } } }], + ])) as never, +})); + +it("updates palette PR status from linked snapshots without a legacy link or branch PR", async () => { + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "example/repo", + number: 7, + url: "https://github.com/example/repo/pull/7", + source: "manual", + linkedAt: "2026-01-01T00:00:00Z", + stack: null, + snapshot: { + state: "merged", + title: "Linked change", + headBranch: "deleted-branch", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-01-03T00:00:00Z", + }, + }; + const row = (pullRequest: ThreadPullRequestLink) => ( + + + + ); + await using screen = await render(row(link)); + await expect.element(screen.getByLabelText("PR #7 - Merged: Linked change")).toBeVisible(); + await expect.element(screen.getByLabelText("PR #7, status pending")).not.toBeInTheDocument(); + + await screen.rerender(row({ ...link, snapshot: null })); + await expect.element(screen.getByLabelText("PR #7, status pending")).toBeVisible(); + await expect + .element(screen.getByLabelText("PR #7 - Merged: Linked change")) + .not.toBeInTheDocument(); + + await screen.rerender( + row({ ...link, snapshot: { ...link.snapshot!, state: "open", isDraft: true } }), + ); + await expect.element(screen.getByLabelText("PR #7 - Draft: Linked change")).toBeVisible(); + await expect.element(screen.getByLabelText("PR #7, status pending")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0af0e063bdea3..ea49b01665b64 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -674,6 +674,8 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar thread.environmentId, thread.linkedPullRequest, leaseLiveStatus, + thread.pullRequests, + thread.branchPullRequest, ); const gitStatus = useEnvironmentQuery( leaseLiveStatus && @@ -696,8 +698,11 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar linkedPullRequest: thread.linkedPullRequest, linkedPullRequestStatus: linkedPullRequest, }; - const pr = resolveDisplayedThreadPr(displayedPrInput); - const prStatus = prStatusIndicator(pr, resolveDisplayedThreadPrProvider(displayedPrInput)); + const pr = linkedPullRequest?.pr ?? resolveDisplayedThreadPr(displayedPrInput); + const prStatus = prStatusIndicator( + pr, + linkedPullRequest?.sourceControlProvider ?? resolveDisplayedThreadPrProvider(displayedPrInput), + ); const threadStatus = resolveThreadStatusPill({ thread: { ...thread, diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index cbe5ad8cddb24..2b90b09f68e77 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -31,6 +31,7 @@ import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCom import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; import { composerFloatingLayerProps } from "./composerEventScope"; import { observeResize } from "~/lib/observeResize"; +import { textDirection } from "~/textDirection"; export function AssistantCitationChip({ citation, @@ -130,7 +131,9 @@ export function AssistantCitationChip({ aria-label={`View cited assistant text: ${label}`} >
); } diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index d9fd60645618e..0a8357bb9baba 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -1,5 +1,6 @@ import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; import { useState, type Ref } from "react"; +import { textDirection } from "~/textDirection"; import { Button } from "../ui/button"; @@ -51,6 +52,7 @@ export function AssistantCitationCommentEditor({ aria-invalid={commentTooLong || undefined} placeholder="Add an optional comment..." rows={2} + dir={textDirection(comment)} className="field-sizing-content block max-h-40 min-h-16 w-full resize-none bg-transparent px-1 py-1.5 text-base outline-none placeholder:text-muted-foreground sm:text-sm" value={comment} onChange={(event) => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c423773fb6fc6..82b6b9617c1b0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -934,11 +934,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedProviderSlashCommands = selectedProviderStatus ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, gitCwd) : NO_PROVIDER_SLASH_COMMANDS; - const refreshWorkspaceSnapshotOnTyping = useProviderWorkspaceSnapshotRefresh({ - environmentId, - gitCwd, - instanceId: selectedProviderEntry?.instanceId, - }); + const { refresh: refreshWorkspaceSnapshotOnTyping, scanning: workspaceSkillsScanning } = + useProviderWorkspaceSnapshotRefresh({ + environmentId, + gitCwd, + instanceId: selectedProviderEntry?.instanceId, + }); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; @@ -1139,6 +1140,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderSkills, selectedProviderSlashCommands, showSkillsInSlashMenu: settings.showSkillsInSlashMenu, + workspaceSkillsScanning, questionAttachmentTarget, }); @@ -3289,7 +3291,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? isComposerMenuLoading ? composerTriggerKind === "pull-request" ? "Finding pull request..." - : "Searching workspace files..." + : composerTriggerKind === "skill" || composerTriggerKind === "slash-command" + ? "Searching workspace skills..." + : "Searching workspace files..." : composerMenuEmptyState : ""}
diff --git a/apps/web/src/components/chat/ChatHeader.browser.test.tsx b/apps/web/src/components/chat/ChatHeader.browser.test.tsx new file mode 100644 index 0000000000000..115de39efe25d --- /dev/null +++ b/apps/web/src/components/chat/ChatHeader.browser.test.tsx @@ -0,0 +1,73 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +const persisted = vi.hoisted(() => ({ title: "" })); +vi.mock(import("../../state/session"), async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock(import("../../state/use-orchestration-command"), async (importOriginal) => ({ + ...(await importOriginal()), + useOrchestrationCommand: (() => + async ({ input }: { input: { title: string } }) => { + persisted.title = input.title; + return { _tag: "Success" }; + }) as never, +})); +vi.mock(import("~/hooks/useThreadActionMenu"), () => ({ + useThreadActionMenu: () => ({ openMenu: () => {}, closeMenu: () => {} }), +})); + +import { render } from "../../../test/render"; +import { ChatHeader } from "./ChatHeader"; + +function header(title: string) { + return ( + <> + {}} + rightPanelOpen={false} + onNewThreadInProject={() => {}} + /> +
Outside rename
+ + ); +} + +it.each(["blur", "Enter"] as const)( + "preserves a concurrent title update when an untouched rename ends with %s", + async (finish) => { + persisted.title = "New thread"; + await using screen = await render(header(persisted.title)); + await screen.getByRole("button", { name: "Thread actions for New thread" }).dblClick(); + const input = screen.getByRole("textbox", { name: "Thread title" }); + await expect.element(input).toHaveValue("New thread"); + + persisted.title = "Fix login redirect"; + await screen.rerender(header(persisted.title)); + if (finish === "Enter") await userEvent.keyboard("{Enter}"); + else await screen.getByText("Outside rename").click(); + + await expect.element(input).not.toBeInTheDocument(); + expect(persisted.title).toBe("Fix login redirect"); + + await screen.rerender(header(persisted.title)); + await screen.getByRole("button", { name: "Thread actions for Fix login redirect" }).dblClick(); + await screen.getByRole("textbox", { name: "Thread title" }).fill("My edited title"); + persisted.title = "Another concurrent title"; + await screen.rerender(header(persisted.title)); + await userEvent.keyboard("{Enter}"); + await expect + .element(screen.getByRole("textbox", { name: "Thread title" })) + .not.toBeInTheDocument(); + expect(persisted.title).toBe("My edited title"); + }, +); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 144402d1dfe9f..49f21a1ddc829 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -175,7 +175,10 @@ export const ChatHeader = memo(function ChatHeader({ const commitRename = (title: string) => { setRenaming(null); if (!readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope)) return; - const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); + const resolution = resolveRenameCommit({ + title, + originalTitle: renamingTitle ?? activeThreadTitle, + }); if (resolution.action === "reject-empty") { toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); return; diff --git a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx index bf83b014b7f6f..4c9702abe5f31 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.browser.test.tsx @@ -1,6 +1,7 @@ import { InfoIcon } from "lucide-react"; import type { ReactNode } from "react"; import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; @@ -123,3 +124,28 @@ it("shows the details button while text or nested text is cut, until it fits aga await screen.rerender({noticeRow("4rem", "Ready")}); await expect.element(details).not.toBeInTheDocument(); }); + +it("opens the details of a long notice right above its button", async () => { + const viewport = { width: window.innerWidth, height: window.innerHeight }; + await page.viewport(1280, 800); + await using _viewport = { + [Symbol.asyncDispose]: () => page.viewport(viewport.width, viewport.height), + }; + const script = Array.from({ length: 400 }, (_, line) => `echo "line ${line}"`).join("\n"); + const container = document.createElement("div"); + container.style.cssText = "position: fixed; bottom: 4rem; right: 4rem;"; + await using screen = await render(noticeRow("40rem", script), { container }); + const details = screen.getByRole("button", { name: "Show details" }); + await userEvent.hover(details); + const popup = page.getByLabelText("Notice details"); + await expect.element(popup).toBeVisible(); + + // A positioner sized to the unscrolled text flips beside the button and covers it. + await vi.waitFor(() => { + const gap = + details.element().getBoundingClientRect().top - + popup.element().getBoundingClientRect().bottom; + expect(gap).toBeGreaterThanOrEqual(0); + expect(gap).toBeLessThan(8); + }); +}); diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 61778f817dc37..ee602dcdac1eb 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -155,9 +155,7 @@ export function NoticeText({ side="top" className="max-w-[min(30rem,calc(100vw-2rem))] whitespace-normal wrap-anywhere [--inline-button-text-align:start] [--inline-button-white-space:normal] [&_[data-slot=inline-button]]:max-w-full" > - - {children} - + {children} ) : null} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index afa058587b553..0b9dda460d8ef 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -152,7 +152,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {

{props.isLoading - ? props.triggerKind === "skill" + ? props.triggerKind === "skill" || props.triggerKind === "slash-command" ? "Searching workspace skills..." : props.triggerKind === "pull-request" ? "Finding pull request..." diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx index 139e80d081e4e..9c1e92d93641f 100644 --- a/apps/web/src/components/chat/ComposerControl.tsx +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -12,7 +12,7 @@ function composerControlClassName(size: ComposerControlSize, className?: string) "relative inline-flex shrink-0 cursor-pointer items-center justify-center whitespace-nowrap rounded-(--control-radius) border border-transparent text-base outline-none hover:bg-accent data-pressed:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&:active:not([aria-haspopup])]:scale-[0.97] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:-mx-0.5 [&_svg[data-composer-control-icon]]:mx-0", "[&_:is([data-composer-control-label],[data-chat-provider-model-picker-label])]:[text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both_cap_alphabetic]:[&_:is([data-composer-control-label],[data-chat-provider-model-picker-label])]:py-1.5", size === "xs" - ? "h-7 gap-1 px-1.75 font-normal text-foreground/70 text-sm hover:text-foreground dark:text-muted-foreground sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5 [&_svg]:mx-0 [&_svg[data-composer-control-chevron]]:ms-0 [&_svg[data-composer-control-chevron]]:-me-1" + ? "h-7 gap-1 has-[[data-composer-label]]:gap-1.5 px-1.75 font-normal text-foreground/70 text-sm hover:text-foreground dark:text-muted-foreground sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5 [&_svg]:mx-0 [&_svg[data-composer-control-chevron]]:ms-0 [&_svg[data-composer-control-chevron]]:-me-1" : "h-7 gap-1.5 px-2 font-medium text-foreground/80 [&_svg:not([class*='text-'])]:text-muted-foreground hover:text-foreground sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4", "aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-pressed:hover:bg-accent/80", className, diff --git a/apps/web/src/components/chat/ComposerImageThumbnail.tsx b/apps/web/src/components/chat/ComposerImageThumbnail.tsx index e9de2aa93b302..68158c7151b2f 100644 --- a/apps/web/src/components/chat/ComposerImageThumbnail.tsx +++ b/apps/web/src/components/chat/ComposerImageThumbnail.tsx @@ -1,24 +1,15 @@ -import { memo, Suspense, use, type ReactNode } from "react"; +import { memo, useLayoutEffect, useState, type ReactNode } from "react"; + +import { cn } from "~/lib/utils"; import { createComposerImageThumbnail, + isHeicImageFile, + MAX_COMPRESSIBLE_SOURCE_BYTES, readComposerImageThumbnail, } from "../../lib/imageCompression"; -export const ComposerImageThumbnail = memo(function ComposerImageThumbnail(props: { - file: File; - alt: string; - className: string; - fallback: ReactNode; -}) { - return ( - - - - ); -}); - -function ThumbnailImage({ +export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({ file, alt, className, @@ -29,8 +20,63 @@ function ThumbnailImage({ className: string; fallback: ReactNode; }) { - // A settled thumbnail paints synchronously instead of suspending for a tick. - const settled = readComposerImageThumbnail(file); - const src = settled === undefined ? use(createComposerImageThumbnail(file)) : settled; - return src ? {alt} : fallback; -} + const [preview, setPreview] = useState(() => ({ + file, + src: readComposerImageThumbnail(file), + })); + const [loadedSrc, setLoadedSrc] = useState(null); + const [failedSrc, setFailedSrc] = useState(null); + const src = preview.file === file ? preview.src : readComposerImageThumbnail(file); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- owns the local blob URL and decodes its replacement before swapping the visible image + useLayoutEffect(() => { + let disposed = false; + let localUrl: string | undefined; + const cached = readComposerImageThumbnail(file); + if ( + file.size <= MAX_COMPRESSIBLE_SOURCE_BYTES && + !isHeicImageFile(file) && + cached === undefined + ) { + localUrl = URL.createObjectURL(file); + // oxlint-disable-next-line react/set-state-in-effect -- the blob URL is created and revoked by this effect + setPreview({ file, src: localUrl }); + } + if (cached === undefined) { + void createComposerImageThumbnail(file).then(async (thumbnail) => { + if (disposed) return; + if (!thumbnail) { + if (!localUrl) setPreview({ file, src: null }); + return; + } + const image = new Image(); + image.src = thumbnail; + try { + await image.decode(); + if (!disposed) setPreview({ file, src: thumbnail }); + } catch { + // Keep the local preview if the thumbnail cannot be decoded. + } + }); + } + return () => { + disposed = true; + if (localUrl) URL.revokeObjectURL(localUrl); + }; + }, [file]); + + return src && failedSrc !== src ? ( + { + if (image?.complete && image.naturalWidth > 0) setLoadedSrc(src); + }} + src={src} + alt={alt} + onLoad={() => setLoadedSrc(src)} + onError={() => setFailedSrc(src)} + className={cn(className, loadedSrc !== src && "invisible")} + /> + ) : src === undefined ? null : ( + fallback + ); +}); diff --git a/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx new file mode 100644 index 0000000000000..3d704b5209305 --- /dev/null +++ b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx @@ -0,0 +1,125 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ProviderOptionSelection, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { useState } from "react"; +import { expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { ComposerEffortColumn, describeComposerEffort } from "./ComposerModelEffort"; +import type { TraitsSelectionInput } from "./TraitsPicker"; + +// Shaped like Devin over ACP: its session mode comes first, and its reasoning id is its own. +const models: ReadonlyArray = [ + { + slug: "fusion", + name: "Fusion (Claude Opus 5.5 Medium + SWE-2 High)", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { + id: "mode", + label: "Session Mode", + type: "select", + currentValue: "bypass", + options: [ + { id: "code", label: "Code" }, + { id: "plan", label: "Plan" }, + { id: "bypass", label: "Bypass Permissions" }, + ], + }, + { + id: "thought_level", + label: "Thinking", + type: "select", + role: "reasoning", + currentValue: "medium", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, + { + id: "speed", + label: "Speed", + type: "select", + currentValue: "standard", + options: [ + { id: "standard", label: "Standard" }, + { id: "fast", label: "Fast" }, + ], + }, + { id: "auto_approve", label: "Auto-approve tools", type: "boolean", currentValue: false }, + ], + }), + }, +]; + +function traitsInput( + modelOptions: ReadonlyArray, + onModelOptionsChange: (next: ReadonlyArray | undefined) => void, +): TraitsSelectionInput { + return { + provider: ProviderDriverKind.make("acpRegistry"), + instanceId: ProviderInstanceId.make("devin"), + models, + model: "fusion", + modelOptions, + planModeEnabled: false, + onModelOptionsChange, + }; +} + +function Column() { + const [options, setOptions] = useState>([]); + const traits = traitsInput(options, (next) => setOptions(next ?? [])); + return ( + <> +

+ +
+

{describeComposerEffort(traits, "bars", "sm").suffix}

+ + ); +} + +it("draws an agent's reasoning as bars and keeps its mode out of the levels", async () => { + await using screen = await render(, { router: false }); // no routes involved + const status = screen.getByRole("status"); + expect(status.element().querySelector("[data-composer-effort-glyph]")).not.toBeNull(); + await expect.element(status).toHaveTextContent(""); + + const levels = screen.getByRole("radiogroup", { name: "Thinking" }); + await expect.element(levels.getByRole("radio")).toHaveLength(3); + await expect.element(screen.getByRole("radio", { name: "Plan" })).not.toBeInTheDocument(); + + // Up and Down walk from the levels through the option rows without opening their menus. + await userEvent.tab(); + await expect.element(screen.getByRole("radio", { name: "Medium" })).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}"); + const speed = screen.getByRole("button", { name: "Speed: Standard" }); + await expect.element(speed).toHaveFocus(); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); + await userEvent.keyboard("{ArrowRight}"); + await expect.element(page.getByRole("menuitemradio", { name: "Standard" })).toHaveFocus(); + await userEvent.keyboard("{ArrowLeft}"); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); + await expect.element(speed).toHaveFocus(); + + await userEvent.click(screen.getByText("Auto-approve tools")); + await expect + .element(screen.getByRole("switch", { name: "Auto-approve tools" })) + .toHaveAttribute("aria-checked", "true"); + + await userEvent.click(screen.getByRole("button", { name: "Session Mode: Bypass Permissions" })); + await userEvent.click(page.getByRole("menuitemradio", { name: "Plan" })); + await expect + .element(screen.getByRole("button", { name: "Session Mode: Plan" })) + .toBeInTheDocument(); + await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index 5ce77c60b1078..3a863936101b2 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -1,16 +1,25 @@ import { buildTraitsTriggerDisplay, getDescriptorStringValue, - REASONING_SELECT_DESCRIPTOR_IDS, + isReasoningControl, selectableProviderOptionChoices, } from "@t3tools/client-runtime/model-traits"; import type { ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; import type { EffortDisplay } from "@t3tools/contracts/settings"; -import { CheckIcon, ChevronUpIcon } from "lucide-react"; -import { type KeyboardEvent, type ReactNode, use, useRef } from "react"; +import { getProviderOptionCurrentLabel } from "@t3tools/shared/model"; +import { CheckIcon, ChevronRightIcon, ChevronUpIcon } from "lucide-react"; +import { type KeyboardEvent, type ReactNode, use, useRef, useState } from "react"; import { cn } from "~/lib/utils"; -import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { + Menu, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuRadioItemIndicator, + MenuTrigger, +} from "../ui/menu"; +import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { ComposerControlSize } from "./ComposerControl"; import { ComposerPermissionsSection, type RuntimeModeOption } from "./ComposerFooterControls"; @@ -51,8 +60,8 @@ type ColumnControl = | { kind: "boolean"; descriptor: BooleanDescriptor }; /** - * The column lists the model's reasoning control first, under the header that names it, then - * its other options. + * The column lists the model's reasoning levels under the header that names it. Other options, + * such as an agent's session mode, sit below as one row each. */ function columnControls( provider: TraitsSelectionInput["provider"], @@ -64,8 +73,7 @@ function columnControls( ...columnSelects.map((descriptor) => ({ kind: "select" as const, descriptor })), ...columnToggles.map((descriptor) => ({ kind: "boolean" as const, descriptor })), ]; - const effort = - controls.find((control) => REASONING_SELECT_DESCRIPTOR_IDS.has(control.descriptor.id)) ?? null; + const effort = controls.find((control) => isReasoningControl(control.descriptor)) ?? null; return { effort, others: controls.filter((control) => control !== effort) }; } @@ -94,20 +102,40 @@ export function describeComposerEffort( modelSelection, reportedModelSelection: traits.reportedModelSelection, }); + // The same reasoning control the column lists, wherever the agent put it among its options. + const reasoning = + columnControls(traits.provider, visibility.selectDescriptors, visibility.booleanDescriptors) + .effort?.descriptor ?? null; + const ultrathink = visibility.ultrathinkPromptControlled && reasoning === primary; const glyph = - primary && REASONING_SELECT_DESCRIPTOR_IDS.has(primary.id) + reasoning?.type === "select" ? resolveEffortGlyph( - primary, - visibility.ultrathinkPromptControlled + reasoning, + ultrathink ? "ultrathink" - : getDescriptorStringValue(primary, modelSelection, traits.reportedModelSelection), + : getDescriptorStringValue(reasoning, modelSelection, traits.reportedModelSelection), ) : null; - // Without a reasoning select the whole label is the word; it already names speed and the rest. // Workflow levels such as Ultracode always show their name; the setting names every level. const word = glyph ? (glyph.special ?? (effortDisplay === "label" ? glyph.label : null)) - : display.label; + : !reasoning + ? null + : reasoning.type === "boolean" + ? `${reasoning.label} ${reasoning.currentValue === true ? "On" : "Off"}` + : ultrathink + ? "Ultrathink" + : getProviderOptionCurrentLabel(reasoning, modelSelection, traits.reportedModelSelection); + const fast = display.speedLabel === "Fast" || display.speedLabel === "Ultrafast"; + // Agent options such as modes live in the picker; the trigger adds only the context window. + const contextWindow = + visibility.contextWindowDescriptor && + getProviderOptionCurrentLabel( + visibility.contextWindowDescriptor, + modelSelection, + traits.reportedModelSelection, + ); + if (!word && !glyph && !fast && !contextWindow) return { suffix: null, detail: display.label }; return { detail: display.label, suffix: ( @@ -118,22 +146,22 @@ export function describeComposerEffort( size === "sm" && "-ms-0.5", )} > - {word !== null ? ( - {word} + {word ? ( + + {word} + ) : glyph ? ( ) : null} - {glyph && (display.speedLabel === "Fast" || display.speedLabel === "Ultrafast") ? ( - - ) : null} - {glyph && display.otherLabels.length > 0 ? ( + {fast ? : null} + {contextWindow ? ( - {display.otherLabels.join(" · ")} + {contextWindow} ) : null} @@ -150,10 +178,10 @@ function ColumnRow(props: { /** Shown on hover; the column is too narrow to print it under the label. */ description?: string | undefined; selected: boolean; + /** The column's one tab stop: the selected level, else the first when none shows as selected. */ + tabStop: boolean; isDefault?: boolean | undefined; disabled?: boolean; - /** A level of the reasoning control, where focus lands after a model pick. */ - level: boolean; onPick: () => void; }) { const rowProps = { @@ -162,9 +190,10 @@ function ColumnRow(props: { "aria-checked": props.selected, disabled: props.disabled, "data-effort-row": true, - "data-effort-level": props.level || undefined, - // The reasoning control holds the column's one tab stop; Up and Down move between rows. - tabIndex: props.selected && props.level ? 0 : -1, + // Focus lands on a level after a model pick. + "data-effort-level": true, + // Up and Down move between rows. + tabIndex: props.tabStop ? 0 : -1, className: COLUMN_ROW, onClick: props.onPick, } as const; @@ -194,6 +223,8 @@ function ColumnRow(props: { function moveBetweenRows(event: KeyboardEvent) { if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + // An option's side menu is a React child here, but it moves through its own items. + if (!event.currentTarget.contains(event.target as Node)) return; const rows = Array.from( event.currentTarget.querySelectorAll("[data-effort-row]:not(:disabled)"), ); @@ -230,66 +261,222 @@ function EffortLevels(props: { traits: TraitsSelectionInput }) { ); } if (!effort && others.length === 0) return ; - const renderControl = (control: ColumnControl) => { - const isEffort = control === effort; - const { descriptor } = control; - if (control.kind === "boolean") { - return ( + // Ultrathink in the prompt locks the levels; the first option then holds the tab stop. + const locked = + effort?.descriptor.id === traits.primarySelectDescriptor?.id && traits.ultrathinkInBodyText; + return ( + <> + {effort ? : null} + {others.length > 0 ? (
- {isEffort ? null :
{descriptor.label}
} - {([true, false] as const).map((value) => ( - traits.changeBoolean(descriptor.id, value)} - /> - ))} + {others.map((control, index) => + control.kind === "boolean" ? ( + + ) : ( + + ), + )}
- ); - } - const locked = - descriptor.id === traits.primarySelectDescriptor?.id && traits.ultrathinkInBodyText; - const value = traits.selectValue(control.descriptor); + ) : null} + + ); +} + +type TraitsSelection = ReturnType; + +function EffortRows(props: { control: ColumnControl; traits: TraitsSelection; locked: boolean }) { + const { control, traits } = props; + const { descriptor } = control; + if (control.kind === "boolean") { return ( -
- {isEffort ? null :
{descriptor.label}
} - {locked ? ( -
- {ULTRATHINK_IN_BODY_NOTE} -
- ) : null} - {selectableProviderOptionChoices(control.descriptor).map((option) => ( +
+ {([true, false] as const).map((value) => ( traits.changeSelect(control.descriptor, option.id)} + key={String(value)} + label={value ? "On" : "Off"} + selected={(control.descriptor.currentValue === true) === value} + tabStop={(control.descriptor.currentValue === true) === value} + onPick={() => traits.changeBoolean(descriptor.id, value)} /> ))}
); - }; + } + const { locked } = props; + const value = traits.selectValue(control.descriptor); + const choices = selectableProviderOptionChoices(control.descriptor); + // An "Ultrathink:" prefix selects a level the column does not offer. + const shown = choices.some((option) => option.id === value); return ( - <> - {effort ? renderControl(effort) : null} - {others.map(renderControl)} - +
+ {locked ? ( +
+ {ULTRATHINK_IN_BODY_NOTE} +
+ ) : null} + {choices.map((option, index) => ( + traits.changeSelect(control.descriptor, option.id)} + /> + ))} +
+ ); +} + +const OPTION_ROW = cn(COLUMN_ROW, "h-auto py-1"); + +/** An option's name over its current value. */ +function OptionRowText(props: { label: string; value: string }) { + return ( + + {props.label} + {props.value} + + ); +} + +/** + * One select besides reasoning, such as an agent's session mode. It names the current choice and + * opens the rest beside the column, so any number of options leaves the levels in place. + */ +function OptionMenuRow(props: { + descriptor: SelectDescriptor; + traits: TraitsSelection; + /** Holds the column's tab stop when the model has no levels. */ + tabStop: boolean; +}) { + const { descriptor, traits } = props; + const [open, setOpen] = useState(false); + const popupRef = useRef(null); + const choices = selectableProviderOptionChoices(descriptor); + const value = traits.selectValue(descriptor); + const current = choices.find((choice) => choice.id === value)?.label ?? ""; + return ( + { + if (!isOpen) return; + const checked = popupRef.current?.querySelector( + '[role="menuitemradio"][aria-checked="true"]', + ); + checked?.focus({ preventScroll: true }); + checked?.scrollIntoView({ block: "nearest" }); + }} + > + {/* No hover opening: an opened menu takes focus, which would steal typing from search. */} + { + if (event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + setOpen(true); + }} + /> + } + > + + + { + if (event.key !== "ArrowLeft") return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + }} + > + { + if (next !== value) traits.changeSelect(descriptor, next); + }} + > + {choices.map((choice) => ( + + + + {choice.label} + {choice.description ? ( + + {choice.description} + + ) : null} + + {choice.isDefault ? ( + Default + ) : null} + + + + ))} + + + + ); +} + +/** A two-state option toggles in place; a menu would add a click for nothing. */ +function OptionSwitchRow(props: { + descriptor: BooleanDescriptor; + traits: TraitsSelection; + tabStop: boolean; +}) { + const { descriptor, traits } = props; + const checked = descriptor.currentValue === true; + return ( + ); } @@ -303,20 +490,22 @@ function NoEffortRow() { export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | null }) { const picker = use(DockedPickerContext); const visibility = props.traits ? getTraitsSectionVisibility(props.traits) : null; - const effort = + const controls = props.traits && visibility ? columnControls( props.traits.provider, visibility.selectDescriptors, visibility.booleanDescriptors, - ).effort + ) : null; return ( <> {/* Matches the search row's box, so one rule runs across the top of the popup. */}
- {effort?.descriptor.label ?? "Effort"} + + {controls?.effort?.descriptor.label ?? (controls?.others.length ? "Options" : "Effort")} + {picker.modelLabel} @@ -325,7 +514,8 @@ export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | nul {/* pt-2.5 puts the first level on the same line as the first model. */}
{props.traits ? : }
diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.direction.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.direction.browser.test.tsx new file mode 100644 index 0000000000000..6e1b387a7412f --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.direction.browser.test.tsx @@ -0,0 +1,100 @@ +import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +vi.mock(import("../ChatMarkdown"), () => ({ + default: (({ text }: { text: string }) =>

{text}

) as never, +})); + +import { render } from "../../../test/render"; +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; + +type Question = { + header: string; + question: string; + options: { label: string; description: string }[]; +}; + +const renderPanel = (questions: Question[], questionIndex = 0) => + render( + ({ + ...question, + id: `question-${index}`, + multiSelect: false, + })), + }, + ]} + cwd={undefined} + threadRef={{ environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }} + respondingRequestIds={[]} + answers={{}} + questionIndex={questionIndex} + onToggleOption={() => {}} + onAdvance={() => {}} + onDismiss={() => {}} + />, + ); + +it("reads a mostly Hebrew question set right to left", async () => { + await using screen = await renderPanel([ + { + header: "גישה", + question: "איך לבצע את המיגרציה?", + options: [ + { + label: "React Server Components בכל המסכים של האפליקציה", + description: "מסך אחד בכל פעם", + }, + { label: "Keep it on React 18", description: "English only" }, + ], + }, + ]); + + const text = (value: string) => screen.getByText(value, { exact: true }); + await expect.element(text("גישה")).toHaveStyle({ direction: "rtl" }); + await expect + .element(text("React Server Components בכל המסכים של האפליקציה")) + .toHaveStyle({ direction: "rtl" }); + await expect.element(text("מסך אחד בכל פעם")).toHaveStyle({ direction: "rtl" }); + await expect + .element(screen.getByRole("button", { name: /React Server/ })) + .toHaveStyle({ direction: "rtl" }); + await expect.element(text("Keep it on React 18")).toHaveStyle({ direction: "ltr" }); + + await screen.getByTitle("Hide the question and its options").click(); + await expect.element(text("איך לבצע את המיגרציה?")).toHaveStyle({ direction: "rtl" }); +}); + +it("keeps one direction for the whole set when a single question is Hebrew", async () => { + await using screen = await renderPanel( + [ + { + header: "Migration", + question: "How should we move to React Server Components?", + options: [ + { label: "Screen by screen", description: "Lower risk, each step tested alone." }, + { label: "Server Actions for all forms first", description: "Then the rest." }, + ], + }, + { + header: "גרסה", + question: "בחר גרסה", + options: [{ label: "גרסה 3 (חדשה!)", description: "!!!" }], + }, + ], + 1, + ); + + await expect + .element(screen.getByRole("button", { name: /גרסה 3/ })) + .toHaveStyle({ direction: "ltr" }); + await expect + .element(screen.getByText("גרסה 3 (חדשה!)", { exact: true })) + .toHaveStyle({ direction: "rtl" }); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index 58ad8e8c0900d..777238f7635e8 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -17,7 +17,9 @@ vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({ })); import { act, fakeTimers, render } from "../../../test/render"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { makeThreadProjectionFixture } from "../../test-fixtures"; +import { toastManager } from "../ui/toast"; import { usePendingThreadRequests } from "./usePendingThreadRequests"; const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; @@ -89,7 +91,8 @@ function useProbeRequests({ environmentId, projection, loading = false }: ProbeP serverProjection: loading ? null : projection, supportsQuestionAttachments: false, composerDraftTarget: { environmentId, threadId: projection.thread.id }, - setComposerDraftPrompt: () => {}, + setComposerDraftPrompt: (target, prompt) => + useComposerDraftStore.getState().setPrompt(target, prompt), promptRef, composerRef, setThreadError: () => {}, @@ -141,6 +144,43 @@ it("retains answers, selections and question position after leaving chat, includ expect(requests).toMatchObject(expected); }); +it("tells the user when an option click moves their typed answer into the hidden composer draft", async () => { + using _timers = fakeTimers(); + const environmentId = EnvironmentId.make("displaced-environment"); + const draftTarget = { environmentId, threadId: threadRef.threadId }; + const draftPrompt = () => useComposerDraftStore.getState().getComposerDraft(draftTarget)?.prompt; + using toast = vi.spyOn(toastManager, "add"); + await using _screen = await render( + , + ); + + await act(() => requests.onSelectActivePendingUserInputOption("question-0", "Incremental")); + expect(toast).not.toHaveBeenCalled(); + + await act(() => requests.onChangeActivePendingUserInputCustomAnswer("question-0", "first try")); + await act(() => requests.onSelectActivePendingUserInputOption("question-0", "Incremental")); + expect(draftPrompt()).toBe("first try"); + expect(toast).toHaveBeenCalledTimes(1); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + type: "info", + title: "Your typed text was set aside", + description: "It will be back in the composer once the question is answered.", + data: { threadRef: draftTarget }, + }), + ); + + await act(() => requests.onChangeActivePendingUserInputCustomAnswer("question-0", "second try")); + expect(draftPrompt()).toBe("first try"); + expect(requests.activePendingDraftAnswers["question-0"]).toMatchObject({ + customAnswer: "second try", + }); + expect(toast).toHaveBeenCalledTimes(1); +}); + function QuestionPositionProbe(props: ProbeProps) { const state = useProbeRequests(props); return ( diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 37a002e2c2708..a19e7f205cef9 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,4 +1,5 @@ import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts"; +import { markdownProseDirection, textDirection } from "~/textDirection"; import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; import { useStableCallback } from "~/hooks/useStableCallback"; @@ -179,8 +180,20 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( return null; } + const promptDirection = markdownProseDirection( + prompt.questions + .flatMap((question) => [ + question.header, + question.question, + ...question.options.flatMap((option) => [option.label, option.description ?? ""]), + ]) + .join(" "), + ); + const questionTextAlign = promptDirection === "rtl" ? "text-right" : "text-left"; + return ( { setCollapsedQuestionId(open ? null : activeQuestion.id); @@ -197,13 +210,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( {activeQuestion.header} {isCollapsed ? ( <> - + {activeQuestion.question} @@ -245,7 +262,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( text={activeQuestion.question} cwd={cwd} threadRef={threadRef} - className="pt-2 text-foreground/85" + className={cn("pt-2 text-foreground/85", questionTextAlign)} /> {/* Rows span the header row's width so the radios align with the question text and the shortcut column sits under the dismiss button. */}
@@ -257,7 +274,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const isSelected = isOptimisticallySelected || draftSelectedOptions?.[index] === true; const className = cn( - "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 text-left sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", + questionTextAlign, + "group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", isSelected ? "text-foreground" : "text-foreground/70 hover:bg-muted/30", (disabled || isResponding) && "opacity-50 cursor-not-allowed", !disabled && !isResponding && "cursor-pointer", @@ -273,9 +291,16 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( )}
- {option.label} + + {option.label} + {option.description && option.description !== option.label ? ( - {option.description} + + {option.description} + ) : null}
{index < 9 ? ( diff --git a/apps/web/src/components/chat/EffortGlyph.test.ts b/apps/web/src/components/chat/EffortGlyph.test.ts index 712e4b796ba61..0b73e89e4f833 100644 --- a/apps/web/src/components/chat/EffortGlyph.test.ts +++ b/apps/web/src/components/chat/EffortGlyph.test.ts @@ -45,9 +45,11 @@ describe("resolveEffortGlyph", () => { }); }); - it("falls back to the word when the value is unknown or the scale has one level", () => { + it("falls back to the word when the value is unknown or the scale is too short or long", () => { expect(resolveEffortGlyph(claude, "unknown")).toBeNull(); expect(resolveEffortGlyph(descriptor(["high"]), "high")).toBeNull(); + const levels = Array.from({ length: 8 }, (_, index) => `level${index}`); + expect(resolveEffortGlyph(descriptor(levels), "level0")).toBeNull(); }); }); diff --git a/apps/web/src/components/chat/EffortGlyph.tsx b/apps/web/src/components/chat/EffortGlyph.tsx index 39a46d2564901..7614e41c496a6 100644 --- a/apps/web/src/components/chat/EffortGlyph.tsx +++ b/apps/web/src/components/chat/EffortGlyph.tsx @@ -14,6 +14,8 @@ type SelectDescriptor = Extract; // Max is named too, so the full scale stops short of it. const NAMED_LEVELS = new Set(["ultracode", "max"]); const OFF_LEVELS = new Set(["off", "none"]); +// More bars than this no longer fit the glyph's height in whole pixels. +const MAX_BARS = 7; /** * Maps a reasoning select to bars: one per ordinary level, filled up to the current one, with an @@ -22,7 +24,7 @@ const OFF_LEVELS = new Set(["off", "none"]); export function resolveEffortGlyph(descriptor: SelectDescriptor, current: string | null) { const special = new Set([...(descriptor.promptInjectedValues ?? []), ...NAMED_LEVELS]); const levels = descriptor.options.filter((option) => !special.has(option.id)); - if (levels.length < 2 || current === null) return null; + if (levels.length < 2 || levels.length > MAX_BARS || current === null) return null; const startsAtOff = OFF_LEVELS.has(levels[0]!.id); const count = startsAtOff ? levels.length - 1 : levels.length; if (special.has(current)) { diff --git a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx index 1ea8d4c558f35..0dc5b367b4e34 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx @@ -16,7 +16,46 @@ const snapShotSource: SnapShotSource = { windowTitle: "Downloads", }; +const D2_SVG = + ''; + describe("ExpandedImageDialog", () => { + it.each(["/work/pipeline.svg", "/work/diagram#v2.svg", "/work/diagram?v2.svg"])( + "fits a markdown SVG without intrinsic size to the viewer: %s", + async (path) => { + const src = URL.createObjectURL(new Blob([D2_SVG], { type: "image/svg+xml" })); + using _src = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + await using screen = await render( + , + // The dialog renders no links and reads no route. + { router: false }, + ); + + const viewer = screen.getByRole("region", { name: "Pipeline, zoomable image" }); + const availableWidth = window.innerWidth * 0.92 - (window.innerWidth >= 640 ? 96 : 0); + await vi.waitFor(() => + expect(viewer.element().getBoundingClientRect().width).toBeCloseTo(availableWidth, 0), + ); + }, + ); + it("Escape closes a popup opened inside the dialog before the dialog", async () => { const onClose = vi.fn(); await using screen = await render( diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index aadbcab79b129..72fb4db069216 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -25,6 +25,8 @@ import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { expandedImageKey } from "./ExpandedImagePreview"; import { registerThreadOverlay } from "./threadOverlayRetention"; +import { mediaReferenceFileName } from "@t3tools/client-runtime/media-reference"; +import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/mediaPreview"; interface ExpandedImageDialogProps { preview: ExpandedImagePreview; @@ -42,6 +44,16 @@ function ExpandedMediaFailure({ children }: { children: ReactNode }) { ); } +function itemMimeType(item: ExpandedImageItem): string | undefined { + if (item.mimeType) return item.mimeType; + const reference = item.actionsSource?.reference; + const fileName = reference && mediaReferenceFileName(reference); + if (fileName) { + return mediaMimeTypeFromExtension(fileName.slice(fileName.lastIndexOf("."))) ?? undefined; + } + return mediaMimeType(item.src ?? "") ?? undefined; +} + function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { const asset = item.actionsSource?.asset; const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null); @@ -222,7 +234,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ key={`${index}:${item.src}`} src={item.src} name={item.name} - mimeType={item.mimeType} + mimeType={itemMimeType(item)} onError={() => setFailedImageSrc(item.src)} /> )} diff --git a/apps/web/src/components/chat/MarkdownCodeBlock.tsx b/apps/web/src/components/chat/MarkdownCodeBlock.tsx index 500e6cd9d5a8b..430f2abce1cae 100644 --- a/apps/web/src/components/chat/MarkdownCodeBlock.tsx +++ b/apps/web/src/components/chat/MarkdownCodeBlock.tsx @@ -65,7 +65,7 @@ export function MarkdownCodeBlockFrame({ return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 7fd73739d9195..2fcaa596a5b6c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -859,7 +859,7 @@ describe("MessagesTimeline attachments", () => { onOpen={onOpen} /> ); - await using screen = await renderElement(preview()); + await using screen = await renderElement(
{preview()}
); const audio = screen.container.querySelector("audio")!; const duration = vi.spyOn(audio, "duration", "get").mockReturnValue(Infinity); const play = vi.spyOn(audio, "play").mockResolvedValue(); @@ -884,6 +884,10 @@ describe("MessagesTimeline attachments", () => { audio.dispatchEvent(new Event("durationchange")); }); expect(screen.text()).toContain("0:03 / 0:10"); + expect( + getComputedStyle(screen.container.querySelector("audio")!.previousElementSibling!) + .direction, + ).toBe("ltr"); expect(progress.style.width).toBe("30%"); await act(() => { duration.mockReturnValue(NaN); diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 9928530b97f2a..e70c32b064a3e 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -67,7 +67,14 @@ export const ModelListRow = memo(function ModelListRow(props: { badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-5xs" />
-
+
{ + const name = event.currentTarget; + name.title = name.scrollWidth > name.clientWidth ? getDisplayModelName(model) : ""; + }} + > {getDisplayModelName(model)} {/* The logo is decorative, so name the provider for screen readers. */} , {props.providerDisplayName} diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 742a7cf6b09fd..f040df764cda9 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -366,6 +366,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { docked?: boolean; /** Right at the end of the search text moves into the popup's effort column. */ onFocusEffort?: () => void; + /** Closes the list column, inside the popup's fixed height. */ + listFooter?: ReactNode; }) { const { searchInputRef, @@ -838,16 +840,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ref={pickerContentRef} className={cn( "relative flex w-screen max-w-90 flex-row overflow-hidden", - props.docked - ? "max-sm:h-56 max-sm:min-h-0! max-sm:w-full max-sm:max-w-none" - : "max-h-86.5", + props.docked ? "max-sm:h-56 max-sm:w-full max-sm:max-w-none" : "max-h-86.5", )} + // Docked, the popup sets the height; a minimum here would overflow short windows. style={ - isSearching - ? { height: searchHeight } - : { - minHeight: `min(calc(var(--spacing) * ${tallestTabUnits + LIST_CHROME_UNITS} + 1px), calc(var(--spacing) * 86.5))`, - } + props.docked + ? undefined + : isSearching + ? { height: searchHeight } + : { + minHeight: `min(calc(var(--spacing) * ${tallestTabUnits + LIST_CHROME_UNITS} + 1px), calc(var(--spacing) * 86.5))`, + } } data-model-picker-content="true" > @@ -1072,6 +1075,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onRequestClose={onRequestClose} onOpenProviderSetup={onOpenProviderSetup} /> + {props.listFooter}
diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index 6750298402942..56d93123d7d67 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -190,9 +190,11 @@ export function WorkspaceTitlebarControls(props: { {props.showMaximizeControl ? ( { return ( target instanceof Element && - target.closest("[data-model-picker-content], [data-model-picker-effort]") + target.closest( + "[data-model-picker-content], [data-model-picker-effort], [data-model-picker-option-menu]", + ) ); }; const preventBackgroundWheel = (event: WheelEvent) => { @@ -154,16 +156,22 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /** * Focuses the selected reasoning level, else the first level. With `rows: "any"` it falls back - * to the column's other options. False when nothing took focus. + * to the column's first enabled option. False when nothing took focus. */ const focusEffort = (rows: "levels" | "any") => { const column = popupRef.current?.querySelector("[data-model-picker-effort]"); - const first = (selector: string) => - column?.querySelector(`${selector}[aria-checked="true"]:not(:disabled)`) ?? - column?.querySelector(`${selector}:not(:disabled)`); + const level = + column?.querySelector( + '[data-effort-level][aria-checked="true"]:not(:disabled)', + ) ?? column?.querySelector("[data-effort-level]:not(:disabled)"); + // No aria-checked here: an On switch is checked but is not where focus belongs. const row = - first("[data-effort-level]") ?? (rows === "any" ? first("[data-effort-row]") : null); + level ?? + (rows === "any" + ? column?.querySelector("[data-effort-row]:not(:disabled)") + : null); row?.focus({ preventScroll: true }); + row?.scrollIntoView({ block: "nearest" }); return row != null; }; const openTarget = (target: "effort" | "permissions") => { @@ -230,7 +238,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const triggerText = props.triggerLabel ?? multipleLabel ?? triggerTitle; const triggerTextProps = { - className: "min-w-0 flex-1 overflow-hidden truncate", + className: cn( + "min-w-0 flex-1 overflow-hidden truncate", + // Long names such as "Fusion (Claude Opus 5.5 Medium + SWE-2 High)" would push the composer. + props.isComposerOwned && "max-w-56", + ), "data-chat-provider-model-picker-label": "true", } as const; @@ -262,7 +274,16 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { : (props.getModelDisabledReason?.(instanceId, model) ?? null) } onInstanceModelChange={handleInstanceModelChange} - {...(docked ? { docked, onFocusEffort: () => void focusEffort("any") } : {})} + {...(docked + ? { + docked, + onFocusEffort: () => void focusEffort("any"), + // Inside the fixed height, so switching to or from a ChatGPT plan never resizes it. + ...(props.selectedModels === undefined + ? { listFooter: } + : {}), + } + : {})} /> ); @@ -401,12 +422,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { > {props.effortColumn ? ( -
-
+
+ {/* One fixed height, so a model with more options never resizes the popup. Short + windows shrink it and keep the footer. */} +
{picker}
{ if (event.key !== "ArrowLeft" || event.altKey || event.ctrlKey || event.metaKey) return; @@ -424,7 +447,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ) : ( picker )} - {props.selectedModels === undefined ? ( + {props.selectedModels === undefined && !docked ? ( ) : null} diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 6ca50c824cb45..b703e5d05da97 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -79,7 +79,7 @@ export function RightPanelSurfaceContent(props: { readonly files: RightPanelFileProps; readonly sideChatParentPanel: SideChatParentPanel; }) { - const { surface, threadRef, visible } = props; + const { surface, threadRef, visible, onCloseSurface } = props; if (surface === null) return null; switch (surface.kind) { case "preview": @@ -176,7 +176,7 @@ export function RightPanelSurfaceContent(props: { parentRef={threadRef} threadId={surface.threadId} parentPanel={props.sideChatParentPanel} - onClose={() => props.onCloseSurface(surface)} + onClose={() => onCloseSurface(surface)} /> ); case "device": diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 4c0af2d3472c0..5342bd09b3ca9 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -191,7 +191,14 @@ export function backgroundWorkNowItem(input: { icon: rows.every(isCommand) ? : , title: only ? ( <> - {verb} {only.task.label} + {verb}{" "} + + {only.task.label} + ) : ( presentation.title diff --git a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx index ae13f519e02fd..9d31ec35761e9 100644 --- a/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx +++ b/apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx @@ -10,6 +10,7 @@ import { AgentElapsed } from "./AgentElapsed"; import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { ThreadHoverCardPopup } from "../ThreadHoverCard"; import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon"; +import type { ProviderInstanceEntry } from "../../providerInstances"; import { cn } from "../../lib/utils"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; @@ -52,6 +53,7 @@ function WorkflowMemberRow({ providerInstanceId, provider, providers, + providerEntries, driver, onOpen, }: { @@ -59,6 +61,7 @@ function WorkflowMemberRow({ providerInstanceId: ProviderInstanceId; provider: ServerProvider | undefined; providers: ReadonlyArray | undefined; + providerEntries: ReadonlyArray; driver: ProviderDriverKind | undefined; onOpen: (threadId: string) => void; }) { @@ -78,7 +81,12 @@ function WorkflowMemberRow({ /> } > - + {member.title} {member.status} {member.startedAt ? ( @@ -113,6 +121,7 @@ export function ThreadLineageWorkflowRow({ providerInstanceId, provider, providers, + providerEntries, driver, onOpenThread, onStop, @@ -124,6 +133,7 @@ export function ThreadLineageWorkflowRow({ readonly providerInstanceId: ProviderInstanceId; readonly provider: ServerProvider | undefined; readonly providers: ReadonlyArray | undefined; + readonly providerEntries: ReadonlyArray; readonly driver: ProviderDriverKind | undefined; readonly onOpenThread: (threadId: string) => void; readonly onStop?: (() => void) | undefined; @@ -221,6 +231,7 @@ export function ThreadLineageWorkflowRow({ providerInstanceId={providerInstanceId} provider={provider} providers={providers} + providerEntries={providerEntries} driver={driver} onOpen={onOpenThread} /> @@ -237,6 +248,7 @@ export function ThreadLineageWorkflowRow({ providerInstanceId={providerInstanceId} provider={provider} providers={providers} + providerEntries={providerEntries} driver={driver} onOpen={onOpenThread} /> diff --git a/apps/web/src/components/chat/ThreadRelationshipIcon.tsx b/apps/web/src/components/chat/ThreadRelationshipIcon.tsx index 6b6b322159335..c007ac03c2c44 100644 --- a/apps/web/src/components/chat/ThreadRelationshipIcon.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipIcon.tsx @@ -1,6 +1,7 @@ import type { ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; import { BotIcon, type LucideIcon } from "lucide-react"; import { cn } from "../../lib/utils"; +import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; export function threadRelationshipStatusLabel(status: string | null): string { @@ -45,22 +46,32 @@ export function threadRelationshipStatusLabel(status: string | null): string { export function ThreadRelationshipIcon({ driver, provider, + providerEntries = [], status, fallbackIcon: FallbackIcon = BotIcon, }: { driver?: ProviderDriverKind | undefined; provider?: ServerProvider | undefined; + providerEntries?: ReadonlyArray; status: string | null; fallbackIcon?: LucideIcon; }) { const iconClassName = "size-4 shrink-0 text-muted-foreground"; + const entry = driver + ? providerEntries.find((candidate) => candidate.instanceId === provider?.instanceId) + : undefined; + const showInstanceBadge = entry !== undefined && shouldShowInstanceBadge(entry, providerEntries); return ( {driver ? ( @@ -69,7 +80,8 @@ export function ThreadRelationshipIcon({ )} (null); // Derive graphs after hooks to preserve their compiler cache. const providers = providersByEnvironment.get(props.environmentId); + const derivedProviderEntries = deriveProviderInstanceEntries(providers ?? []); + const providerEntries = settings + ? applyProviderInstanceSettings( + derivedProviderEntries, + settings, + currentThread?.projectId ?? null, + ) + : derivedProviderEntries; const projects = allProjects.filter((project) => project.environmentId === props.environmentId); const parentNativeStatus = parentProjection ? deriveProviderSubagentStatus(parentProjection)?.status @@ -673,6 +687,7 @@ export function ThreadRelationshipsPanel(props: { @@ -729,6 +744,7 @@ export function ThreadRelationshipsPanel(props: { providerInstanceId={agent.providerInstanceId} provider={provider} providers={providers} + providerEntries={providerEntries} driver={providerDriver} onOpenThread={(memberThreadId) => openThread(memberThreadId as ThreadId)} onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined} diff --git a/apps/web/src/components/chat/TimelineMinimap.tsx b/apps/web/src/components/chat/TimelineMinimap.tsx index 4e87edfe59495..742657ba68c0d 100644 --- a/apps/web/src/components/chat/TimelineMinimap.tsx +++ b/apps/web/src/components/chat/TimelineMinimap.tsx @@ -217,7 +217,7 @@ export function TimelineMinimap({
{fileTreeOpen ? ( - + ) : null}
{unstructured} diff --git a/apps/web/src/components/pullRequest/PullRequestStackMenu.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestStackMenu.browser.test.tsx new file mode 100644 index 0000000000000..790cf493e82f4 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackMenu.browser.test.tsx @@ -0,0 +1,75 @@ +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +const state = vi.hoisted(() => ({ detail: "" })); + +vi.mock(import("~/state/use-atom-command"), async () => { + const { PullRequestOperationError } = await import("@t3tools/contracts"); + const { Cause } = await import("effect"); + const { AsyncResult } = await import("effect/reactivity"); + return { + useAtomCommand: (() => async () => + AsyncResult.failure( + Cause.fail(new PullRequestOperationError({ operation: "runAction", detail: state.detail })), + )) as never, + }; +}); + +import "~/index.css"; +import { render } from "../../../test/render"; +import { ToastProvider } from "../ui/toast"; +import { PullRequestStackMenu } from "./PullRequestStackMenu"; + +function setup(detail: string) { + state.detail = detail; +} + +const HEAD_MOVED = "The stack head moved. Refresh the stack before trying again."; + +it.each([ + { label: "Merge stack", detail: HEAD_MOVED, description: HEAD_MOVED }, + { label: "Rebase stack", detail: HEAD_MOVED, description: HEAD_MOVED }, + { + label: "Rebase stack", + detail: "GitHub CLI command failed.", + description: + "The host refused the rebase. A rebase stops at the first commit that does not apply cleanly.", + }, +])("$label refused with $detail shows $description", async (row) => { + setup(row.detail); + await using screen = await render( + + {}} + /> + , + ); + if (row.label === "Rebase stack") { + await screen.getByRole("button", { name: "Stack 1, layer 1 of 1" }).click(); + await page.getByRole("menuitem", { name: row.label }).click(); + } else { + await screen.getByRole("button", { name: row.label, exact: true }).click(); + } + await page.getByRole("dialog").getByRole("button", { name: row.label, exact: true }).click(); + await expect.element(page.getByText(row.description, { exact: true })).toBeInTheDocument(); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx index 359fdb47d71e8..c870eebdd5855 100644 --- a/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx +++ b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx @@ -27,6 +27,7 @@ import { PullRequestStackLayers } from "./PullRequestStackLayers"; import { PullRequestStackHeader } from "./PullRequestStackHeader"; import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent"; import { PullRequestGlyph } from "./pullRequestIcons"; +import { readableFailure } from "./pullRequestDetail.logic"; export function PullRequestStackMenu({ hostLabel, @@ -114,7 +115,12 @@ export function PullRequestStackMenu({ toastManager.update(toastId, { type: "error", title: action === "merge" ? "Could not merge the stack" : "Could not rebase the stack", - description: String(squashAtomCommandFailure(result)), + description: readableFailure( + squashAtomCommandFailure(result), + action === "merge" + ? "The host refused the merge. Check that you have write access, that the checks it requires have passed, and that the branch is not conflicting." + : "The host refused the rebase. A rebase stops at the first commit that does not apply cleanly.", + ), }); } else { toastManager.update(toastId, { diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts index a6e20a6c8c835..899aff44f1934 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts @@ -118,4 +118,24 @@ describe("orderDiffFiles", () => { "src/orphan.spec.ts", ]); }); + + it("pairs a test with the source whose name carries its own dotted suffix", () => { + expect( + order([ + file("src/c.browser.test.tsx"), + file("src/b.test.ts"), + file("src/a.logic.test.ts"), + file("src/c.tsx"), + file("src/b.ts"), + file("src/a.logic.ts"), + ]), + ).toEqual([ + "src/a.logic.ts", + "src/b.ts", + "src/c.tsx", + "src/a.logic.test.ts", + "src/b.test.ts", + "src/c.browser.test.tsx", + ]); + }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts index 738b372585a65..737206d96e508 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts @@ -117,8 +117,17 @@ function orderByImports( return ordered; } -function testedBaseName(path: string): string { - return (path.split("/").at(-1) ?? "").replace(/\.(?:test|spec)\..*$/, "").replace(/\.[^.]+$/, ""); +// `a.logic.test.ts` covers `a.logic.ts` while `a.browser.test.ts` covers `a.ts`, so the longest +// dotted prefix that names a source file wins. +function testedSourcePosition(path: string, sourcePositions: ReadonlyMap): number { + let name = (path.split("/").at(-1) ?? "").replace(/\.(?:test|spec)\..*$/, ""); + while (true) { + const position = sourcePositions.get(name); + if (position !== undefined) return position; + const dot = name.lastIndexOf("."); + if (dot <= 0) return Number.MAX_SAFE_INTEGER; + name = name.slice(0, dot); + } } export function orderDiffFiles( @@ -159,9 +168,10 @@ export function orderDiffFiles( const orderedTests = [...byPath.keys()] .filter((path) => tiers.get(path) === "test") .sort((left, right) => { - const leftPosition = sourcePositions.get(testedBaseName(left)) ?? Number.MAX_SAFE_INTEGER; - const rightPosition = sourcePositions.get(testedBaseName(right)) ?? Number.MAX_SAFE_INTEGER; - return leftPosition - rightPosition || left.localeCompare(right); + return ( + testedSourcePosition(left, sourcePositions) - + testedSourcePosition(right, sourcePositions) || left.localeCompare(right) + ); }); const orderedGenerated = [...byPath.keys()] diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx index e10ba0efe98f0..30f104b1d5b67 100644 --- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx +++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx @@ -19,6 +19,7 @@ import { useEnvironmentStageLabel, } from "../SidebarStageBackdrop"; import { useCustomThemes } from "../../hooks/useCustomThemes"; +import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { useTheme } from "../../hooks/useTheme"; import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -107,6 +108,20 @@ export function AppearanceSettingsPanel() { environmentIdentificationOptionList.find( ([value]) => value === settings.environmentIdentificationMode, )?.[1] ?? ENVIRONMENT_IDENTIFICATION_LABELS[settings.environmentIdentificationMode]; + const effectiveEnvironmentIdentificationMode = useEnvironmentIdentificationMode(); + const themeHidesEnvironmentArtwork = + effectiveEnvironmentIdentificationMode === "pill" && + resolveSidebarStageBackdropVariant( + environmentStageLabel, + settings.environmentIdentificationMode, + ) !== null; + const environmentIdentificationDescription = themeHidesEnvironmentArtwork + ? environmentStageVariant + ? "Only built-in themes show artwork, so this theme shows the version pill." + : "Only built-in themes show artwork." + : environmentStageVariant + ? "Choose how Dev and Nightly environments are identified." + : "Show Dev or Nightly artwork in the sidebar header."; const glassOpacityRatio = (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); const glassOpacitySliderStyle = { @@ -398,11 +413,7 @@ export function AppearanceSettingsPanel() { /> ({ + ...(await original()), + useEnvironmentScope: () => true, + useEnvironmentsWithScope: ((environments: ReadonlyArray<{ environmentId: string }>) => + new Set(environments.map(({ environmentId }) => environmentId))) as never, + readEnvironmentScope: () => true, +})); +vi.mock(import("../../state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: (() => vi.fn()) as never, +})); vi.mock(import("./SettingsScopeContext"), async (original) => ({ ...(await original()), useSettingsScope: (() => { @@ -22,16 +33,78 @@ vi.mock(import("./SettingsScopeContext"), async (original) => ({ }; }) as never, })); -vi.mock(import("../../state/session"), async (original) => ({ - ...(await original()), - useEnvironmentScope: () => true, - useEnvironmentsWithScope: ((environments: ReadonlyArray<{ environmentId: string }>) => - new Set(environments.map((environment) => environment.environmentId))) as never, - readEnvironmentScope: () => true, -})); +import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; import { KeybindingsSettingsPanel } from "./KeybindingsSettings"; +// The modal dialog's inert layer swallows pointer events in this harness, so drive the DOM. +async function activate(locator: Locator, action: "click" | "focus") { + await expect.element(locator).toBeVisible(); + (locator.element() as HTMLElement)[action](); +} + +const existingField = page.getByRole("textbox", { name: "Keybinding for Sidebar: Toggle" }); +const existingPill = page.getByRole("button", { name: /^Edit shortcut for Sidebar: Toggle:/ }); +const newField = page.getByRole("textbox", { name: "Keybinding for new keybinding" }); + +describe("Esc in a shortcut recorder", () => { + it.each<{ + name: string; + field: Locator; + open: () => Promise; + expectReset: () => Promise; + }>([ + { + name: "existing binding", + field: existingField, + open: () => activate(existingPill, "click"), + expectReset: async () => { + await expect.element(existingField).not.toBeInTheDocument(); + await expect.element(existingPill).toHaveFocus(); + await expect.element(page.getByRole("button", { name: "Save" })).not.toBeInTheDocument(); + }, + }, + { + name: "new binding", + field: newField, + open: async () => { + await activate(page.getByRole("button", { name: "Add keybinding" }), "click"); + await activate(newField, "focus"); + }, + expectReset: async () => { + await expect.element(newField).toHaveValue(""); + await expect.element(newField).toHaveAttribute("placeholder", "Unassigned"); + }, + }, + ])("leaves the $name field without closing Settings", async ({ field, open, expectReset }) => { + const onOpenChange = vi.fn(); + await using _screen = await render( + + + Settings + + + , + ); + + await open(); + await userEvent.keyboard("{Control>}k{/Control}"); + await expect.element(field).not.toHaveValue(""); + await userEvent.tab(); + await activate(field, "focus"); + await expect.element(field).toHaveAttribute("placeholder", "Press shortcut"); + + await userEvent.keyboard("{Escape}"); + + expect(onOpenChange).not.toHaveBeenCalled(); + expect(document.activeElement?.matches("[data-keybinding-capture]")).toBe(false); + await expectReset(); + + await userEvent.keyboard("{Escape}"); + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); +}); + it("re-enables Save when a When editor closes on an expression it could not parse", async () => { await using screen = await render( diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index f6298d778073e..cb00bca7e9cae 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -20,6 +20,7 @@ import { useRef, useState, } from "react"; +import { flushSync } from "react-dom"; import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, @@ -717,6 +718,7 @@ function useKeybindingRowEditor({ onSave: (input: ServerUpsertKeybindingInput) => void; }) { const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft); + const pillRef = useRef(null); const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; const whenDraftExpression = whenAstToExpression(whenDraft); const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when; @@ -739,7 +741,13 @@ function useKeybindingRowEditor({ if (event.key === "Tab" && !isRecording) return; event.preventDefault(); if (event.key === "Escape") { - setDraft({ keyDraft: row.key, isRecording: false }); + // An unhandled Escape closes the settings page. + event.stopPropagation(); + const field = event.currentTarget; + flushSync(() => setDraft({ keyDraft: row.key, isRecording: false })); + // The field may unmount for the pill; focus would otherwise drop to the page. + if (pillRef.current) pillRef.current.focus(); + else field.blur(); return; } const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform, { @@ -760,6 +768,7 @@ function useKeybindingRowEditor({ setDraft, save, captureKeybinding, + pillRef, }; } @@ -790,8 +799,16 @@ function KeybindingKeyControl({ isSaving: boolean; pillClassName?: string | undefined; }) { - const { keyDraft, isRecording, isDirty, isWhenDraftValid, setDraft, save, captureKeybinding } = - editor; + const { + keyDraft, + isRecording, + isDirty, + isWhenDraftValid, + setDraft, + save, + captureKeybinding, + pillRef, + } = editor; const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; return ( @@ -807,6 +824,7 @@ function KeybindingKeyControl({ ) : null} {showPill ? (
- {props.jumpLabel ? : null} {detailsTooltip} diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 47ae92c241e15..1c199afe446f5 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -25,6 +25,7 @@ function MenuPopup({ align = "center", alignOffset, side = "bottom", + collisionAvoidance, anchor, keepMounted = false, width = "default", @@ -34,6 +35,7 @@ function MenuPopup({ sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"]; alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"]; side?: MenuPrimitive.Positioner.Props["side"]; + collisionAvoidance?: MenuPrimitive.Positioner.Props["collisionAvoidance"]; anchor?: MenuPrimitive.Positioner.Props["anchor"]; keepMounted?: boolean; width?: "default" | "content"; @@ -44,6 +46,7 @@ function MenuPopup({ align={align} alignOffset={alignOffset} anchor={anchor} + collisionAvoidance={collisionAvoidance} className="z-[130]" data-slot="menu-positioner" render={renderClosingPositioner} diff --git a/apps/web/src/composer-list-split.browser.test.ts b/apps/web/src/composer-list-split.browser.test.ts index a1d09bccab5e3..632bb1ff4d3ef 100644 --- a/apps/web/src/composer-list-split.browser.test.ts +++ b/apps/web/src/composer-list-split.browser.test.ts @@ -68,6 +68,25 @@ describe("Backspace at the start of a line", () => { editor.commands.keyboardShortcut("Backspace"); expect(storedDraft(editor)).toBe(expected); }); + + // Neither item has a list of its own kind one level up to lift into. + it.each([ + ["- a\n - [ ] x\n - [ ] y", "- a\n - [ ] xy"], + ["- [ ] a\n - x\n - y", "- [ ] a\n - xy"], + ["- a\n - [ ] y\n - [ ] q", "- ay\n - [ ] q"], + ])("joins y in %j onto the line above", (value, expected) => { + using editor = makeEditor(value); + placeCaret(editor, "y"); + editor.commands.keyboardShortcut("Backspace"); + expect(storedDraft(editor)).toBe(expected); + }); + + it("removes an empty checkbox under a bullet", () => { + using editor = makeEditor("- a\n - [ ] x\n - [ ]"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + editor.commands.keyboardShortcut("Backspace"); + expect(storedDraft(editor)).toBe("- a\n - [ ] x"); + }); }); describe("blocks that a list item or quote cannot hold", () => { diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 3ad569e4b8a40..ab7f2c542a19f 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -20,7 +20,10 @@ import { ComposerBlockExtensions, ComposerCodeBlockExtension, ComposerListExtensions, + ComposerBoldExtension, ComposerCodeExtension, + ComposerItalicExtension, + ComposerStrikeExtension, ComposerTaskItemExtension, flatToMarkdown, flatToPm, @@ -1003,7 +1006,17 @@ describe("inline code entry", () => { const editor = new Editor({ element: null, extensions: [ - StarterKit.configure({ code: false, codeBlock: false, trailingNode: false }), + StarterKit.configure({ + bold: false, + code: false, + codeBlock: false, + italic: false, + strike: false, + trailingNode: false, + }), + ComposerBoldExtension, + ComposerItalicExtension, + ComposerStrikeExtension, ComposerCodeExtension, ComposerCodeBlockExtension, stubAtom("composer-mention", { path: { default: "" }, source: { default: "" } }), @@ -1028,6 +1041,56 @@ describe("inline code entry", () => { } } + it.each(["", "this is a "])( + "formats code after %j when the opening backtick is typed last", + (prefix) => { + const editor = editorFor(); + type(editor, `${prefix}test\` after`); + editor.commands.setTextSelection(prefix.length + 1); + type(editor, "`"); + + expect(editor.state.doc.textContent).toBe(`${prefix}test after`); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}\`test\` after`); + expect(editor.state.selection.from).toBe(prefix.length + 1); + type(editor, "new "); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}\`new test\` after`); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + type(editor, " plain"); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${prefix}\`new test\` after plain`); + const formatted = editor.state.doc.toJSON(); + let undos = 0; + while (editor.commands.undo()) undos += 1; + expect(undos).toBeGreaterThan(0); + expect(editor.state.doc.textContent).toBe(""); + for (let i = 0; i < undos; i += 1) expect(editor.commands.redo()).toBe(true); + expect(editor.state.doc.toJSON()).toEqual(formatted); + }, + ); + + it.each([ + { name: "doubled closing ticks", text: "test``" }, + { name: "a code block", text: "test`", block: "codeBlock" }, + { name: "bold text", text: "test`", mark: "bold" }, + { name: "existing inline code", text: "test`", mark: "code" }, + ])("keeps an opening tick literal before $name", ({ text, block, mark }) => { + const editor = editorFor(block, mark); + type(editor, text); + editor.commands.setTextSelection(1); + type(editor, "`"); + expect(editor.state.doc.textContent).toBe(`\`${text}`); + }); + + it.each(["hardBreak", "composer-mention"])("does not pair ticks across a %s", (nodeType) => { + const editor = editorFor(); + type(editor, "before"); + editor.commands.insertContent({ type: nodeType }); + type(editor, "after`"); + editor.commands.setTextSelection(1); + type(editor, "`"); + expect(editor.state.doc.firstChild?.firstChild?.marks).toEqual([]); + expect(editor.state.doc.textContent).toBe("`beforeafter`"); + }); + it.each(["", "prefix ", "@README.md"])( "formats a filled pair after %j, leaves following text plain, and keeps the normal input rule", (prefix) => { @@ -1115,4 +1178,70 @@ describe("inline code entry", () => { editor.commands.setTextSelection(editor.state.doc.content.size - 1); expect(editor.state.doc.toJSON()).toEqual(before); }); + + function marked(editor: Editor, name: string) { + const text: string[] = []; + editor.state.doc.descendants((node) => { + if (node.isText && node.marks.some((mark) => mark.type.name === name)) { + text.push(node.text ?? ""); + } + }); + return text; + } + + /** Types `text` into a backtick pair and steps the caret out so it formats. */ + function typeCode(editor: Editor, text: string) { + const offset = editor.state.selection.from; + type(editor, "``"); + editor.commands.setTextSelection(offset + 1); + type(editor, text); + editor.commands.setTextSelection(offset + text.length + 2); + expect(marked(editor, "code")).toContain(text); + } + + it.each([ + { mark: "italic", open: "*" }, + { mark: "bold", open: "**" }, + { mark: "strike", open: "~~" }, + ])("keeps a $mark delimiter inside an earlier code span literal", ({ mark, open }) => { + const editor = editorFor(); + typeCode(editor, `${open}foo`); + type(editor, ` bar \`${open}`); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`\`${open}foo\` bar \`${open}`); + expect(marked(editor, mark)).toEqual([]); + expect(marked(editor, "code")).toEqual([`${open}foo`]); + }); + + it.each([ + { mark: "italic", open: "*" }, + { mark: "bold", open: "**" }, + { mark: "strike", open: "~~" }, + ])("still wraps a code span in $mark when both delimiters are outside it", ({ mark, open }) => { + const editor = editorFor(); + type(editor, open); + typeCode(editor, "foo"); + type(editor, ` bar${open}`); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${open}\`foo\` bar${open}`); + expect(marked(editor, mark)).toEqual(["foo", " bar"]); + + editor.commands.setContent({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: `${open}foo tail ` }, + { type: "text", text: "x", marks: [{ type: "code" }] }, + ], + }, + ], + }); + editor.commands.setTextSelection({ + from: open.length + 4, + to: editor.state.doc.content.size - 1, + }); + type(editor, open); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${open}foo${open}`); + expect(marked(editor, mark)).toEqual(["foo"]); + }); }); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index c0e0a653016e3..7025b687850da 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,4 +1,4 @@ -import { joinBackward, joinTextblockBackward, joinTextblockForward } from "@tiptap/pm/commands"; +import { joinTextblockBackward, joinTextblockForward } from "@tiptap/pm/commands"; import { Fragment, Mark, @@ -6,6 +6,11 @@ import { type Node as ProseMirrorNode, type ResolvedPos, } from "@tiptap/pm/model"; +import { + Bold, + starInputRegex as boldStarInputRegex, + underscoreInputRegex as boldUnderscoreInputRegex, +} from "@tiptap/extension-bold"; import { Code } from "@tiptap/extension-code"; import { Blockquote } from "@tiptap/extension-blockquote"; import { CodeBlock } from "@tiptap/extension-code-block"; @@ -16,11 +21,13 @@ import { type Command, type Editor, Extension, + InputRule, markInputRule, markPasteRule, mergeAttributes, } from "@tiptap/core"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; +import { Strike, inputRegex as strikeInputRegex } from "@tiptap/extension-strike"; import { TaskItem } from "@tiptap/extension-task-item"; import { TaskList } from "@tiptap/extension-task-list"; import { @@ -81,14 +88,37 @@ function codePairCaretPlugin(type: MarkType) { handleTextInput(view, from, to, text, insert) { if (view.composing || from !== to || text !== "`") return false; const $from = view.state.doc.resolve(from); - if ( - !$from.parent.isTextblock || - $from.parent.type.spec.code || - !/(^|[^`])`$/.test($from.nodeBefore?.text ?? "") - ) { - return false; + if (!$from.parent.isTextblock || $from.parent.type.spec.code) return false; + const before = $from.nodeBefore?.text ?? ""; + if (/(^|[^`])`$/.test(before)) { + view.dispatch(insert().setMeta(codePairKey, { from: from - 1, to: from + 1 })); + return true; } - view.dispatch(insert().setMeta(codePairKey, { from: from - 1, to: from + 1 })); + if (before.endsWith("`")) return false; + + // The opening tick can be typed after the closing one. Input rules + // cannot recognize that pair because they only read before the caret. + const after = $from.parent.textBetween( + $from.parentOffset, + $from.parent.content.size, + "\n", + "\n", + ); + const match = /^([^`\n]+)`(?!`)/.exec(after); + if (!match) return false; + const end = from + match[1]!.length; + let plain = true; + view.state.doc.nodesBetween(from, end + 1, (node) => { + if (node.isInline && (!node.isText || node.marks.length > 0)) plain = false; + }); + if (!plain) return false; + const tr = insert() + .delete(end + 1, end + 2) + .delete(from, from + 1); + tr.addMark(from, end, type.create()); + tr.setSelection(TextSelection.create(tr.doc, from)); + tr.addStoredMark(type.create()); + view.dispatch(tr); return true; }, }, @@ -140,6 +170,29 @@ export const ComposerCodeExtension = Code.extend({ }, }); +// Code permits nested emphasis, so Tiptap's exclusion check cannot protect literal delimiters. +function markInputRuleOutsideCode(find: RegExp, type: MarkType) { + const rule = markInputRule({ find, type }); + return new InputRule({ + find, + handler: (props) => { + const { state, range, match } = props; + const code = state.schema.marks.code; + const content = match[match.length - 1] ?? ""; + const textStart = range.from + match[0].indexOf(content); + const textEnd = textStart + content.length; + if ( + code && + (state.doc.rangeHasMark(range.from + match[0].search(/\S/), textStart, code) || + state.doc.rangeHasMark(textEnd, state.selection.from, code)) + ) { + return null; + } + return rule.handler(props); + }, + }); +} + /** * The composer writes italic as `*text*`, so it keeps only the `*` shortcuts. * Tiptap's `_text_` rules fire on the `_` in names like `node_modules` and @@ -147,13 +200,28 @@ export const ComposerCodeExtension = Code.extend({ */ export const ComposerItalicExtension = Italic.extend({ addInputRules() { - return [markInputRule({ find: starInputRegex, type: this.type })]; + return [markInputRuleOutsideCode(starInputRegex, this.type)]; }, addPasteRules() { return [markPasteRule({ find: starPasteRegex, type: this.type })]; }, }); +export const ComposerBoldExtension = Bold.extend({ + addInputRules() { + return [ + markInputRuleOutsideCode(boldStarInputRegex, this.type), + markInputRuleOutsideCode(boldUnderscoreInputRegex, this.type), + ]; + }, +}); + +export const ComposerStrikeExtension = Strike.extend({ + addInputRules() { + return [markInputRuleOutsideCode(strikeInputRegex, this.type)]; + }, +}); + /** * Mod+B bolds selected text only. Without a selection Tiptap would toggle bold * for the next typed text, which users hit by accident when they mean the @@ -1135,7 +1203,8 @@ function backspaceAcrossList(editor: Editor): boolean { return moved; }; // can() skips the schema check and can accept tasks with no parent list to lift into. - return editor.chain().command(lift).run() || joinBackward(state, editor.view.dispatch); + // Those join the line above; joinBackward would re-nest them or select the parent line. + return editor.chain().command(lift).run() || joinTextblockBackward(state, editor.view.dispatch); } const before = $from.depth === 1 ? $from.node(0).maybeChild($from.index(0) - 1) : null; if (before && LIST_NODE_NAMES.has(before.type.name) && $from.parent.type.name !== "codeBlock") { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 08faddc903708..4e6dfe04a7ebb 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -53,7 +53,11 @@ import type { import { waitForDraftHeroTransition } from "./components/chat/draftHeroTransition"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { readThreadPreviewState } from "./previewStateStore"; -import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; +import { + readInstanceCustomModels, + resolveAppModelSelection, + resolveAppModelSelectionForInstance, +} from "./modelSelection"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -1128,8 +1132,8 @@ export function deriveEffectiveComposerModelState(input: { const instanceSelection = input.selectedInstanceId ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId] : undefined; + // Legacy driver-keyed drafts belong to the default instance. const legacySelection = - input.selectedProvider === "antigravity" && input.selectedInstanceId && input.selectedInstanceId !== defaultInstanceIdForDriver(input.selectedProvider) ? undefined @@ -1188,6 +1192,7 @@ function withFallbackInstanceOptions(input: { readonly selectedProvider: ProviderDriverKind; readonly selectedInstanceId?: ProviderInstanceId | null | undefined; readonly requestedInstanceId?: ProviderInstanceId | null | undefined; + readonly settings: UnifiedSettings; }): ProviderOptionSelectionsByProvider | null { const { modelOptions, selectedInstanceId, requestedInstanceId } = input; if ( @@ -1200,16 +1205,26 @@ function withFallbackInstanceOptions(input: { return modelOptions; } const requestedOptions = modelOptions[requestedInstanceId]; - const requestedDriver = input.providers.find( + const requestedProvider = input.providers.find( (provider) => provider.instanceId === requestedInstanceId, - )?.driver; - // The model of the selection the options came from, which can differ from the shown model's source. + ); const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; - if ( - !requestedOptions || - requestedDriver !== input.selectedProvider || - requestedModel !== input.selectedModel - ) { + // Static aliases are shared across instances; exact custom slugs and catalog aliases are not. + const modelIdentity = (instanceId: ProviderInstanceId, model: string | undefined) => { + const isCustom = + input.providers + .find((provider) => provider.instanceId === instanceId) + ?.models.some((entry) => entry.slug === model && entry.isCustom) || + readInstanceCustomModels(input.settings, instanceId, input.selectedProvider).some( + (entry) => entry.slug === model, + ); + return isCustom ? model : normalizeModelSlug(model, input.selectedProvider); + }; + const sameModel = + requestedModel === input.selectedModel || + modelIdentity(requestedInstanceId, requestedModel) === + modelIdentity(selectedInstanceId, input.selectedModel); + if (!requestedOptions || requestedProvider?.driver !== input.selectedProvider || !sameModel) { return modelOptions; } return { ...modelOptions, [selectedInstanceId]: requestedOptions }; diff --git a/apps/web/src/hooks/useResizableWidth.browser.test.tsx b/apps/web/src/hooks/useResizableWidth.browser.test.tsx new file mode 100644 index 0000000000000..a34c985f8c128 --- /dev/null +++ b/apps/web/src/hooks/useResizableWidth.browser.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../test/render"; +import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth"; + +const STORAGE_KEY = "test:responsive-panel-width"; + +function Panel({ defaultWidth }: { defaultWidth: number }) { + const { width, setWidth, handlers } = useResizableWidth({ + storageKey: STORAGE_KEY, + defaultWidth, + minWidth: 160, + maxWidth: 560, + edge: "left", + }); + return ( + <> + +
+ + ); +} + +describe("useResizableWidth", () => { + it("follows a responsive default until the user saves a width", async () => { + const previous = localStorage.getItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + try { + // This hook only needs browser storage and React; no navigation is involved. + await using screen = await render(, { router: false }); + expect(screen.container.textContent).toBe("320"); + await screen.rerender(); + expect(screen.container.textContent).toBe("200"); + + await act(() => screen.container.querySelector("button")!.click()); + await screen.rerender(); + expect(screen.container.textContent).toBe("350"); + + await screen.unmount(); + await using restored = await render(, { router: false }); + expect(restored.container.textContent).toBe("350"); + } finally { + if (previous === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, previous); + } + }); + + it("keeps the default responsive after a stationary click and persists an actual drag", async () => { + const previous = localStorage.getItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + try { + // This hook only needs browser storage and React; no navigation is involved. + await using screen = await render(, { router: false }); + await screen.getByRole("separator").click(); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + await screen.rerender(); + expect(screen.container.textContent).toBe("256"); + + const handle = screen.container.querySelector('[role="separator"]')!; + // Synthetic drag events have no active browser pointer to capture. + using _capture = vi.spyOn(handle, "setPointerCapture").mockImplementation(() => {}); + const pointer = { bubbles: true, pointerId: 1, button: 0 }; + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientX: 500 })); + }); + await screen.rerender(); + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientX: 500 })); + }); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(screen.container.style.getPropertyValue(RESIZABLE_WIDTH_PROPERTY)).toBe("280px"); + + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientX: 500 })); + handle.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientX: 450 })); + }); + expect(localStorage.getItem(STORAGE_KEY)).toBe("330"); + await screen.rerender(); + expect(screen.container.textContent).toBe("330"); + } finally { + if (previous === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, previous); + } + }); +}); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 50fdba38a9fc5..dc5fe8569d03e 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -3,6 +3,7 @@ import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useState } fro import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; import { useResizeDrag } from "./useResizeDrag"; +import { useStableCallback } from "./useStableCallback"; const WidthSchema = Schema.Finite; @@ -26,7 +27,7 @@ export interface ResizableWidthHandlers { readonly onLostPointerCapture: (event: ReactPointerEvent) => void; } -/** Persist on drag end to avoid a storage write on every animation frame. */ +/** Uses the current default until a width is chosen; persists on drag end, not every frame. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; readonly setWidth: (width: number) => void; @@ -39,14 +40,14 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return Math.max(minWidth, Math.min(maxWidth, value)); }; - const readWidth = () => { - if (typeof window === "undefined") return defaultWidth; + const readWidth = (): number | null => { + if (typeof window === "undefined") return null; try { const stored = getLocalStorageItem(storageKey, WidthSchema); - return clamp(stored ?? defaultWidth); + return stored === null ? null : clamp(stored); } catch (error) { console.error("Could not read persisted panel width.", error); - return defaultWidth; + return null; } }; const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() })); @@ -54,7 +55,10 @@ export function useResizableWidth(options: UseResizableWidthOptions): { setWidthState({ storageKey, width: readWidth() }); } - const clampedWidth = clamp(widthState.width); + const clampedWidth = clamp(widthState.width ?? defaultWidth); + const restoreWidth = useStableCallback((host: HTMLElement | null) => { + host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${clampedWidth}px`); + }); const commitWidth = (nextStorageKey: string, nextWidth: number) => { // Resizing remains usable for this session when browser storage is unavailable. @@ -82,8 +86,9 @@ export function useResizableWidth(options: UseResizableWidthOptions): { host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`); return nextWidth; }, - finish(finalWidth) { - commitWidth(startStorageKey, finalWidth); + finish(finalWidth, moved) { + if (moved) commitWidth(startStorageKey, finalWidth); + else restoreWidth(host); }, cleanup(committed) { host?.style.removeProperty("transition-property"); diff --git a/apps/web/src/hooks/useThreadVisitedMigration.test.ts b/apps/web/src/hooks/useThreadVisitedMigration.test.ts new file mode 100644 index 0000000000000..e0bd84855cc33 --- /dev/null +++ b/apps/web/src/hooks/useThreadVisitedMigration.test.ts @@ -0,0 +1,103 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId } from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { environmentThreadShells } from "../state/threads"; +import { makeThreadFixture } from "../test-fixtures"; +import { startThreadVisitedMigration } from "./useThreadVisitedMigration"; + +const state = vi.hoisted(() => ({ + localVisits: {} as Record, +})); + +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ runAtomCommand: vi.fn() })); +vi.mock("../uiStateStore", () => ({ + useUiStateStore: { getState: () => ({ threadLastVisitedAtById: state.localVisits }) }, +})); +vi.mock("../state/threads", async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentThreadShells: { + threadShellsAtom: Atom.make>([]), + }, + threadEnvironment: { visit: {} }, + }; +}); + +const localVisit = "2026-10-27T10:00:00.000Z"; + +function setup(threads: ReadonlyArray, local = localVisit) { + state.localVisits = Object.fromEntries( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + local, + ]), + ); + vi.mocked(runAtomCommand).mockReset().mockResolvedValue(AsyncResult.success(undefined)); + const tasks: Array<() => void> = []; + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + tasks.push(task); + return () => {}; + }, + }); + const shells = environmentThreadShells.threadShellsAtom; + if (!Atom.isWritable(shells)) throw new Error("Expected writable test shell state"); + registry.set(shells, threads); + const stop = startThreadVisitedMigration(registry); + return { + setThreads(next: ReadonlyArray) { + registry.set(shells, next); + for (let task = tasks.shift(); task !== undefined; task = tasks.shift()) task(); + }, + [Symbol.dispose]() { + stop(); + registry.dispose(); + }, + }; +} + +describe("startThreadVisitedMigration", () => { + it("preserves mark unread when a client with a newer local visit reloads", () => { + using _migration = setup([makeThreadFixture({ lastVisitedAt: "2026-10-27T09:59:59.999Z" })]); + + expect(runAtomCommand).not.toHaveBeenCalled(); + }); + + it("seeds unset visits once per environment and thread", () => { + const thread = makeThreadFixture({ lastVisitedAt: null }); + const remote = { ...thread, environmentId: EnvironmentId.make("remote") }; + using migration = setup([thread, remote]); + + expect(vi.mocked(runAtomCommand).mock.calls.map((call) => call[2])).toEqual([ + { + environmentId: thread.environmentId, + input: { threadId: thread.id, visitedAt: localVisit }, + }, + { + environmentId: remote.environmentId, + input: { threadId: remote.id, visitedAt: localVisit }, + }, + ]); + migration.setThreads([{ ...thread }, { ...remote }]); + expect(runAtomCommand).toHaveBeenCalledTimes(2); + }); + + it("waits for older servers to support visits before migrating", () => { + const thread = makeThreadFixture(); + expect(thread.lastVisitedAt).toBeUndefined(); + using migration = setup([thread]); + expect(runAtomCommand).not.toHaveBeenCalled(); + + migration.setThreads([{ ...thread, lastVisitedAt: null }]); + expect(runAtomCommand).toHaveBeenCalledTimes(1); + }); + + it.each(["", "not-a-date"])("ignores invalid local visits: %s", (local) => { + using _migration = setup([makeThreadFixture({ lastVisitedAt: null })], local); + expect(runAtomCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useThreadVisitedMigration.ts b/apps/web/src/hooks/useThreadVisitedMigration.ts index 2d01d800360ea..fb90b6d4f2e1d 100644 --- a/apps/web/src/hooks/useThreadVisitedMigration.ts +++ b/apps/web/src/hooks/useThreadVisitedMigration.ts @@ -15,8 +15,8 @@ export function startThreadVisitedMigration(registry: AtomRegistry.AtomRegistry) const migratedThreadKeys = new Set(); const migrate = (threads: ReadonlyArray) => { for (const thread of threads) { - // Missing means unsupported; retain the local value until the server upgrades. - if (thread.lastVisitedAt === undefined) continue; + // An established server watermark may be deliberately older after Mark unread. + if (thread.lastVisitedAt !== null) continue; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); if (migratedThreadKeys.has(threadKey)) continue; migratedThreadKeys.add(threadKey); @@ -24,8 +24,6 @@ export function startThreadVisitedMigration(registry: AtomRegistry.AtomRegistry) if (!local) continue; const localMs = Date.parse(local); if (!Number.isFinite(localMs)) continue; - const serverMs = thread.lastVisitedAt === null ? null : Date.parse(thread.lastVisitedAt); - if (serverMs !== null && Number.isFinite(serverMs) && serverMs >= localMs) continue; void runAtomCommand( registry, threadEnvironment.visit, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index bf886892827ff..45aabd06a7f33 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1569,6 +1569,7 @@ html[data-theme-id] [data-app-sidebar] { --accent-foreground: var(--app-theme-accent-surface-foreground); --muted: var(--app-theme-muted); --muted-foreground: var(--app-theme-muted-foreground); + --secondary-label: var(--app-theme-secondary-label); --border: var(--app-theme-border); --input: var(--app-theme-input); --sidebar: var(--app-theme-sidebar); diff --git a/apps/web/src/localEnvironment.ts b/apps/web/src/localEnvironment.ts index d9f4be0e29518..bccffd64ce23d 100644 --- a/apps/web/src/localEnvironment.ts +++ b/apps/web/src/localEnvironment.ts @@ -1,3 +1,5 @@ export function isLocalEnvironmentDisabled(): boolean { - return window.desktopBridge?.getLocalEnvironmentEnabled() === false; + return ( + typeof window !== "undefined" && window.desktopBridge?.getLocalEnvironmentEnabled() === false + ); } diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index b5e9be7d9e011..435854d54b393 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -1,11 +1,17 @@ import { ANTIGRAVITY_DEFAULT_MODEL, DEFAULT_TEXT_GENERATION_MODEL, + DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; -import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_UNIFIED_SETTINGS, + type UnifiedSettings, +} from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; import { createModelSelection } from "@t3tools/shared/model"; import { deriveEffectiveComposerModelState } from "./composerDraftStore"; @@ -17,6 +23,7 @@ import { providerInstanceEntriesFor, } from "./providerInstances"; import { + defaultModelSelectionsAreMixed, getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, @@ -73,6 +80,142 @@ function settingsWithProviderInstances(): UnifiedSettings { }; } +describe("scoped default model selections", () => { + // regression: #27997 equal automatic defaults can resolve to different providers + it("compares automatic defaults using each environment's models", () => { + const codex = { + projectId: null, + settings: DEFAULT_UNIFIED_SETTINGS, + providers: [provider({ instanceId: "codex", models: ["gpt-6-astra"] })], + }; + const claude = { + projectId: null, + settings: DEFAULT_UNIFIED_SETTINGS, + providers: [ + provider({ + instanceId: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + models: ["claude-sonnet-4-6"], + }), + ], + }; + + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, claude])).toBe(true); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [claude, codex])).toBe(true); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, codex])).toBe(false); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [claude])).toBe(false); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [])).toBe(false); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, { ...codex, providers: [] }]), + ).toBe(true); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + codex, + { ...codex, providers: [provider({ instanceId: "codex", models: ["gpt-6-sol"] })] }, + ]), + ).toBe(true); + + const projectId = ProjectId.make("claude-only"); + const project = { + projectId, + settings: { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [projectId]: { + providerInstances: { [ProviderInstanceId.make("codex")]: { enabled: false } }, + }, + }, + }, + providers: [...codex.providers, ...claude.providers], + }; + const remoteProject = { ...claude, projectId: ProjectId.make("remote-project") }; + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [project, remoteProject])).toBe( + false, + ); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [remoteProject, project])).toBe( + false, + ); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + project, + { ...project, projectId: ProjectId.make("other-project") }, + ]), + ).toBe(true); + + const configured = { + projectId: null, + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerModelPreferences: { + [ProviderInstanceId.make("codex")]: { + hiddenModels: ["gpt-6-astra"], + modelOrder: [], + }, + }, + }, + providers: [provider({ instanceId: "codex", models: ["gpt-6-astra", "gpt-6-sol"] })], + }; + const inherited = { ...configured, settings: DEFAULT_SERVER_SETTINGS }; + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [configured, inherited])).toBe( + true, + ); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [inherited, configured])).toBe( + true, + ); + const clientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + providerModelPreferences: configured.settings.providerModelPreferences, + }; + expect(defaultModelSelectionsAreMixed(clientSettings, [configured, inherited])).toBe(false); + expect(defaultModelSelectionsAreMixed(clientSettings, [inherited, configured])).toBe(false); + expect( + defaultModelSelectionsAreMixed(clientSettings, [ + { ...inherited, settings: { ...DEFAULT_SERVER_SETTINGS, providerModelPreferences: {} } }, + inherited, + ]), + ).toBe(true); + }); + + it("keeps automatic, explicit, and different saved options distinct", () => { + const providers = [provider({ instanceId: "codex", models: ["gpt-6-astra"] })]; + const selected = { + projectId: null, + providers, + settings: { + ...DEFAULT_UNIFIED_SETTINGS, + defaultModelSelection: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-6-astra", + ), + }, + }; + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [selected, { ...selected }]), + ).toBe(false); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + selected, + { projectId: null, providers, settings: DEFAULT_UNIFIED_SETTINGS }, + ]), + ).toBe(true); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + selected, + { + ...selected, + settings: { + ...selected.settings, + defaultModelSelection: { + ...selected.settings.defaultModelSelection, + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }, + }, + ]), + ).toBe(true); + }); +}); + describe("instance-scoped model selection", () => { it("keeps custom models on the provider instance that declared them", () => { const providers = [ @@ -609,6 +752,37 @@ describe("instance-scoped model selection", () => { ).toEqual(createModelSelection(instanceId, "openai/gpt-5.5")); }); + it("keeps the default instance's draft model off a custom instance of the same driver", () => { + const driver = ProviderDriverKind.make("opencode"); + const defaultInstanceId = ProviderInstanceId.make("opencode"); + const kimiInstanceId = ProviderInstanceId.make("kimi_api"); + const kimiModel = "kimi-code-plan-global/k3.1-pro"; + const providers = [ + provider({ + provider: driver, + instanceId: defaultInstanceId, + models: ["opencode-go/kimi-k3.1-pro"], + }), + provider({ provider: driver, instanceId: kimiInstanceId, models: [kimiModel] }), + ]; + const state = deriveEffectiveComposerModelState({ + draft: { + activeProvider: kimiInstanceId, + modelSelectionByProvider: { + [defaultInstanceId]: createModelSelection(defaultInstanceId, "opencode-go/kimi-k3.1-pro"), + }, + }, + providers, + selectedProvider: driver, + selectedInstanceId: kimiInstanceId, + threadModelSelection: null, + projectModelSelection: null, + settings: DEFAULT_UNIFIED_SETTINGS, + }); + + expect(state.selectedModel).toBe(kimiModel); + }); + it("self-heals a persisted selection pointing at a text-generation-incapable instance", () => { const providers = [ provider({ @@ -829,6 +1003,7 @@ describe("composer fallback to another instance of the same driver", () => { readonly providers: ReadonlyArray; readonly draft?: Parameters[0]["draft"]; readonly threadModelSelection: ReturnType; + readonly customModels?: Partial>>; }) { const settings = { ...DEFAULT_UNIFIED_SETTINGS, @@ -838,7 +1013,13 @@ describe("composer fallback to another instance of the same driver", () => { { driver: provider.driver, enabled: provider.enabled, - config: {}, + config: { + customModels: (input.customModels?.[provider.instanceId] ?? []).map((slug) => ({ + slug, + name: slug, + capabilities: null, + })), + }, }, ]), ), @@ -923,6 +1104,138 @@ describe("composer fallback to another instance of the same driver", () => { expect(traits.modelOptionsForDispatch).toBeUndefined(); }); + describe("with model aliases", () => { + const reasoningModel = ( + slug: string, + id: string, + extra: Partial = {}, + ): ServerProvider["models"][number] => ({ + slug, + name: slug, + isCustom: false, + ...extra, + capabilities: { + optionDescriptors: [ + { + id, + label: id, + type: "select", + options: ["medium", "high", "xhigh"].map((value) => ({ + id: value, + label: value, + isDefault: value === "medium", + })), + }, + ], + }, + }); + + it.each([ + ["5.4", "gpt-5.4"], + ["gpt-5-codex", "gpt-5.4"], + ["gpt-5.4", "5.4"], + ])("keeps the effort from %s on fallback %s", (savedModel, fallbackModel) => { + const disabledCodex = ProviderInstanceId.make("codex"); + const fallbackCodex = ProviderInstanceId.make("codex_team"); + const models = [reasoningModel(fallbackModel, "reasoningEffort")]; + const { selectedInstanceId, state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: disabledCodex }), enabled: false, models }, + { ...provider({ provider: codex, instanceId: fallbackCodex }), models }, + ], + threadModelSelection: createModelSelection(disabledCodex, savedModel, [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(selectedInstanceId).toBe(fallbackCodex); + expect(state.selectedModel).toBe(fallbackModel); + expect(traits.promptEffort).toBe("xhigh"); + }); + + it.each([ + { + // A disabled Codex instance reports only its custom models. + state: "disabled", + requested: { + enabled: false, + models: [reasoningModel("5.4", "reasoningEffort", { isCustom: true })], + }, + }, + { + // An unavailable instance reports no models; its custom models are only in settings. + state: "unavailable", + requested: { enabled: false, availability: "unavailable" as const, models: [] }, + }, + ])( + "does not alias-expand a custom model named 5.4 when the requested instance is $state", + ({ requested }) => { + const disabledCodex = ProviderInstanceId.make("codex"); + const { state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: disabledCodex }), ...requested }, + { + ...provider({ provider: codex, instanceId: "codex_team" }), + models: [reasoningModel("gpt-5.4", "reasoningEffort")], + }, + ], + customModels: { [disabledCodex]: ["5.4"] }, + threadModelSelection: createModelSelection(disabledCodex, "5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(state.selectedModel).toBe("gpt-5.4"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }, + ); + + it("does not alias-expand the fallback instance's custom model", () => { + const { state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: "codex" }), enabled: false }, + { + ...provider({ provider: codex, instanceId: "codex_team" }), + models: [reasoningModel("5.4", "reasoningEffort", { isCustom: true })], + }, + ], + customModels: { codex_team: ["5.4"] }, + threadModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(state.selectedModel).toBe("5.4"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }); + + it("does not carry options through an alias that names another model per instance", () => { + // "antigravity-default" is each Antigravity instance's current model. + const antigravity = ProviderDriverKind.make("antigravity"); + const disabledAntigravity = ProviderInstanceId.make("antigravity"); + const current = { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] }; + const { state, traits } = composerFor({ + providers: [ + { + ...provider({ provider: antigravity, instanceId: disabledAntigravity }), + enabled: false, + models: [reasoningModel("gemini-3-pro", "thinking", current)], + }, + { + ...provider({ provider: antigravity, instanceId: "antigravity_work" }), + models: [reasoningModel("claude-sonnet-4-6", "thinking", current)], + }, + ], + threadModelSelection: createModelSelection(disabledAntigravity, ANTIGRAVITY_DEFAULT_MODEL, [ + { id: "thinking", value: "high" }, + ]), + }); + + expect(state.selectedModel).toBe("claude-sonnet-4-6"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }); + }); + it("does not carry options to another driver", () => { const codexOnly: ReadonlyArray = [ { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index db52028c397e0..ec2816cc2849a 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -4,9 +4,11 @@ import { DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, type ModelSelection, + type ProjectId, ProviderDriverKind, ProviderInstanceId, type ServerProvider, + type ServerSettings, } from "@t3tools/contracts"; import { type CustomModelDefinition, @@ -16,8 +18,10 @@ import { resolveSelectableModel, } from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderState"; -import { UnifiedSettings } from "@t3tools/contracts/settings"; +import { type ClientSettings, UnifiedSettings } from "@t3tools/contracts/settings"; +import { resolveModelPreferences } from "@t3tools/shared/serverSettings"; import * as Arr from "effect/Array"; +import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; import { getDefaultServerModel, @@ -29,6 +33,8 @@ import { type ProviderInstanceEntry, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + resolveDefaultProviderModelSelection, + resolveProjectDefaultProviderModelSelection, } from "./providerInstances"; import { sortModelsForProviderInstance } from "./modelOrdering"; import { resolveModelShortName } from "@t3tools/client-runtime/provider-clients"; @@ -37,7 +43,7 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; const DEFAULT_TEXT_GENERATION_INSTANCE_ID = ProviderInstanceId.make("codex"); // Legacy per-driver buckets would leak custom-instance models into stock instances. -function readInstanceCustomModels( +export function readInstanceCustomModels( settings: UnifiedSettings, instanceId: ProviderInstanceId, driverKind: ProviderDriverKind, @@ -308,6 +314,38 @@ export function resolveEffectiveDefaultModelSelection( return model === null || model === selection.model ? selection : { ...selection, model }; } +export function defaultModelSelectionsAreMixed( + clientSettings: ClientSettings, + targets: ReadonlyArray<{ + projectId: ProjectId | null; + settings: ServerSettings; + providers: ReadonlyArray; + }>, +): boolean { + const selections = targets.map(({ projectId, settings, providers }) => { + const stored = settings.defaultModelSelection; + const selection = + stored === null + ? resolveProjectDefaultProviderModelSelection(providers, settings, projectId) + : resolveDefaultProviderModelSelection(providers, stored); + return { + stored, + effective: selection + ? resolveEffectiveDefaultModelSelection( + { + ...clientSettings, + ...settings, + ...resolveModelPreferences(settings, clientSettings), + }, + providers, + selection, + ) + : null, + }; + }); + return selections.some((selection) => !Equal.equals(selection, selections[0])); +} + export function getCustomModelOptionsByInstance( settings: UnifiedSettings, providers: ReadonlyArray, diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index a07ff9ae9b17e..891f3312ae833 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { applyProviderInstanceSettings, + brandProviderInstanceEntries, deriveProviderEntriesByEnvironment, deriveProviderInstanceEntries, getDefaultProviderInstanceModel, @@ -254,6 +255,32 @@ describe("deriveProviderEntriesByEnvironment", () => { }); }); +describe("brandProviderInstanceEntries", () => { + it("keeps an instance a project turns on as a sibling for the instance badge", () => { + const codex = ProviderDriverKind.make("codex"); + const settings = { + providerInstances: { + [ProviderInstanceId.make("codex_work")]: { driver: codex, enabled: false }, + }, + projectSettingsOverrides: { + [ProjectId.make("project-work")]: { + providerInstances: { [ProviderInstanceId.make("codex_work")]: { enabled: true } }, + }, + }, + }; + const entries = brandProviderInstanceEntries( + deriveProviderInstanceEntries([ + provider({ provider: codex, instanceId: "codex" }), + provider({ provider: codex, instanceId: "codex_work" }), + ]), + settings, + ); + const personal = entries.find((entry) => entry.instanceId === "codex"); + + expect(personal && shouldShowInstanceBadge(personal, entries)).toBe(true); + }); +}); + describe("resolveSelectableProviderInstance", () => { it("falls back to the first enabled and available instance", () => { const disabled = ProviderInstanceId.make("codex"); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index f9e711c40e7fc..c2120f60e31cc 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -93,10 +93,7 @@ export function deriveProviderEntriesByEnvironment( const byEnvironment = new Map>(); for (const [environmentId, providers, settings] of providersByEnvironment) { const derived = deriveProviderInstanceEntries(providers); - // The snapshot includes instances enabled by any project; machine settings alone do not. - const entries = settings - ? derived.map((entry) => applyProviderInstanceBranding(entry, settings)) - : derived; + const entries = settings ? brandProviderInstanceEntries(derived, settings) : derived; byEnvironment.set( environmentId, new Map(entries.map((entry) => [entry.instanceId as string, entry] as const)), @@ -105,6 +102,14 @@ export function deriveProviderEntriesByEnvironment( return byEnvironment; } +// The snapshot includes instances enabled by any project; machine settings alone do not. +export function brandProviderInstanceEntries( + entries: ReadonlyArray, + settings: Pick, +): ReadonlyArray { + return entries.map((entry) => applyProviderInstanceBranding(entry, settings)); +} + /** projectId applies project overrides; explicit null uses machine settings. */ export function applyProviderInstanceSettings( entries: ReadonlyArray, diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 0b2cab287d223..a35af5e094a00 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -114,3 +114,7 @@ export function useEnvironmentMachines() { export function useConnectedEnvironmentIds() { return useAtomValue(environmentSummaries.connectedEnvironmentIdsAtom); } + +export function useEnvironmentConnectionSummaries() { + return useAtomValue(environmentSummaries.environmentsAtom); +} diff --git a/apps/web/src/state/shell.test.ts b/apps/web/src/state/shell.test.ts index 4d5920a94ad85..19a88cfbb19f9 100644 --- a/apps/web/src/state/shell.test.ts +++ b/apps/web/src/state/shell.test.ts @@ -7,9 +7,9 @@ import { EMPTY_SHELL_STATE, type EnvironmentShellState } from "@t3tools/client-r import { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/reactivity"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { createAllEnvironmentProjectSnapshotsReadyAtom } from "./shell"; +import { createAllEnvironmentProjectSnapshotsReadyAtom, expectsPrimaryEnvironment } from "./shell"; const LOCAL = EnvironmentId.make("local"); const REMOTE = EnvironmentId.make("remote"); @@ -135,3 +135,16 @@ describe("project snapshot readiness", () => { registry.dispose(); }); }); + +it("expects a primary environment only on desktops with the local environment enabled", () => { + const desktop = (enabled: boolean) => { + vi.stubGlobal("window", { desktopBridge: { getLocalEnvironmentEnabled: () => enabled } }); + try { + return expectsPrimaryEnvironment(); + } finally { + vi.unstubAllGlobals(); + } + }; + expect(desktop(true)).toBe(true); + expect(desktop(false)).toBe(false); +}); diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index c4675d3d01301..ef46e49839808 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -21,6 +21,7 @@ import { AsyncResult, Atom } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { isHostedStaticApp } from "../hostedPairing"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); @@ -123,12 +124,17 @@ export const allEnvironmentProjectSnapshotsReadyAtom = createAllEnvironmentProjectSnapshotsReadyAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, shellStateValueAtom: environmentShell.stateValueAtom, - requiresPrimaryEnvironment: !isHostedStaticApp(), + requiresPrimaryEnvironment: expectsPrimaryEnvironment(), }); +/** Hosted clients and desktops with the local environment disabled never discover a primary. */ +export function expectsPrimaryEnvironment(): boolean { + return !isHostedStaticApp() && !isLocalEnvironmentDisabled(); +} + export const allReachableShellsLiveAtom = Atom.make((get) => { const catalog = get(environmentCatalog.catalogValueAtom); - if (!catalogIsComplete(catalog, !isHostedStaticApp())) return false; + if (!catalogIsComplete(catalog, expectsPrimaryEnvironment())) return false; for (const environmentId of enabledEnvironmentIds(catalog)) { const shell = get(environmentShell.stateValueAtom(environmentId)); if (shell.status === "live" && Option.isSome(shell.snapshot)) continue; diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index f6df5912ecdd1..dafb07f354437 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -187,6 +187,25 @@ describe("extractTerminalLinks", () => { ]); }); + it.each([ + "connected to redis://127.0.0.1:6379/0", + "connected to postgres://localhost:5432/app", + "uploaded s3://bucket/reports/today.csv", + " at main (file:///Users/jane/app/src/index.mjs:3:7)", + "+redis://localhost:6379/0", + "-redis://localhost:6379/0", + ])("does not turn part of a non-web URL into an editor link: %s", (line) => { + expect(extractTerminalLinks(line)).toEqual([]); + }); + + it("keeps file links beside a non-web URL", () => { + const line = "src/db.ts:4 redis://localhost:6379/0 C:/app/src/db.ts:9"; + expect(extractTerminalLinks(line)).toEqual([ + { kind: "path", text: "src/db.ts:4", start: 0, end: 11 }, + { kind: "path", text: "C:/app/src/db.ts:9", start: 37, end: 55 }, + ]); + }); + it("keeps a question mark inside a filesystem path", () => { const path = "/tmp/report?draft"; expect(extractTerminalLinks(`wrote ${path}`)).toEqual([ diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 1c078d86cd20f..68bd4c8d5a835 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -41,6 +41,9 @@ const TRAILING_PATH_PUNCTUATION_PATTERN = /[.,;:!?]+$/; // A question mark is legal in filenames; only exclude query-bearing request targets. const REQUEST_ROUTE_PATTERN = /(?:^|[\s"'`])(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|CONNECT|TRACE|proxying)\s+["'`]?(?:\/|[A-Za-z0-9_-]+\/)[^\s?]*\?\S*/g; +// The tail of `redis://host/0` or `file:///a.mjs` is not a path. One-letter schemes stay drives, +// a diff's `+` or `-` may lead, and the lookbehind keeps runs from being rescanned at every suffix. +const SCHEME_URL_PATTERN = /(?]+/gu; function trimClosingDelimiters(value: string, kind: TerminalLinkKind): string { let output = value.replace( @@ -110,10 +113,10 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { const promptMatches: TerminalLinkMatch[] = promptPath ? [{ kind: "path", text: promptPath, start: 3, end: 3 + promptPath.length }] : []; - const requestRoutes = Array.from(line.matchAll(REQUEST_ROUTE_PATTERN), (match) => ({ - start: match.index, - end: match.index + match[0].length, - })); + const nonPathRanges = Array.from( + [...line.matchAll(REQUEST_ROUTE_PATTERN), ...line.matchAll(SCHEME_URL_PATTERN)], + (match) => ({ start: match.index, end: match.index + match[0].length }), + ); const urlMatches = collectMatches(line, "url", URL_PATTERN, promptMatches); const quotedMatches = collectMatches(line, "path", QUOTED_FILE_PATH_PATTERN, [ ...promptMatches, @@ -126,7 +129,7 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { ]); return [...promptMatches, ...urlMatches, ...quotedMatches, ...pathMatches] .filter( - (match) => match.kind === "url" || !requestRoutes.some((route) => overlaps(match, route)), + (match) => match.kind === "url" || !nonPathRanges.some((range) => overlaps(match, range)), ) .toSorted((a, b) => a.start - b.start); } diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index dcae81b4fb9ff..b8ca5a624636f 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -177,15 +177,19 @@ function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { } /** Bounds String.fromCodePoint arguments for cells with enormous combining-mark sequences. */ -export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string { - if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(0, true)); +export function ghosttyCellText( + codepointView: DataView, + graphemeLength: number, + byteOffset = 0, +): string { + if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(byteOffset, true)); const CHUNK_SIZE = 4_096; let text = ""; for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) { const count = Math.min(CHUNK_SIZE, graphemeLength - start); const codes = new Array(count); for (let index = 0; index < count; index += 1) { - codes[index] = codepointView.getUint32((start + index) * 4, true); + codes[index] = codepointView.getUint32(byteOffset + (start + index) * 4, true); } text += String.fromCodePoint(...codes); } @@ -853,12 +857,12 @@ export class GhosttyTerminalCore { if (rowDirty) { this.rows[rowIndex] = this.readRow(iterator, cols, foreground, background); dirtyRows.add(rowIndex); - this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.memoryBytes()[this.scratch] = 0; this.runtime.call("ghostty_render_state_row_set", iterator, 0, this.scratch); } rowIndex += 1; } - this.runtime.view(this.scratch, 4).setUint32(0, 0, true); + this.runtime.memoryDataView().setUint32(this.scratch, 0, true); this.runtime.call("ghostty_render_state_set", this.renderState, 0, this.scratch); } @@ -1009,19 +1013,19 @@ export class GhosttyTerminalCore { "ghostty_render_state_row_get(raw)", this.runtime.call("ghostty_render_state_row_get", iterator, ROW_DATA.raw, this.scratch), ); - const rawRow = this.runtime.view(this.scratch, 8).getBigUint64(0, true); - this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + const rawRow = this.runtime.memoryDataView().getBigUint64(this.scratch, true); + this.runtime.memoryBytes()[this.scratch + 8] = 0; this.assertSuccess( "ghostty_row_get(wrap continuation)", this.runtime.call("ghostty_row_get", rawRow, 2, this.scratch + 8), ); - const isWrapContinuation = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; - this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + const isWrapContinuation = this.runtime.memoryBytes()[this.scratch + 8] !== 0; + this.runtime.memoryBytes()[this.scratch + 8] = 0; this.assertSuccess( "ghostty_row_get(wrap)", this.runtime.call("ghostty_row_get", rawRow, 1, this.scratch + 8), ); - const wrapsToNext = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; + const wrapsToNext = this.runtime.memoryBytes()[this.scratch + 8] !== 0; this.assertSuccess( "ghostty_render_state_row_get(cells)", @@ -1034,15 +1038,13 @@ export class GhosttyTerminalCore { ); const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); const { size: styleSize, fields: styleFields } = this.runtime.layout("GhosttyStyle"); + const cellsNext = this.runtime.fn("ghostty_render_state_row_cells_next"); + const cellsGetMulti = this.runtime.fn("ghostty_render_state_row_cells_get_multi"); const cells: GhosttyCell[] = []; - while ( - cells.length < cols && - this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0 - ) { - this.runtime.bytes(this.style, styleSize).fill(0); + while (cells.length < cols && cellsNext(cellsIterator) !== 0) { + this.runtime.memoryBytes().fill(0, this.style, this.style + styleSize); this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); - const result = this.runtime.call( - "ghostty_render_state_row_cells_get_multi", + const result = cellsGetMulti( cellsIterator, 5, this.cellQuery + CELL_QUERY.keys, @@ -1081,8 +1083,7 @@ export class GhosttyTerminalCore { ) === GHOSTTY_SUCCESS ) { // Byte allocations need not be 4-byte aligned; use DataView instead of Uint32Array. - const codepointView = this.runtime.view(this.graphemes, bufferSize); - text = ghosttyCellText(codepointView, graphemeLength); + text = ghosttyCellText(this.runtime.memoryDataView(), graphemeLength, this.graphemes); } } let wide = 0; @@ -1097,13 +1098,13 @@ export class GhosttyTerminalCore { this.scratch, ), ); - const rawCell = this.runtime.view(this.scratch, 8).getBigUint64(0, true); - this.runtime.view(this.scratch + 8, 4).setUint32(0, 0, true); + const rawCell = this.runtime.memoryDataView().getBigUint64(this.scratch, true); + this.runtime.memoryDataView().setUint32(this.scratch + 8, 0, true); this.assertSuccess( "ghostty_cell_get(wide)", this.runtime.call("ghostty_cell_get", rawCell, RAW_CELL_DATA.wide, this.scratch + 8), ); - wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); + wide = this.runtime.memoryDataView().getUint32(this.scratch + 8, true); } // Read the style after allocation and ABI calls, which can grow WASM memory. const styleView = this.runtime.memoryDataView(); @@ -1228,34 +1229,34 @@ export class GhosttyTerminalCore { } private getU16(data: number): number { - this.runtime.bytes(this.scratch, 2).fill(0); + this.runtime.memoryDataView().setUint16(this.scratch, 0, true); this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); - return this.runtime.view(this.scratch, 2).getUint16(0, true); + return this.runtime.memoryDataView().getUint16(this.scratch, true); } private getU32(data: number): number { - this.runtime.bytes(this.scratch, 4).fill(0); + this.runtime.memoryDataView().setUint32(this.scratch, 0, true); this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); - return this.runtime.view(this.scratch, 4).getUint32(0, true); + return this.runtime.memoryDataView().getUint32(this.scratch, true); } private getBool(data: number): boolean { - this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.memoryBytes()[this.scratch] = 0; this.assertSuccess( "ghostty_render_state_get", this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), ); - return this.runtime.bytes(this.scratch, 1)[0] !== 0; + return this.runtime.memoryBytes()[this.scratch] !== 0; } private getColor(data: number, fallback: GhosttyColor): GhosttyColor { - this.runtime.bytes(this.scratch, 3).fill(0); + this.runtime.memoryBytes().fill(0, this.scratch, this.scratch + 3); const result = this.runtime.call( "ghostty_render_state_get", this.renderState, @@ -1266,15 +1267,15 @@ export class GhosttyTerminalCore { } private getRowBool(iterator: number, data: number): boolean { - this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.memoryBytes()[this.scratch] = 0; return ( this.runtime.call("ghostty_render_state_row_get", iterator, data, this.scratch) === - GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + GHOSTTY_SUCCESS && this.runtime.memoryBytes()[this.scratch] !== 0 ); } private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor { - this.runtime.bytes(this.scratch, 3).fill(0); + this.runtime.memoryBytes().fill(0, this.scratch, this.scratch + 3); const result = this.runtime.call( "ghostty_render_state_row_cells_get", iterator, diff --git a/apps/web/src/terminal/ghostty/runtime.ts b/apps/web/src/terminal/ghostty/runtime.ts index 22c9c13e50722..762fb0f6ef13d 100644 --- a/apps/web/src/terminal/ghostty/runtime.ts +++ b/apps/web/src/terminal/ghostty/runtime.ts @@ -24,6 +24,7 @@ export class GhosttyRuntime { readonly layouts: TypeLayouts; private readonly exports: WebAssembly.Exports; private memoryView: DataView; + private memoryU8: Uint8Array; private readonly ptyWriters = new Map void>(); private nextPtyWriterId = 1; private writePtyFunctionIndex = 0; @@ -36,6 +37,7 @@ export class GhosttyRuntime { } this.memory = memory; this.memoryView = new DataView(memory.buffer); + this.memoryU8 = new Uint8Array(memory.buffer); const jsonPointer = this.call("ghostty_type_json"); const bytes = new Uint8Array(memory.buffer); let end = jsonPointer; @@ -68,11 +70,15 @@ export class GhosttyRuntime { } call(name: string, ...args: Array): number { + return this.fn(name)(...args); + } + + fn(name: string): WasmFunction { const fn = this.exports[name]; if (typeof fn !== "function") { throw new Error(`libghostty-vt export is unavailable: ${name}`); } - return (fn as WasmFunction)(...args); + return fn as WasmFunction; } layout(name: string): TypeLayout { @@ -126,18 +132,29 @@ export class GhosttyRuntime { } view(pointer: number, size?: number): DataView { - return new DataView(this.memory.buffer, pointer, size); + return new DataView(this.memoryDataView().buffer, pointer, size); } bytes(pointer: number, size: number): Uint8Array { - return new Uint8Array(this.memory.buffer, pointer, size); + return new Uint8Array(this.memoryDataView().buffer, pointer, size); } memoryDataView(): DataView { - if (this.memoryView.buffer !== this.memory.buffer) { + this.refreshMemoryViews(); + return this.memoryView; + } + + memoryBytes(): Uint8Array { + this.refreshMemoryViews(); + return this.memoryU8; + } + + private refreshMemoryViews(): void { + // Growing memory detaches the old buffer. Reading `memory.buffer` costs far more than this check. + if (this.memoryU8.byteLength === 0) { this.memoryView = new DataView(this.memory.buffer); + this.memoryU8 = new Uint8Array(this.memory.buffer); } - return this.memoryView; } setField(pointer: number, structName: string, fieldName: string, value: number): void { diff --git a/apps/web/src/textDirection.test.ts b/apps/web/src/textDirection.test.ts index a104133799c52..3cc618e525f07 100644 --- a/apps/web/src/textDirection.test.ts +++ b/apps/web/src/textDirection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { textDirection } from "./textDirection"; +import { markdownProseDirection, textDirection } from "./textDirection"; describe("textDirection", () => { it.each([ @@ -24,3 +24,39 @@ describe("textDirection", () => { expect(textDirection("42 — 7 ✓")).toBeUndefined(); }); }); + +describe("markdownProseDirection", () => { + it.each([ + [ + "Hebrew around a code block", + "תתקן את זה:\n```ts\nexport function useCheckout(props: CheckoutProps) {\n return props.items.length;\n}\n```\nותריץ שוב", + "rtl", + ], + ["Hebrew around inline code", "לערוך את `apps/web/src/chat.tsx` או להשאיר `useMemo`?", "rtl"], + ["Hebrew around double-backtick code", "תבדוק ``const a = `b c d e` + f`` עכשיו", "rtl"], + [ + "Hebrew after a four-backtick fence", + "````md\none two three four five six\n````\nשלום עולם, זה עובד", + "rtl", + ], + [ + "Hebrew after a tilde fence", + "~~~\nnpm run build --filter web --force\n~~~\nזה נכשל אצלי", + "rtl", + ], + [ + "Hebrew before an unclosed fence", + "שלום, הנה השגיאה:\n```\nError: cannot find module react dom client", + "rtl", + ], + ["Hebrew around a URL", "ראה https://github.com/pingdotgg/t3code/pull/24050 לפני", "rtl"], + ["English around inline code", "Should I edit `useMemo` or keep it?", "ltr"], + ["English quoting one Hebrew word", "Please check the שלום label before the release", "ltr"], + ])("reads %s", (_, markdown, direction) => { + expect(markdownProseDirection(markdown)).toBe(direction); + }); + + it("leaves markdown without prose letters to its surroundings", () => { + expect(markdownProseDirection("```\nconst a = 1;\n```")).toBeUndefined(); + }); +}); diff --git a/apps/web/src/textDirection.ts b/apps/web/src/textDirection.ts index 9f0adedd30e3c..8b1a9e8c2a357 100644 --- a/apps/web/src/textDirection.ts +++ b/apps/web/src/textDirection.ts @@ -1,27 +1 @@ -export type TextDirection = "ltr" | "rtl"; - -// Hebrew through Arabic Extended-A, their presentation forms, and the -// supplementary right-to-left blocks. -const RTL_LETTER = /[֐-ࣿיִ-﷿ﹰ-\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u; -const LETTER = /\p{L}/u; - -/** - * `dir="auto"` misreads Arabic prose that opens with an English term. - * Count words so mixed prose follows its main language; break ties with the first word. - */ -export function textDirection(text: string): TextDirection | undefined { - if (!RTL_LETTER.test(text)) return LETTER.test(text) ? "ltr" : undefined; - let rtlWords = 0; - let ltrWords = 0; - let first: TextDirection | undefined; - for (const word of text.split(/\s+/)) { - const letter = LETTER.exec(word)?.[0]; - if (!letter) continue; - const direction = RTL_LETTER.test(letter) ? "rtl" : "ltr"; - first ??= direction; - if (direction === "rtl") rtlWords += 1; - else ltrWords += 1; - } - if (rtlWords === ltrWords) return first; - return rtlWords > ltrWords ? "rtl" : "ltr"; -} +export * from "@t3tools/shared/textDirection"; diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index f6903655449b5..5069cf4f8187b 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -61,6 +61,17 @@ function contrastRatio(first: string, second: string): number { } describe("theme files", () => { + it("gives non-Latin theme names distinct stable ids", () => { + const file = { + version: THEME_FILE_VERSION, + appearance: "dark", + colors: { canvas: "#101820" }, + }; + const night = parseThemeFile({ ...file, name: "夜" }); + expect(parseThemeFile({ ...file, name: "朝" }).id).not.toBe(night.id); + expect(parseThemeFile({ ...file, name: " 夜 " }).id).toBe(night.id); + }); + it("keeps built-in and standard search pairs readable and distinct from inline code", () => { const toOklab = converter("oklab"); const distance = (first: string, second: string) => { diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 25b4b5a020f23..ac8ea80adc663 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1220,13 +1220,20 @@ export function getThemePreferenceMode(theme: ThemePreference): ThemeAppearance } export function themeIdFromName(name: string): string { - const normalized = name - .trim() - .toLowerCase() + const label = name.trim().toLowerCase(); + const normalized = label .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48); - return normalized || "custom-theme"; + if (normalized || !label) return normalized || "custom-theme"; + + // Names without ASCII letters or digits still need independent, stable ids. + let hash = 2_166_136_261; + for (let index = 0; index < label.length; index += 1) { + hash ^= label.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return `theme-${(hash >>> 0).toString(36)}`; } export class ThemeLibraryStorageError extends Schema.TaggedError()( diff --git a/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index 93792b815d852..f0172c2354d03 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { getThemeColorsForMode, + parseThemeFile, themeColorToHex, THEME_FILE_VERSION, toCanonicalThemeColor, @@ -9,6 +10,7 @@ import { import { isVsCodeThemeFile, pairVsCodeThemes, + parseThemeImport, parseVsCodeThemeFile, resolveThemeLabelCollisions, } from "./vscodeThemeImport"; @@ -63,6 +65,59 @@ const VSCODE_DARK = { tokenColors: [], }; +describe("theme import text", () => { + it("preserves conversion of ordinary VS Code JSON", () => { + expect(parseThemeImport(JSON.stringify(VSCODE_DARK))).toEqual( + parseVsCodeThemeFile(VSCODE_DARK), + ); + }); + + it.each([ + '{ // line comment\n "name": "Test", "colors": { "editor.background": "#002b36" } }', + '{ /* block comment */ "name": "Test", "colors": { "editor.background": "#002b36" } }', + '{ "name": "Test", "colors": { "editor.background": "#002b36", }, "tokenColors": [ { "scope": ["comment",], "settings": {}, }, ], }', + ])("imports VS Code JSONC without changing conversion: %s", (source) => { + const theme = parseThemeImport(source); + expect(theme.label).toBe("Test"); + expect(theme.appearance).toBe("dark"); + expect(asHex(theme.colors.canvas)).toBe("#002b36"); + }); + + it.each([ + "", + '{ "name": "Test", "colors": { "editor.background": "#002b36" }', + '{ "name": "Test" "colors": { "editor.background": "#002b36" } }', + '{ "name": "Test", "colors": { "editor.background": "#002b36" } } /* unterminated', + '{ "name": "Test", "colors": { "editor.background": "#002b36" } } extra', + ])("rejects malformed input rather than using parser recovery: %s", (source) => { + expect(() => parseThemeImport(source)).toThrow("That theme file is not valid JSON."); + }); + + it("preserves native T3 theme parsing and strict JSON syntax", () => { + const nativeTheme = { + version: THEME_FILE_VERSION, + name: "Native theme", + appearance: "dark", + colors: { canvas: "#002b36" }, + }; + const source = JSON.stringify(nativeTheme); + expect(parseThemeImport(source)).toEqual(parseThemeFile(nativeTheme)); + expect(() => parseThemeImport(source.replace("{", "{ /* native theme */"))).toThrow(); + expect(() => parseThemeImport(source.slice(0, -1) + ",}")).toThrow(); + }); + + it("still validates theme structure after parsing JSONC", () => { + expect(() => + parseThemeImport('{ /* not a theme */ "colors": { "editor.foreground": "#fff" } }'), + ).toThrow('no "editor.background"'); + expect(() => + parseThemeImport( + '{ "version": 1, "name": "Bad native", "appearance": "dark", "colors": { "unknown": "#fff" } }', + ), + ).toThrow('"unknown" is not a supported theme color role.'); + }); +}); + describe("VS Code theme import", () => { it("recognises workbench themes and rejects our own files", () => { expect(isVsCodeThemeFile(VSCODE_DARK)).toBe(true); diff --git a/apps/web/src/vscodeThemeImport.ts b/apps/web/src/vscodeThemeImport.ts index 57e3d245b212b..416351b16edfb 100644 --- a/apps/web/src/vscodeThemeImport.ts +++ b/apps/web/src/vscodeThemeImport.ts @@ -1,3 +1,5 @@ +import { parse, type ParseError } from "jsonc-parser"; + import { createVividThemeColors, getStandardThemeColors, @@ -133,6 +135,15 @@ export function isVsCodeThemeFile(value: unknown): boolean { return hasWorkbenchColors || Array.isArray(value.tokenColors); } +export function parseThemeImport(source: string): ThemeDefinition { + const errors: ParseError[] = []; + const value: unknown = parse(source, errors, { allowTrailingComma: true }); + if (errors.length > 0) throw new Error("That theme file is not valid JSON."); + return isVsCodeThemeFile(value) + ? parseVsCodeThemeFile(value) + : parseThemeFile(JSON.parse(source)); +} + function resolveAppearance(value: Record, canvas: VsCodeRgb): ThemeAppearance { const type = typeof value.type === "string" ? value.type.toLowerCase() : null; if (type === "light" || type === "hc-light") return "light"; diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 42f0a70101b3a..3a31a9dac93cd 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -93,7 +93,10 @@ manual-only because their version is pinned in mise's config. A Grok cask whose cannot be verified also stays manual-only: `grok update` would install a second copy. npm updates pin `--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the provider. Homebrew compares against `brew info` since casks trail npm by hours; native installs share npm's version -train, so the registry stays authoritative for them. +train, so the registry stays authoritative for them. A Chocolatey install of Claude stays +manual-only: its package directory is writable only by administrators, and `claude update` through +the shim installs a native copy elsewhere. The latest version comes from `choco search`, and the +card shows `choco upgrade claude-code` without running it. See the [resolver](../../apps/server/src/provider/providerMaintenanceRunner.ts). Ownership is cached per instance and re-read immediately before an update runs. The diff --git a/docs/internals/web-ui.md b/docs/internals/web-ui.md index 13b7d9df99fd0..c54a3802be941 100644 --- a/docs/internals/web-ui.md +++ b/docs/internals/web-ui.md @@ -31,8 +31,8 @@ The composer banners are the reference. renders `Button`. - [`ComposerBannerStack.tsx`](../../apps/web/src/components/chat/ComposerBannerStack.tsx) composes the slots and owns only stack behavior: priority, expand and collapse, dismiss transitions. -- Consumers are a few lines of slots, and the slots carry the styling: - [`ComposerActivityStatus.tsx`](../../apps/web/src/components/chat/ComposerActivityStatus.tsx). +- Consumers are a few lines of slots, and the slots carry the banner styling: + [`ComposerPlanFollowUpBanner.tsx`](../../apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx). Build new UI the same way. Compose or extend a component instead of copying its markup and classes. When a call site repeats a treatment another one already uses, make it a variant or a slot on the diff --git a/docs/user/composer.md b/docs/user/composer.md index db41f6cc013fe..49f49a87e3690 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -47,6 +47,8 @@ You can drag or paste images into the web or desktop composer. HEIC and HEIF photos are converted to JPEG there and when selected from the mobile photo library; photos over the image limit are also resized to fit. On mobile, you can also send files to T3 Code through another app's system share sheet. +On Android, you can also drag images from another app into the message input +in split screen. See [images and videos](#images-and-videos-in-messages) for previewing and saving media. @@ -211,8 +213,8 @@ images. **Send now** sends a message right away, into the running turn if there also edit or remove it. If you stop the agent or the server restarts, queued messages keep their order and are held. -Press **Resume** in an empty composer on web and desktop, or **Resume queue** on mobile, to -continue. Sending a new message also resumes the queue, unless it steers a turn that is already +Press **Resume** in an empty composer to continue. **Resume** also continues a stopped +run or one that hit a usage limit, even with nothing queued. Sending a new message also resumes the queue, unless it steers a turn that is already running. You can edit or remove held messages without starting them. The pencil on a queued message opens it in the composer for editing. The original message @@ -240,7 +242,12 @@ Provider commands must start the message to run. T3 Code commands such as `/model` and `/plan`, and skill mentions, work on any line. Send `/compact` in an existing conversation to reduce context usage when the -provider supports it. Web and desktop also offer compaction from the context meter. +provider supports it, even with the context meter turned off. + +On web and desktop, turn on **Settings → Labs → Legacy → Context +window indicator (legacy)** to see context usage. The meter is off by default +and appears once the provider reports usage. Open it for usage details and +**Compact context** when the provider supports compaction. Claude instances can also compact idle threads for you. See [compact long conversations](./providers-claude.md#compact-long-conversations). diff --git a/docs/user/install.md b/docs/user/install.md index 3f44f5b596b80..6f3f63330e08b 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -186,9 +186,10 @@ When a provider CLI is behind its latest release, its provider card shows the available version. **Update now** runs the installer that owns the CLI (Homebrew, or a global npm, pnpm, Yarn, Bun, Volta, or Vite+ install), or the CLI's own update command when T3 Code cannot tell. Update a CLI installed with -mise through mise. Cursor and Antigravity update with T3 Code. Homebrew installs -compare against the version Homebrew offers, which can trail the npm release by -a few hours. +mise through mise. Cursor and Antigravity update with T3 Code. Homebrew and +Chocolatey installs compare against the version their package manager offers, +which can trail the npm release; update Claude installed with Chocolatey +through Chocolatey. Add another provider instance for a separate account or configuration. Each instance can have its own environment variables, such as API keys or a custom diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 9c5e30ef76074..ce4b1fd85cf96 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -2,10 +2,10 @@ Sign in to T3 Connect, link your environments, and enable **Device Notifications** in **Settings → Notifications** to receive alerts when an agent finishes, fails, needs approval, or asks for input. Tap a notification to open its thread. Your environment must have agent activity publishing enabled. -Enable **Ongoing Agent Activity** on Android or **Live Activity Updates** on iOS to follow work without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an Android activity card without disabling alerts; turn off ongoing activity in Settings to stop future cards. +Enable **Live Activity Updates** on iOS, **Agent Live Updates** on Android 16 and newer, or **Ongoing Agent Activity** on older Android versions to follow work without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an Android activity card without disabling alerts; turn off the same switch to stop future cards. Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. -Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer can promote ongoing activity to a Live Update, subject to system settings and device support. Other devices show a regular ongoing notification. Android 7's battery-saving modes can delay removal of expired cards. +Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer can promote ongoing activity to a Live Update, subject to system settings and device support. Other devices show a regular ongoing notification. If Live Updates don't appear on Android 16 or newer, check **Live Update Settings** in T3 Code's **Settings → Notifications**. Android 7's battery-saving modes can delay removal of expired cards. Notification permission and Android notification channels are controlled in system Settings. Background delivery requires T3 Connect; a direct or Tailscale connection alone does not enable push notifications. The mobile app does not need to maintain a connection to your environment. Force-stopping the Android app in system Settings prevents push delivery until you open it again. diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 63d2ba04e8eab..ddecdc40fafc1 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -78,11 +78,13 @@ branch name remains. ## Scheduled tasks on mobile -Open **Settings → Scheduled tasks** to create recurring tasks or manage existing +Open **Settings → Scheduled tasks** to create scheduled tasks or manage existing ones across your connected environments. Use the settings filter to narrow the list by environment or project. Each task runs on the environment you choose, using its project, model, and workspace settings. Fixed-time schedules use that -environment's time zone, which may differ from your phone's. +environment's time zone, which may differ from your phone's. Choose **Once** +to run a task a single time at a date and time in your own time zone. If the +environment is offline then, the task runs when it is back. Deleting a thread also deletes tasks that post into that thread. Tasks that start a new thread on each run survive deletion of their previous threads. @@ -134,7 +136,7 @@ compare as text, and a value that is an object or a list only passes **exists**. Separate **is one of** values with commas; a value can't contain a comma or start or end with a space. -To suppress repeated deliveries, set a **Replay key** template, such as +To suppress repeated deliveries, set a **Duplicate request key** template, such as `{{headers.delivery-id}}`. The same key is ignored for seven days per task. To combine a burst, set a **Coalesce key**, such as `{{body.id}}`, and a window of 10–3600 seconds (120 by default). One run uses the latest body, headers, diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md index 120df9c6141f6..ab660b746cae0 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -100,7 +100,9 @@ including when another client performs the import. The thread keeps the native session's title and last-update value as provider metadata without overwriting a title you set in T3 Code. Agents that report ACP context usage drive the composer's -context meter and cumulative cost metadata. Text resources and links render as assistant output; +context meter and cumulative cost metadata. The meter is off by default; see +[commands and skills](./composer.md#commands-and-skills) to enable it on web and desktop. +Text resources and links render as assistant output; binary resources, images, and audio that T3 Code cannot render yet appear as explicit placeholders instead of disappearing. diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 206d82ce32333..c94fc3420f875 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -49,8 +49,12 @@ Set **Auto-compact after** in the Claude provider settings to an integer between window. Leave it empty for Claude Code's default. You can also send `/compact` in an existing conversation. Web and desktop offer -**Compact context** from the context meter. When you return to a large thread -after Claude's prompt cache expires, a **Compact** chip with the thread's token count shows +**Compact context** from the context meter. The meter is off by default; see +[commands and skills](./composer.md#commands-and-skills) to enable it. Claude Code +compacts automatically whether the meter is on or off. + +When you return to a large thread after Claude's prompt cache expires, a +**Compact** chip with the thread's token count shows next to the send button. While it is on, Enter summarizes the history first, then sends your message. Click the chip to switch it to **Full** and keep the full history for that message. The cache lasts about an hour on a Claude diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index e106d0afdd387..37026d50ab76c 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -86,5 +86,6 @@ threads. environment, including `PI_CODING_AGENT_DIR` if set. Trust the folder or its parent with `/trust`, then start a new thread using that same worktree. Launch arguments with `--no-approve` keep project files off without the notice. -- The context meter appears once Pi reports usage for the thread. Some model providers only - report usage when a response completes, so the meter can wait for the first reply. +- The context meter is off by default; see [commands and skills](./composer.md#commands-and-skills) + to enable it on web and desktop. It appears once Pi reports usage for the thread. Some model + providers only report usage when a response completes, so the meter can wait for the first reply. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index a90dabf06d8c8..bed4ed73a40c5 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -238,8 +238,9 @@ from its context menu, or **Copy thread** from its long-press menu on mobile. **Limited** means the provider stopped on a usage or rate limit. The conversation keeps the provider's explanation. Retry after the limit resets, or switch to another provider instance. -On web and desktop, press **Resume** in an empty composer to continue a limited -or interrupted turn manually. +Press **Resume** in an empty composer to continue a limited or interrupted turn +manually. Do this when the provider reports no reset time, because nothing +continues the turn on its own. Queued messages stay saved while the limit blocks the thread. They run after the continuation finishes. If the queue was held by a restart, resume it then. diff --git a/evals/mcp-tool-use/cases/pull-requests.ndjson b/evals/mcp-tool-use/cases/pull-requests.ndjson index 378dc9e45760a..997243d873a49 100644 --- a/evals/mcp-tool-use/cases/pull-requests.ndjson +++ b/evals/mcp-tool-use/cases/pull-requests.ndjson @@ -1,17 +1,17 @@ -{"id": "review-child-links-reviewed-pr", "area": "pull-requests", "kind": "bad", "pattern": "review-only child links the PR it reviews", "experience": "A read-only review child linked the PR it only reviewed, then called list_thread_pull_requests. Parent had already linked it.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. Your parent thread already linked https://github.com/acme/app/pull/123. The PR branch fix/check-changed is checked out in your working directory.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "delegateRole": "review", "world": {"branch": "fix/check-changed", "commits": [{"sha": "5b7e2d9", "subject": "feat(scripts): one command checks what the branch changed"}], "diff": "diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts\nnew file mode 100644\n--- /dev/null\n+++ b/scripts/check-changed.ts\n@@ -0,0 +1,9 @@\n+import { execSync } from \"node:child_process\";\n+const base = process.argv[2] ?? \"origin/main\";\n+const files = execSync(`git diff --name-only ${base}`).toString().split(\"\\n\");\n+const ts = files.filter((f) => f.endsWith(\".ts\"));\n+if (ts.length > 0) execSync(`vp lint ${ts.join(\" \")}`, { stdio: \"inherit\" });\n+const diff = execSync(`git diff ${base}`).toString();\n+if (diff.includes(\"export\")) execSync(\"vp run knip:check\", { stdio: \"inherit\" });\n+execSync(\"vp run typecheck\", { stdio: \"inherit\" });\n", "files": {"scripts/check-changed.ts": "import { execSync } from \"node:child_process\";\nconst base = process.argv[2] ?? \"origin/main\";\nconst files = execSync(`git diff --name-only ${base}`).toString().split(\"\\n\");\nconst ts = files.filter((f) => f.endsWith(\".ts\"));\nif (ts.length > 0) execSync(`vp lint ${ts.join(\" \")}`, { stdio: \"inherit\" });\nconst diff = execSync(`git diff ${base}`).toString();\nif (diff.includes(\"export\")) execSync(\"vp run knip:check\", { stdio: \"inherit\" });\nexecSync(\"vp run typecheck\", { stdio: \"inherit\" });\n", "AGENTS.md": "Keep things simple. Run focused tests only.\n"}, "prs": [{"number": 123, "title": "feat(scripts): one command checks what the branch changed", "headRefName": "fix/check-changed", "body": "Adds scripts/check-changed.ts. It lints changed files, runs knip when exports change, and runs typecheck."}]}}, "prompt": "Review pull request https://github.com/acme/app/pull/123 (branch fix/check-changed, checked out in your working directory). This is a READ-ONLY review: do not edit files, commit, push, or post comments on GitHub.\n\nRead `gh pr view 123` and `git diff origin/main...HEAD`.\n\nReport:\n1. Overall verdict in one or two sentences.\n2. Real bugs, each with a failure scenario and file:line.\n3. Design concerns, ranked.\nKeep it short.", "expect": {"notCalls": ["link_pull_request", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "27 of 27 Codex review or scoring children did this. Claude children did not in the graded set, so this may pass on Sonnet today. Keep it as the main A/B target for the PR block rewrite."} -{"id": "scorer-child-links-scored-prs", "area": "pull-requests", "kind": "bad", "pattern": "scoring child links every PR it scores", "experience": "A scoring child linked all 9 PRs it only read and scored, so the child thread carried 9 PRs it never changed.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. No pull requests are linked to this thread.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": {"pullRequests": []}}, "delegateRole": "review", "world": {"files": {"/tmp/audit/SCORE.md": "# Score open pull requests\nFor each PR, read `gh pr view -R acme/app` and `gh pr diff -R acme/app`.\nScore value 1-5 and risk 1-5. Write one JSON line per PR to the output file:\n{\"pr\":n,\"value\":v,\"risk\":r,\"verdict\":\"MERGE|TAKEOVER|SKIP\",\"why\":\"one sentence\"}\nRead-only: never comment, review, label, or merge.\n"}, "prs": [{"number": 101, "title": "fix(web): small bug fix", "headRefName": "fix/small", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/session.ts b/src/session.ts\n--- a/src/session.ts\n+++ b/src/session.ts\n@@ -40,7 +40,9 @@ export function stopTask(task: Task) {\n- if (task.status !== \"running\") return;\n+ if (task.status === \"cancelled\") {\n+ emitTaskRow(task); // resend so stale clients catch up\n+ return;\n+ }\n cancel(task);\n"}, {"number": 102, "title": "feat(server): add a health endpoint", "headRefName": "feat/health", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/server/health.ts b/src/server/health.ts\n--- a/src/server/health.ts\n+++ b/src/server/health.ts\n@@ -1,0 +1,1 @@\n+export const health = () => ({ ok: true });\n"}, {"number": 103, "title": "chore: rename internal helpers", "headRefName": "chore/rename", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/util/names.ts b/src/util/names.ts\n--- a/src/util/names.ts\n+++ b/src/util/names.ts\n@@ -3,1 +3,1 @@\n-export function fmt(x: string) {\n+export function formatName(x: string) {\n"}]}}, "prompt": "Read /tmp/audit/SCORE.md and follow it exactly. Score each PR as it is now.\n\nPRs: 101 102 103\nOutput file: /tmp/audit/round5/scores-b.jsonl", "expect": {"notCalls": ["link_pull_request", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Same root cause as review-child-links-reviewed-pr. Over-linking also makes T3 poll more PRs."} -{"id": "review-round-child-links-pr", "area": "pull-requests", "kind": "bad", "pattern": "round-2 review child links the PR under review", "experience": "A round-2 review child was told 'do not watch it' but still linked the PR it reviewed and listed PRs at the end.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. Your parent thread already linked https://github.com/acme/app/pull/123. Branch fix/orphaned-stop is checked out in your working directory.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "delegateRole": "review", "world": {"branch": "fix/orphaned-stop", "commits": [{"sha": "abc1234", "subject": "fix(server): resend the task row when Stop hits a cancelled task"}, {"sha": "9d8e7f6", "subject": "fix(server): Stop always clears an orphaned delegate"}], "diff": "diff --git a/src/session.ts b/src/session.ts\n--- a/src/session.ts\n+++ b/src/session.ts\n@@ -40,7 +40,9 @@ export function stopTask(task: Task) {\n- if (task.status !== \"running\") return;\n+ if (task.status === \"cancelled\") {\n+ emitTaskRow(task); // resend so stale clients catch up\n+ return;\n+ }\n cancel(task);\n", "files": {"src/session.ts": "export function stopTask(task: Task) {\n if (task.status === \"cancelled\") {\n emitTaskRow(task); // resend so stale clients catch up\n return;\n }\n cancel(task);\n}\n"}, "prs": [{"number": 123, "title": "fix(server): Stop always clears an orphaned delegate", "headRefName": "fix/orphaned-stop"}]}}, "prompt": "You are an adversarial code reviewer, round 2. Review `git diff origin/main...HEAD` in your working directory (branch fix/orphaned-stop, PR https://github.com/acme/app/pull/123). The new commit since round 1 is HEAD (abc1234). Do NOT edit files, commit, push, comment on the PR, or watch it. Read-only.\n\n## Your round 1 findings\n1. P1: Stop does nothing when the task row is already cancelled but the client still shows it running.\n2. P2: The new helper writes the parent node twice.\n\n## Author response\nBoth fixed in abc1234.\n\nReport: VERDICT (ship or fix first), whether each round 1 finding is fixed, and any new bugs with file:line.", "expect": {"notCalls": ["link_pull_request", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Shows that 'do not watch' in the task does not stop the link. The 'work on' wording wins."} -{"id": "top-links-background-pr", "area": "pull-requests", "kind": "bad", "pattern": "links a background PR named in the task", "experience": "Asked to fix what PR #123 does not fix, the agent opened its own PR and linked it, then also linked #123, which it never changed.", "rule": "Do not link unrelated PRs mentioned only as background. / register every pull request you create or work on for this thread", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. You are on branch fix/reuse-socket, which has one commit (abc1234) with the fix. Nothing is pushed yet.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "world": {"branch": "fix/reuse-socket", "commits": [{"sha": "abc1234", "subject": "fix(web): keep a healthy socket on visibility change"}], "diff": "diff --git a/apps/web/src/socket.ts b/apps/web/src/socket.ts\n--- a/apps/web/src/socket.ts\n+++ b/apps/web/src/socket.ts\n@@ -1,6 +1,6 @@\n let socket: WebSocket | undefined;\n \n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState !== 'visible') return;\n- socket = openSocket();\n+ if (!socket || socket.readyState !== WebSocket.OPEN) socket = openSocket();\n });\n", "files": {"apps/web/src/socket.ts": "let socket: WebSocket | undefined;\n\ndocument.addEventListener('visibilitychange', () => {\n if (document.visibilityState !== 'visible') return;\n if (!socket || socket.readyState !== WebSocket.OPEN) socket = openSocket();\n});\n"}, "prs": [{"number": 123, "title": "fix(ws): jittered reconnect backoff", "headRefName": "fix/ws-backoff", "diff": "diff --git a/apps/web/src/ws/backoff.ts b/apps/web/src/ws/backoff.ts\n--- a/apps/web/src/ws/backoff.ts\n+++ b/apps/web/src/ws/backoff.ts\n@@ -4,1 +4,1 @@\n- return base * 2 ** attempt;\n+ return base * 2 ** attempt * (0.5 + Math.random());\n"}]}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "# Fix the reconnect storms that #123 does not fix\n\n[#123](https://github.com/acme/app/pull/123) (open, branch fix/ws-backoff) adds jittered backoff. It fixes only about 2% of the storm connects. The rest come from the client opening a new socket on every visibility change while the old one is healthy.\n\nI committed the fix on branch fix/reuse-socket. Push it and open a separate PR. Mention #123 in the body and say how the two PRs relate.", "expect": {"maxCalls": {"link_pull_request": 1, "list_thread_pull_requests": 0}, "linked": ["~/pull/124"], "callsMustNot": [{"tool": "link_pull_request|watch_pull_request", "args": {"url": "~/pull/123"}}]}, "notes": "Claude Opus did this, so it is a good Sonnet reproduction candidate. Pre-fix era (Oct 3), same 'work on' text. Watch is not required (no babysit ask). Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end. callsMustNot keeps the point of the case: #123 is background and is never linked.", "maxSteps": 8} +{"id": "review-child-links-reviewed-pr", "area": "pull-requests", "kind": "bad", "pattern": "review-only child links the PR it reviews", "experience": "A read-only review child linked the PR it only reviewed, then called list_thread_pull_requests. Parent had already linked it.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. Your parent thread already linked https://github.com/acme/app/pull/123. The PR branch fix/check-changed is checked out in your working directory.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "delegateRole": "review", "world": {"branch": "fix/check-changed", "commits": [{"sha": "5b7e2d9", "subject": "feat(scripts): one command checks what the branch changed"}], "diff": "diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts\nnew file mode 100644\n--- /dev/null\n+++ b/scripts/check-changed.ts\n@@ -0,0 +1,9 @@\n+import { execSync } from \"node:child_process\";\n+const base = process.argv[2] ?? \"origin/main\";\n+const files = execSync(`git diff --name-only ${base}`).toString().split(\"\\n\");\n+const ts = files.filter((f) => f.endsWith(\".ts\"));\n+if (ts.length > 0) execSync(`vp lint ${ts.join(\" \")}`, { stdio: \"inherit\" });\n+const diff = execSync(`git diff ${base}`).toString();\n+if (diff.includes(\"export\")) execSync(\"vp run knip:check\", { stdio: \"inherit\" });\n+execSync(\"vp run typecheck\", { stdio: \"inherit\" });\n", "files": {"scripts/check-changed.ts": "import { execSync } from \"node:child_process\";\nconst base = process.argv[2] ?? \"origin/main\";\nconst files = execSync(`git diff --name-only ${base}`).toString().split(\"\\n\");\nconst ts = files.filter((f) => f.endsWith(\".ts\"));\nif (ts.length > 0) execSync(`vp lint ${ts.join(\" \")}`, { stdio: \"inherit\" });\nconst diff = execSync(`git diff ${base}`).toString();\nif (diff.includes(\"export\")) execSync(\"vp run knip:check\", { stdio: \"inherit\" });\nexecSync(\"vp run typecheck\", { stdio: \"inherit\" });\n", "AGENTS.md": "Keep things simple. Run focused tests only.\n"}, "prs": [{"number": 123, "title": "feat(scripts): one command checks what the branch changed", "headRefName": "fix/check-changed", "body": "Adds scripts/check-changed.ts. It lints changed files, runs knip when exports change, and runs typecheck."}]}}, "prompt": "Review pull request https://github.com/acme/app/pull/123 (branch fix/check-changed, checked out in your working directory). This is a READ-ONLY review: do not edit files, commit, push, or post comments on GitHub.\n\nRead `gh pr view 123` and `git diff origin/main...HEAD`.\n\nReport:\n1. Overall verdict in one or two sentences.\n2. Real bugs, each with a failure scenario and file:line.\n3. Design concerns, ranked.\nKeep it short.", "expect": {"notCalls": ["link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "27 of 27 Codex review or scoring children did this. Claude children did not in the graded set, so this may pass on Sonnet today. Keep it as the main A/B target for the PR block rewrite."} +{"id": "scorer-child-links-scored-prs", "area": "pull-requests", "kind": "bad", "pattern": "scoring child links every PR it scores", "experience": "A scoring child linked all 9 PRs it only read and scored, so the child thread carried 9 PRs it never changed.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. No pull requests are linked to this thread.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": {"pullRequests": []}}, "delegateRole": "review", "world": {"files": {"/tmp/audit/SCORE.md": "# Score open pull requests\nFor each PR, read `gh pr view -R acme/app` and `gh pr diff -R acme/app`.\nScore value 1-5 and risk 1-5. Write one JSON line per PR to the output file:\n{\"pr\":n,\"value\":v,\"risk\":r,\"verdict\":\"MERGE|TAKEOVER|SKIP\",\"why\":\"one sentence\"}\nRead-only: never comment, review, label, or merge.\n"}, "prs": [{"number": 101, "title": "fix(web): small bug fix", "headRefName": "fix/small", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/session.ts b/src/session.ts\n--- a/src/session.ts\n+++ b/src/session.ts\n@@ -40,7 +40,9 @@ export function stopTask(task: Task) {\n- if (task.status !== \"running\") return;\n+ if (task.status === \"cancelled\") {\n+ emitTaskRow(task); // resend so stale clients catch up\n+ return;\n+ }\n cancel(task);\n"}, {"number": 102, "title": "feat(server): add a health endpoint", "headRefName": "feat/health", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/server/health.ts b/src/server/health.ts\n--- a/src/server/health.ts\n+++ b/src/server/health.ts\n@@ -1,0 +1,1 @@\n+export const health = () => ({ ok: true });\n"}, {"number": 103, "title": "chore: rename internal helpers", "headRefName": "chore/rename", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}], "diff": "diff --git a/src/util/names.ts b/src/util/names.ts\n--- a/src/util/names.ts\n+++ b/src/util/names.ts\n@@ -3,1 +3,1 @@\n-export function fmt(x: string) {\n+export function formatName(x: string) {\n"}]}}, "prompt": "Read /tmp/audit/SCORE.md and follow it exactly. Score each PR as it is now.\n\nPRs: 101 102 103\nOutput file: /tmp/audit/round5/scores-b.jsonl", "expect": {"notCalls": ["link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Same root cause as review-child-links-reviewed-pr. Over-linking also makes T3 poll more PRs."} +{"id": "review-round-child-links-pr", "area": "pull-requests", "kind": "bad", "pattern": "round-2 review child links the PR under review", "experience": "A round-2 review child was told 'do not watch it' but still linked the PR it reviewed and listed PRs at the end.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. Your parent thread already linked https://github.com/acme/app/pull/123. Branch fix/orphaned-stop is checked out in your working directory.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "delegateRole": "review", "world": {"branch": "fix/orphaned-stop", "commits": [{"sha": "abc1234", "subject": "fix(server): resend the task row when Stop hits a cancelled task"}, {"sha": "9d8e7f6", "subject": "fix(server): Stop always clears an orphaned delegate"}], "diff": "diff --git a/src/session.ts b/src/session.ts\n--- a/src/session.ts\n+++ b/src/session.ts\n@@ -40,7 +40,9 @@ export function stopTask(task: Task) {\n- if (task.status !== \"running\") return;\n+ if (task.status === \"cancelled\") {\n+ emitTaskRow(task); // resend so stale clients catch up\n+ return;\n+ }\n cancel(task);\n", "files": {"src/session.ts": "export function stopTask(task: Task) {\n if (task.status === \"cancelled\") {\n emitTaskRow(task); // resend so stale clients catch up\n return;\n }\n cancel(task);\n}\n"}, "prs": [{"number": 123, "title": "fix(server): Stop always clears an orphaned delegate", "headRefName": "fix/orphaned-stop"}]}}, "prompt": "You are an adversarial code reviewer, round 2. Review `git diff origin/main...HEAD` in your working directory (branch fix/orphaned-stop, PR https://github.com/acme/app/pull/123). The new commit since round 1 is HEAD (abc1234). Do NOT edit files, commit, push, comment on the PR, or watch it. Read-only.\n\n## Your round 1 findings\n1. P1: Stop does nothing when the task row is already cancelled but the client still shows it running.\n2. P2: The new helper writes the parent node twice.\n\n## Author response\nBoth fixed in abc1234.\n\nReport: VERDICT (ship or fix first), whether each round 1 finding is fixed, and any new bugs with file:line.", "expect": {"notCalls": ["link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Shows that 'do not watch' in the task does not stop the link. The 'work on' wording wins."} +{"id": "top-links-background-pr", "area": "pull-requests", "kind": "bad", "pattern": "links a background PR named in the task", "experience": "Asked to fix what PR #123 does not fix, the agent opened its own PR and linked it, then also linked #123, which it never changed.", "rule": "Do not link unrelated PRs mentioned only as background. / register every pull request you create or work on for this thread", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. You are on branch fix/reuse-socket, which has one commit (abc1234) with the fix. Nothing is pushed yet.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": "default"}, "world": {"branch": "fix/reuse-socket", "commits": [{"sha": "abc1234", "subject": "fix(web): keep a healthy socket on visibility change"}], "diff": "diff --git a/apps/web/src/socket.ts b/apps/web/src/socket.ts\n--- a/apps/web/src/socket.ts\n+++ b/apps/web/src/socket.ts\n@@ -1,6 +1,6 @@\n let socket: WebSocket | undefined;\n \n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState !== 'visible') return;\n- socket = openSocket();\n+ if (!socket || socket.readyState !== WebSocket.OPEN) socket = openSocket();\n });\n", "files": {"apps/web/src/socket.ts": "let socket: WebSocket | undefined;\n\ndocument.addEventListener('visibilitychange', () => {\n if (document.visibilityState !== 'visible') return;\n if (!socket || socket.readyState !== WebSocket.OPEN) socket = openSocket();\n});\n"}, "prs": [{"number": 123, "title": "fix(ws): jittered reconnect backoff", "headRefName": "fix/ws-backoff", "diff": "diff --git a/apps/web/src/ws/backoff.ts b/apps/web/src/ws/backoff.ts\n--- a/apps/web/src/ws/backoff.ts\n+++ b/apps/web/src/ws/backoff.ts\n@@ -4,1 +4,1 @@\n- return base * 2 ** attempt;\n+ return base * 2 ** attempt * (0.5 + Math.random());\n"}]}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "# Fix the reconnect storms that #123 does not fix\n\n[#123](https://github.com/acme/app/pull/123) (open, branch fix/ws-backoff) adds jittered backoff. It fixes only about 2% of the storm connects. The rest come from the client opening a new socket on every visibility change while the old one is healthy.\n\nI committed the fix on branch fix/reuse-socket. Push it and open a separate PR. Mention #123 in the body and say how the two PRs relate.", "expect": {"maxCalls": {"link_pull_request": 1, "list_thread_pull_requests": 0, "link_pull_requests": 0}, "linked": ["~/pull/124"], "callsMustNot": [{"tool": "link_pull_request|watch_pull_request", "args": {"url": "~/pull/123"}}]}, "notes": "Claude Opus did this, so it is a good Sonnet reproduction candidate. Pre-fix era (Oct 3), same 'work on' text. Watch is not required (no babysit ask). Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end. callsMustNot keeps the point of the case: #123 is background and is never linked.", "maxSteps": 8} {"id": "list-prs-first-no-pr-work", "area": "pull-requests", "kind": "bad", "pattern": "list_thread_pull_requests with no PR work", "experience": "On 'why is CI failing', the first T3 call was list_thread_pull_requests on a thread with no PRs. It returned an empty list.", "rule": "Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread.", "mocks": {"list_thread_pull_requests": {"pullRequests": [], "chains": []}}, "shellMocks": [{"match": "gh run list", "output": "completed\tfailure\tci\tmain\tpush\t9912345\t3m\t2026-10-24T08:40:00Z"}, {"match": "gh run view", "output": "Typecheck\tRun vp run typecheck\tsrc/timeline.test.tsx(49,45): error TS2307: Cannot find module 'mermaid' or its corresponding type declarations."}], "world": {"commits": [{"sha": "f9dd259", "subject": "test(web): warm up mermaid before first render"}, {"sha": "fcb20e4", "subject": "refactor(web): replace mermaid with a lazy renderer"}], "files": {"src/timeline.test.tsx": "import { warmMermaid } from 'mermaid';\n\nbeforeAll(() => warmMermaid());\n", "src/timeline/renderDiagram.ts": "export async function renderDiagram(code: string) {\n const { render } = await import('./lazyRenderer');\n return render(code);\n}\n"}}}, "prompt": "Why the hell is CI failing on main? Fix it right now.", "expect": {"notCalls": ["list_thread_pull_requests"]}, "notes": "Codex made this call in 139 runs with no PR work before the fixes. If the agent opens a PR, linking it is fine; only the list is graded."} -{"id": "list-after-linking-only-pr", "area": "pull-requests", "kind": "bad", "pattern": "list_thread_pull_requests right after linking the only PR", "experience": "Agent opened and linked a revert PR, merged it, then called list_thread_pull_requests to check the one PR it had just linked.", "rule": "Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. Branch revert-120 has one commit that reverts #120. Nothing is pushed yet. The user has admin rights and allows an admin merge.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": {"pullRequests": [{"number": 124, "url": "https://github.com/acme/app/pull/124", "state": "merged"}]}}, "world": {"branch": "revert-120", "commits": [{"sha": "abc1234", "subject": "Revert \"refactor(web): move thread selector to the shared list\""}], "diff": "diff --git a/apps/web/src/ThreadSelector.tsx b/apps/web/src/ThreadSelector.tsx\n--- a/apps/web/src/ThreadSelector.tsx\n+++ b/apps/web/src/ThreadSelector.tsx\n@@ -10,1 +10,1 @@\n- return ;\n+ return ;\n", "prs": [{"number": 120, "title": "refactor(web): move thread selector to the shared list", "state": "MERGED", "headRefName": "refactor/selector"}]}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "#120 broke project grouping in the new thread selector. File a revert PR and merge it right now. Admin merge is fine.", "expect": {"notCalls": ["list_thread_pull_requests"], "maxCalls": {"link_pull_request": 1}}, "notes": "31 of 32 graded list calls added nothing. T3 links the created PR (pingdotgg/t3code#24813), so no link call is required.", "maxSteps": 7} -{"id": "relink-and-list-on-merge-ask", "area": "pull-requests", "kind": "bad", "pattern": "re-link and list on a later turn", "experience": "On 'Merge it now', the agent linked the already-linked PR again (alreadyLinked) and listed PRs after the merge.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Release builds failing. Fix. Get a PR up asap."}, {"role": "assistant", "text": "Opened [#123](https://github.com/acme/app/pull/123). It ignores the build output folder in the deploy upload, which caused both release failures.", "toolCalls": [{"tool": "Bash", "input": {"command": "gh run view 9931 --log-failed | tail -5"}, "result": "upload\tError: payload too large: dist/ (412 MB) exceeds the 250 MB upload limit"}, {"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/scripts/deploy.ts", "old_string": " include: [\"**/*\"],", "new_string": " include: [\"**/*\"], exclude: [\"dist/**\"],"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/scripts/deploy.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(release): skip the build output folder in the deploy upload\""}, "result": "[fix/release-upload abc1234] fix(release): skip the build output folder in the deploy upload\n 1 file changed"}, {"tool": "Bash", "input": {"command": "git push -u origin fix/release-upload"}, "result": "To github.com:acme/app.git\n * [new branch] fix/release-upload -> fix/release-upload\nbranch 'fix/release-upload' set up to track 'origin/fix/release-upload'."}, {"tool": "Bash", "input": {"command": "gh pr create --base main --title \"fix(release): skip the build output folder in the deploy upload\" --fill"}, "result": "https://github.com/acme/app/pull/123"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}]}], "mocks": {"link_pull_request": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": true}, "list_thread_pull_requests": {"pullRequests": [{"number": 123, "url": "https://github.com/acme/app/pull/123", "state": "merged"}]}}, "world": {"branch": "fix/release-upload", "commits": [{"sha": "abc1234", "subject": "fix(release): skip the build output folder in the deploy upload"}], "diff": "diff --git a/scripts/deploy.ts b/scripts/deploy.ts\n--- a/scripts/deploy.ts\n+++ b/scripts/deploy.ts\n@@ -12,1 +12,1 @@\n- include: [\"**/*\"],\n+ include: [\"**/*\"], exclude: [\"dist/**\"],\n", "prs": [{"number": 123, "title": "fix(release): skip the build output folder in the deploy upload", "headRefName": "fix/release-upload", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}]}]}}, "prompt": "Merge it now", "expect": {"notCalls": ["link_pull_request", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Codex per-turn ceremony. Seen in 3 more threads (see source.also)."} +{"id": "list-after-linking-only-pr", "area": "pull-requests", "kind": "bad", "pattern": "list_thread_pull_requests right after linking the only PR", "experience": "Agent opened and linked a revert PR, merged it, then called list_thread_pull_requests to check the one PR it had just linked.", "rule": "Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. Branch revert-120 has one commit that reverts #120. Nothing is pushed yet. The user has admin rights and allows an admin merge.", "mocks": {"link_pull_request": "default", "list_thread_pull_requests": {"pullRequests": [{"number": 124, "url": "https://github.com/acme/app/pull/124", "state": "merged"}]}}, "world": {"branch": "revert-120", "commits": [{"sha": "abc1234", "subject": "Revert \"refactor(web): move thread selector to the shared list\""}], "diff": "diff --git a/apps/web/src/ThreadSelector.tsx b/apps/web/src/ThreadSelector.tsx\n--- a/apps/web/src/ThreadSelector.tsx\n+++ b/apps/web/src/ThreadSelector.tsx\n@@ -10,1 +10,1 @@\n- return ;\n+ return ;\n", "prs": [{"number": 120, "title": "refactor(web): move thread selector to the shared list", "state": "MERGED", "headRefName": "refactor/selector"}]}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "#120 broke project grouping in the new thread selector. File a revert PR and merge it right now. Admin merge is fine.", "expect": {"notCalls": ["list_thread_pull_requests"], "maxCalls": {"link_pull_request": 1, "link_pull_requests": 0}}, "notes": "31 of 32 graded list calls added nothing. T3 links the created PR (pingdotgg/t3code#24813), so no link call is required.", "maxSteps": 7} +{"id": "relink-and-list-on-merge-ask", "area": "pull-requests", "kind": "bad", "pattern": "re-link and list on a later turn", "experience": "On 'Merge it now', the agent linked the already-linked PR again (alreadyLinked) and listed PRs after the merge.", "rule": "register every pull request you create or work on for this thread / Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Release builds failing. Fix. Get a PR up asap."}, {"role": "assistant", "text": "Opened [#123](https://github.com/acme/app/pull/123). It ignores the build output folder in the deploy upload, which caused both release failures.", "toolCalls": [{"tool": "Bash", "input": {"command": "gh run view 9931 --log-failed | tail -5"}, "result": "upload\tError: payload too large: dist/ (412 MB) exceeds the 250 MB upload limit"}, {"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/scripts/deploy.ts", "old_string": " include: [\"**/*\"],", "new_string": " include: [\"**/*\"], exclude: [\"dist/**\"],"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/scripts/deploy.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(release): skip the build output folder in the deploy upload\""}, "result": "[fix/release-upload abc1234] fix(release): skip the build output folder in the deploy upload\n 1 file changed"}, {"tool": "Bash", "input": {"command": "git push -u origin fix/release-upload"}, "result": "To github.com:acme/app.git\n * [new branch] fix/release-upload -> fix/release-upload\nbranch 'fix/release-upload' set up to track 'origin/fix/release-upload'."}, {"tool": "Bash", "input": {"command": "gh pr create --base main --title \"fix(release): skip the build output folder in the deploy upload\" --fill"}, "result": "https://github.com/acme/app/pull/123"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}]}], "mocks": {"link_pull_request": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": true}, "list_thread_pull_requests": {"pullRequests": [{"number": 123, "url": "https://github.com/acme/app/pull/123", "state": "merged"}]}}, "world": {"branch": "fix/release-upload", "commits": [{"sha": "abc1234", "subject": "fix(release): skip the build output folder in the deploy upload"}], "diff": "diff --git a/scripts/deploy.ts b/scripts/deploy.ts\n--- a/scripts/deploy.ts\n+++ b/scripts/deploy.ts\n@@ -12,1 +12,1 @@\n- include: [\"**/*\"],\n+ include: [\"**/*\"], exclude: [\"dist/**\"],\n", "prs": [{"number": 123, "title": "fix(release): skip the build output folder in the deploy upload", "headRefName": "fix/release-upload", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Test", "state": "pass"}]}]}}, "prompt": "Merge it now", "expect": {"notCalls": ["link_pull_request", "link_pull_requests", "list_thread_pull_requests", "watch_pull_request"]}, "notes": "Codex per-turn ceremony. Seen in 3 more threads (see source.also)."} {"id": "pr-created-not-linked", "area": "pull-requests", "kind": "bad", "pattern": "PR created but never linked", "experience": "In a long non-PR conversation, the user asked if the changes were pushed. The agent pushed, opened a PR, and never linked it.", "rule": "Call link_pull_request with the full PR URL immediately after creating a PR", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Mount /tmp on disk, not RAM, and make swap at least 32 GB by default in the image builder."}, {"role": "assistant", "text": "Done on branch feat/disk-defaults (commit abc1234). /tmp is now on disk and swap defaults to 32 GB. The 11 builder tests pass. Nothing is pushed yet.", "toolCalls": [{"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/builder/defaults.py", "old_string": "TMP_ON_TMPFS = True\nSWAP_GB = 8", "new_string": "TMP_ON_TMPFS = False\nSWAP_GB = 32"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/builder/defaults.py has been updated successfully."}, {"tool": "Bash", "input": {"command": "git checkout -b feat/disk-defaults"}, "result": "Switched to a new branch 'feat/disk-defaults'"}, {"tool": "Bash", "input": {"command": "python3 -m unittest discover builder"}, "result": "...........\n----------------------------------------------------------------------\nRan 11 tests in 0.4s\n\nOK"}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"feat(builder): mount /tmp on disk and default swap to 32 GB\""}, "result": "[feat/disk-defaults abc1234] feat(builder): mount /tmp on disk and default swap to 32 GB\n 1 file changed"}]}], "mocks": {"link_pull_request": "default"}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}, {"match": "python3 -m unittest|pytest", "output": "Ran 11 tests in 0.4s\n\nOK"}], "world": {"branch": "feat/disk-defaults", "commits": [{"sha": "abc1234", "subject": "feat(builder): mount /tmp on disk and default swap to 32 GB"}], "diff": "diff --git a/builder/defaults.py b/builder/defaults.py\n--- a/builder/defaults.py\n+++ b/builder/defaults.py\n@@ -10,2 +10,2 @@\n-TMP_ON_TMPFS = True\n-SWAP_GB = 8\n+TMP_ON_TMPFS = False\n+SWAP_GB = 32\n", "files": {"builder/defaults.py": "TMP_ON_TMPFS = False\nSWAP_GB = 32\n"}}}, "prompt": "Did you push up your changes? Open a PR so we can get this on main.", "expect": {"linked": ["~/pull/124"]}, "notes": "Rare (3 of 240 v2 PR threads). The real prompt did not say 'PR'; I added 'Open a PR' so the case is gradable. The real user then said 'No need to PR it'. Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end.", "maxSteps": 10} {"id": "child-pr-not-linked", "area": "pull-requests", "kind": "bad", "pattern": "delegated child creates PR, never links it", "experience": "An implementation child opened its PR and did not link it, so the PR was not tracked on the child thread.", "rule": "Call link_pull_request with the full PR URL immediately after creating a PR / As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task in your own worktree. Branch t3/sql-lock-wait has one commit with the change. Nothing is pushed yet.", "mocks": {"link_pull_request": "default", "watch_pull_request": "default"}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}, {"match": "git (fetch|rebase|pull)", "output": "Successfully rebased and updated refs/heads/t3/sql-lock-wait."}], "delegateRole": "implementation", "world": {"branch": "t3/sql-lock-wait", "commits": [{"sha": "abc1234", "subject": "feat(server): show lock wait time on SQL spans"}], "diff": "diff --git a/apps/server/src/db/trace.ts b/apps/server/src/db/trace.ts\n--- a/apps/server/src/db/trace.ts\n+++ b/apps/server/src/db/trace.ts\n@@ -1,4 +1,5 @@\n export function traceQuery(span: Span, result: QueryResult) {\n span.setAttribute(\"db.rows\", result.rowCount);\n+ span.setAttribute(\"db.lock_wait_ms\", result.lockWaitMs);\n span.end();\n }\n", "files": {"apps/server/src/db/trace.ts": "export function traceQuery(span: Span, result: QueryResult) {\n span.setAttribute(\"db.rows\", result.rowCount);\n span.setAttribute(\"db.lock_wait_ms\", result.lockWaitMs);\n span.end();\n}\n"}}}, "prompt": "Goal: open ONE real (non-draft) PR in acme/app for the commit already on your branch (t3/sql-lock-wait). It makes SQL spans show lock wait time. Rebase onto latest origin/main first. Use a conventional commit title and plain English in the PR body. Report back when the PR is open.", "expect": {"notCalls": ["watch_pull_request"], "shellMustNot": ["\\bsleep \\d", "gh pr checks[^\\n]*--watch", "gh run watch", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", "\\bfor \\w+ in [^\\n]*;\\s*do[^\\n]*\\bgh pr checks\\b", "pr-wait"], "textMust": ["github\\.com/acme/app/pull/124", "\\b[0-9a-f]{7,40}\\b"], "linked": ["~/pull/124"]}, "notes": "Also checks the child return contract: PR URL and head SHA in the final text, no watch, no poll. Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end.", "maxSteps": 8} {"id": "child-babysit-calls-watch", "area": "pull-requests", "kind": "bad", "pattern": "delegated child calls watch_pull_request", "experience": "The parent's brief said take it to a merge-ready PR. The child linked its PR, then called watch_pull_request, which fails for subagents.", "rule": "As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task in your own worktree. Branch t3/connect-diagnostics has one commit with the change. Nothing is pushed yet.", "mocks": {"link_pull_request": "default", "watch_pull_request": "default"}, "delegateRole": "implementation", "world": {"branch": "t3/connect-diagnostics", "commits": [{"sha": "abc1234", "subject": "feat(web): show which step of a remote connection failed"}], "diff": "diff --git a/apps/web/src/connect/errors.ts b/apps/web/src/connect/errors.ts\n--- a/apps/web/src/connect/errors.ts\n+++ b/apps/web/src/connect/errors.ts\n@@ -1,5 +1,5 @@\n export type ConnectStep = 'dns' | 'tls' | 'auth' | 'socket';\n \n export function connectErrorMessage(step: ConnectStep) {\n- return 'Connection failed';\n+ return `Connection failed at the ${step} step`;\n }\n", "files": {"apps/web/src/connect/errors.ts": "export type ConnectStep = 'dns' | 'tls' | 'auth' | 'socket';\n\nexport function connectErrorMessage(step: ConnectStep) {\n return `Connection failed at the ${step} step`;\n}\n"}}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "Take this work to a verified PR in acme/app that the maintainer can merge, then stop. Do not merge. Babysit the PR until CI is green and every bot review comment is handled. Register the PR with `link_pull_request` when it exists.\n\nThe change is committed on your branch t3/connect-diagnostics. It shows which part of a remote connection failed. Push it and open the PR.", "expect": {"notCalls": ["watch_pull_request", "Monitor", "ScheduleWakeup"], "shellMustNot": ["\\bsleep \\d", "gh pr checks[^\\n]*--watch", "gh run watch", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", "\\bfor \\w+ in [^\\n]*;\\s*do[^\\n]*\\bgh pr checks\\b", "pr-wait"], "textMust": ["github\\.com/acme/app/pull/124", "\\b[0-9a-f]{7,40}\\b"]}, "notes": "The real brief said 'Use the full-stop skill', which says babysit. The case puts 'babysit' in the brief so it does not need the skill. Good: return URL, head SHA, and pending checks. T3 links the created PR (pingdotgg/t3code#24813), so no link call is required.", "maxSteps": 12} {"id": "babysit-uses-monitor", "area": "pull-requests", "kind": "bad", "pattern": "babysit with Monitor/ScheduleWakeup instead of watch_pull_request", "experience": "On 'file the PR and babysit it', the agent linked the PR and armed a Monitor script, re-arming it 4 times, with no watch_pull_request.", "rule": "when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn ... do not poll or run your own watcher", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "cmd+enter no longer sends a new thread to the background. Fix it."}, {"role": "assistant", "text": "Fixed on branch fix/mod-enter-background (commit abc1234). The new-thread shortcut handler did not pass the background flag. The shortcut tests pass, including a new one for cmd+enter. Want me to open a PR?", "toolCalls": [{"tool": "Bash", "input": {"command": "rg -n \"createThread\" apps/web/src/shortcuts.ts"}, "result": "apps/web/src/shortcuts.ts:2: createThread({ text });"}, {"tool": "Bash", "input": {"command": "git checkout -b fix/mod-enter-background"}, "result": "Switched to a new branch 'fix/mod-enter-background'"}, {"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/shortcuts.ts", "old_string": " createThread({ text });", "new_string": " createThread({ text, background: event.metaKey });"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/shortcuts.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "pnpm vitest run apps/web/src/shortcuts.test.ts"}, "result": " Test Files 1 passed (1)\n Tests 6 passed (6)"}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(web): cmd+enter sends a new thread to the background again\""}, "result": "[fix/mod-enter-background abc1234] fix(web): cmd+enter sends a new thread to the background again\n 1 file changed"}]}], "mocks": {"link_pull_request": "default", "watch_pull_request": "default"}, "world": {"branch": "fix/mod-enter-background", "commits": [{"sha": "abc1234", "subject": "fix(web): cmd+enter sends a new thread to the background again"}], "diff": "diff --git a/apps/web/src/shortcuts.ts b/apps/web/src/shortcuts.ts\n--- a/apps/web/src/shortcuts.ts\n+++ b/apps/web/src/shortcuts.ts\n@@ -3,4 +3,4 @@\n // Enter sends; cmd/ctrl+enter sends the new thread to the background.\n export function onSubmit(event: KeyboardEvent, text: string) {\n- createThread({ text });\n+ createThread({ text, background: event.metaKey || event.ctrlKey });\n }\n", "files": {"apps/web/src/shortcuts.ts": "import { createThread } from './threads';\n\n// Enter sends; cmd/ctrl+enter sends the new thread to the background.\nexport function onSubmit(event: KeyboardEvent, text: string) {\n createThread({ text, background: event.metaKey || event.ctrlKey });\n}\n", "apps/web/src/shortcuts.test.ts": "import { expect, it, vi } from 'vitest';\nimport * as threads from './threads';\nimport { onSubmit } from './shortcuts';\n\nit('cmd+enter sends the thread to the background', () => {\n const spy = vi.spyOn(threads, 'createThread').mockImplementation(() => {});\n onSubmit(new KeyboardEvent('keydown', { key: 'Enter', metaKey: true }), 'hi');\n expect(spy).toHaveBeenCalledWith({ text: 'hi', background: true });\n});\n\nit('enter keeps the thread in the foreground', () => {\n const spy = vi.spyOn(threads, 'createThread').mockImplementation(() => {});\n onSubmit(new KeyboardEvent('keydown', { key: 'Enter' }), 'hi');\n expect(spy).toHaveBeenCalledWith({ text: 'hi', background: false });\n});\n"}}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}]}, "prompt": "oh I'm sure it's fine, just file the PR and babysit it already", "expect": {"calls": [{"tool": "watch_pull_request", "args": {"url": "~/pull/124"}}], "notCalls": ["Monitor", "ScheduleWakeup", "CronCreate"], "shellMustNot": ["\\bsleep \\d", "gh pr checks[^\\n]*--watch", "gh run watch", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", "\\bfor \\w+ in [^\\n]*;\\s*do[^\\n]*\\bgh pr checks\\b", "pr-wait"], "endsTurnAfter": "watch_pull_request"}, "notes": "Oct 3, when the babysit-pr skill ran pr-wait in Monitor. 6 of 38 v2 babysit asks used Monitor or ScheduleWakeup. watch_pull_request links the PR, so a separate link call is optional.", "maxSteps": 16} {"id": "merge-when-ready-poll-loop", "area": "pull-requests", "kind": "bad", "pattern": "watch then own gh poll loop", "experience": "On 'merge when you think it's ready', the agent called watch_pull_request, then ran an until-loop on gh pr checks with sleep 20 in the same turn.", "rule": "when asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn ... do not poll or run your own watcher", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Okay this looks good to me. Any risk merging it?"}, {"role": "assistant", "text": "Low risk. [#123](https://github.com/acme/app/pull/123) only adds an accessible title to the HTML preview frame, and the new test covers it. Two checks are still running: Check and Test Web.", "toolCalls": [{"tool": "Bash", "input": {"command": "gh pr view 123 --json title,files --jq '.title, .files[].path'"}, "result": "fix(web): HTML preview frame gets an accessible title\napps/web/src/HtmlPreview.tsx\napps/web/src/HtmlPreview.test.tsx"}, {"tool": "Bash", "input": {"command": "gh pr checks 123"}, "result": "Lint\tpass\t41s\nTypecheck\tpass\t1m2s\nCheck\tpending\t0\nTest Web\tpending\t0"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}]}], "mocks": {"watch_pull_request": "default", "link_pull_request": "default"}, "world": {"branch": "fix/html-preview-title", "commits": [{"sha": "abc1234", "subject": "fix(web): HTML preview frame gets an accessible title"}], "diff": "diff --git a/apps/web/src/HtmlPreview.tsx b/apps/web/src/HtmlPreview.tsx\n--- a/apps/web/src/HtmlPreview.tsx\n+++ b/apps/web/src/HtmlPreview.tsx\n@@ -1,3 +1,3 @@\n export function HtmlPreview({ html }: { html: string }) {\n- return