diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 86860a90b262..c6d9401726d0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.44", + "version": "0.0.99-gitcache.6", "private": true, "type": "module", "main": "dist-electron/boot.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index 32d9e2e922b2..1cbe86132424 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.44", + "version": "0.0.99-gitcache.6", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts index f83f1dd1b9fa..c99cb820bd77 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.test.ts @@ -20,6 +20,7 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService, @@ -108,8 +109,12 @@ describe("ThreadDeletionReactor drain", () => { const terminalManager = { close: () => Effect.void, } as unknown as TerminalManager.TerminalManager["Service"]; + const directory = { + remove: () => Effect.void, + } as unknown as ProviderSessionDirectory["Service"]; const layer = ThreadDeletionReactorLive.pipe( Layer.provide(Layer.succeed(ProviderService, providerService)), + Layer.provide(Layer.succeed(ProviderSessionDirectory, directory)), Layer.provide(Layer.succeed(TerminalManager.TerminalManager, terminalManager)), Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), ); @@ -136,4 +141,47 @@ describe("ThreadDeletionReactor drain", () => { ).pipe(Effect.provide(layer)); }), ); + + effectIt.effect("forgets the provider binding after stopping the session", () => + Effect.gen(function* () { + // Stopping a session ends in a directory upsert, so removing the binding + // first would let the stop re-insert the row this cleanup exists to drop. + const cleanups: Array = []; + const engine = { + latestSequence: Effect.succeed(0), + streamDomainEvents: Stream.make(deletedEvent(1)), + } as unknown as OrchestrationEngineShape; + const providerService = { + stopSession: (input: { readonly threadId: ThreadId }) => + Effect.sync(() => { + cleanups.push(`stop:${input.threadId}`); + }), + } as unknown as ProviderServiceShape; + const terminalManager = { + close: () => Effect.void, + } as unknown as TerminalManager.TerminalManager["Service"]; + const directory = { + remove: (removedThreadId: ThreadId) => + Effect.sync(() => { + cleanups.push(`remove:${removedThreadId}`); + }), + } as unknown as ProviderSessionDirectory["Service"]; + const layer = ThreadDeletionReactorLive.pipe( + Layer.provide(Layer.succeed(ProviderService, providerService)), + Layer.provide(Layer.succeed(ProviderSessionDirectory, directory)), + Layer.provide(Layer.succeed(TerminalManager.TerminalManager, terminalManager)), + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const reactor = yield* ThreadDeletionReactor; + yield* reactor.start(); + yield* reactor.drainThrough(1); + + expect(cleanups).toEqual([`stop:${threadId}`, `remove:${threadId}`]); + }), + ).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 092ca1b1d471..9168846f4cdc 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -7,6 +7,7 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { @@ -40,6 +41,7 @@ export const logCleanupCauseUnlessInterrupted = ({ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; + const providerSessionDirectory = yield* ProviderSessionDirectory; const terminalManager = yield* TerminalManager.TerminalManager; const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => @@ -49,6 +51,15 @@ const make = Effect.gen(function* () { threadId, }); + // Nothing else prunes the runtime table, so a deleted thread would keep its + // binding for the life of the install. + const forgetProviderBinding = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + logCleanupCauseUnlessInterrupted({ + effect: providerSessionDirectory.remove(threadId), + message: "thread deletion cleanup skipped provider binding removal", + threadId, + }); + const closeThreadTerminals = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ effect: terminalManager.close({ threadId, deleteHistory: true }), @@ -61,6 +72,7 @@ const make = Effect.gen(function* () { ) { const { threadId } = event.payload; yield* stopProviderSession(threadId); + yield* forgetProviderBinding(threadId); yield* closeThreadTerminals(threadId); }); diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 2438edca8b1b..c024d9bfa5d4 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -233,6 +233,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { upsert: (binding) => Effect.sync(() => void bindings.push(binding)), getProvider: () => Effect.die("unused"), recordImportedTranscript: () => Effect.void, + remove: () => Effect.void, getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), @@ -338,6 +339,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { upsert: () => Effect.die("must not bind a scanner skip"), getProvider: () => Effect.die("unused"), recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getBinding: () => Effect.die("must not read a scanner skip binding"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), @@ -415,6 +417,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { }, getProvider: () => Effect.die("unused"), recordImportedTranscript: () => Effect.void, + remove: () => Effect.void, getBinding: () => Effect.succeed(bindings[0] === undefined ? Option.none() : Option.some(bindings[0])), listThreadIds: () => Effect.die("unused"), @@ -457,6 +460,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { upsert: () => Effect.die("must not replace an active binding"), getProvider: () => Effect.die("unused"), recordImportedTranscript: () => Effect.void, + remove: () => Effect.void, getBinding: () => Effect.succeedSome(runningBinding), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), @@ -512,6 +516,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { upsert: () => Effect.die("must not bind malformed or wrong-project sessions"), getProvider: () => Effect.die("unused"), recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 9aff859521fc..1940cdbcf8f3 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -226,6 +226,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeedNone, + remove: () => Effect.void, listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 40d034089ae9..ff04252bbd50 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -580,6 +580,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeedNone, + remove: () => Effect.void, listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 7acf3fcb7b01..b4e6d143a27a 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -5030,6 +5030,7 @@ const boundedListing = makeProviderServiceLayer({ recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"), getBinding, + remove: () => Effect.void, listThreadIds, listBindings: () => Effect.die("ProviderService.listSessions does not use listBindings"), }, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 0e9de8fb868f..2ed82de068e8 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -176,6 +176,13 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Effect.mapError(toPersistenceError("ProviderSessionDirectory.recordImportedTranscript")), ); + const remove: ProviderSessionDirectoryShape["remove"] = (threadId) => + repository + .deleteByThreadId({ threadId }) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")), + ); + const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), @@ -199,6 +206,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { recordImportedTranscript, getProvider, getBinding, + remove, listThreadIds, listBindings, } satisfies ProviderSessionDirectoryShape; diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 1b5c47ec1d62..1fd46a8b133f 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -65,6 +65,11 @@ export interface ProviderSessionDirectoryShape { threadId: ThreadId, ) => Effect.Effect, ProviderSessionDirectoryReadError>; + /** Forgets a thread's runtime binding, for example when the thread is deleted. */ + readonly remove: ( + threadId: ThreadId, + ) => Effect.Effect; + readonly listThreadIds: () => Effect.Effect< ReadonlyArray, ProviderSessionDirectoryPersistenceError diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 050650b11515..d07f7003e5df 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -152,6 +152,7 @@ it.effect("marks active running sessions that have persisted resume state", () = ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -281,6 +282,7 @@ it.effect.each( ), ), recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -365,7 +367,6 @@ it.effect.each( ); }), ); - it.effect("does not continue archived or deleted marked sessions", () => { const archived = makeThread( "thread-continue-archived", @@ -412,6 +413,7 @@ it.effect("does not continue archived or deleted marked sessions", () => { }, upsert: () => Effect.void, recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -466,6 +468,7 @@ it.effect("retries continuation preparation before settling a persistent failure }), upsert: () => Effect.void, recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -538,6 +541,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -616,6 +620,7 @@ it.effect( }), upsert: () => Effect.fail(writeFailure), recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -654,6 +659,7 @@ it.effect("retries failed projections and continues after a persistent failure", getBinding: () => Effect.succeedNone, upsert: () => Effect.void, recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -704,6 +710,7 @@ it.effect("does not fail startup when the live provider session inventory cannot getBinding: () => Effect.die("unused"), upsert: () => Effect.die("unused"), recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -768,6 +775,7 @@ for (const scenario of [ upserts.push(binding); }), recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -842,6 +850,7 @@ for (const preparedStatus of [ yield* Deferred.succeed(cleared, undefined); }), recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => @@ -946,6 +955,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", ( binding = next; }), recordImportedTranscript: () => Effect.die("unused"), + remove: () => Effect.void, getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 0ad2450eb192..33ba595d9dd4 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -1,7 +1,9 @@ import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as VcsProcess from "./VcsProcess.ts"; @@ -93,6 +95,54 @@ describe("VcsDriverRegistry", () => { }).pipe(Effect.provide(layer)); }); + it.effect("keeps repository detection cached for minutes, not seconds", () => { + const calls: VcsProcess.VcsProcessInput[] = []; + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( + Layer.provide(NodeServices.layer), + Layer.provide( + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + const command = normalizeGitArgs(input.args).join(" "); + if (command === "rev-parse --is-inside-work-tree") { + return processOutput("true\n"); + } + if (command === "rev-parse --show-toplevel") { + return processOutput("/repo\n"); + } + if (command === "rev-parse --git-common-dir") { + return processOutput("/repo/.git\n"); + } + return processOutput(""); + }), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); + const callsAfterFirstResolve = calls.length; + assert.isAbove(callsAfterFirstResolve, 0); + + // Detection used to expire after two seconds, so every later VCS call + // spawned git again for the same checkout. + yield* TestClock.adjust(Duration.minutes(1)); + yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); + assert.strictEqual(calls.length, callsAfterFirstResolve); + + yield* TestClock.adjust(Duration.minutes(6)); + yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); + assert.isAbove(calls.length, callsAfterFirstResolve); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), layer))); + }); + it.effect("detects a repository created after a negative lookup", () => { let insideWorkTreeChecks = 0; const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index f40e1dadea3e..c5f6034f058b 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -12,7 +12,12 @@ import * as VcsProjectConfig from "./VcsProjectConfig.ts"; import * as VcsDriver from "./VcsDriver.ts"; const DETECTION_CACHE_CAPACITY = 2_048; -const DETECTION_CACHE_TTL = Duration.seconds(2); +// Detection spawns three git processes per cwd. A two second window meant +// every VCS call past that re-ran them, which dominates latency when many +// projects refresh at once. Whether a directory is a git repository is stable, +// and a negative result is still never cached, so `git init` is picked up at +// once. +const DETECTION_CACHE_TTL = Duration.minutes(5); export interface VcsDriverResolveInput { readonly cwd: string; diff --git a/apps/web/package.json b/apps/web/package.json index 587faeb2ac02..4f909d160dcc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.44", + "version": "0.0.99-gitcache.6", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4a606bb46c7..6d7c02722dbf 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -21,6 +21,11 @@ import { isContextMenuPointerDown, isSidebarNestedLinkClick, isSidebarThreadWorking, + isBotThreadTitle, + matchesSidebarThreadFilters, + sidebarThreadAccountKey, + sidebarThreadFilterButtonLabel, + SIDEBAR_UNAVAILABLE_ACCOUNT_LABEL, isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, @@ -71,6 +76,69 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("sidebar conversation filters", () => { + const thread = { + title: "bot-codex--review", + environmentId: "env-one", + modelSelection: { instanceId: "codex-default" }, + session: { providerInstanceId: "codex-work" }, + }; + + it("identifies dispatched bot titles without matching ordinary mentions", () => { + expect(isBotThreadTitle(thread.title)).toBe(true); + expect(isBotThreadTitle("Fix bot--prefix parsing")).toBe(false); + expect(isBotThreadTitle("bot-codex review")).toBe(false); + }); + + it("uses the session account and keeps environments separate", () => { + expect(sidebarThreadAccountKey(thread)).toBe('["env-one","codex-work"]'); + expect(sidebarThreadAccountKey({ ...thread, session: null })).toBe( + '["env-one","codex-default"]', + ); + expect( + matchesSidebarThreadFilters(thread, { + hideBotThreads: false, + accountKey: '["env-two","codex-work"]', + isOpen: false, + }), + ).toBe(false); + }); + + it("combines bot and account filters while keeping the open thread visible", () => { + const filters = { + hideBotThreads: true, + accountKey: '["env-one","codex-work"]', + isOpen: false, + }; + expect(matchesSidebarThreadFilters(thread, filters)).toBe(false); + expect(matchesSidebarThreadFilters({ ...thread, title: "Review" }, filters)).toBe(true); + expect(matchesSidebarThreadFilters(thread, { ...filters, isOpen: true })).toBe(true); + }); + + it("names what the trigger filters and which filters are on", () => { + const accountOptions = [{ key: '["env-one","codex-work"]', label: "AU" }]; + const label = (hideBotThreads: boolean, selectedAccountKey: string | null) => + sidebarThreadFilterButtonLabel({ hideBotThreads, selectedAccountKey, accountOptions }); + + expect(label(false, null)).toBe("Filter threads by bot and account"); + expect(label(true, null)).toBe("Filter threads by bot and account: bot threads hidden"); + expect(label(false, '["env-one","codex-work"]')).toBe("Filter threads by bot and account: AU"); + expect(label(true, '["env-one","codex-work"]')).toBe( + "Filter threads by bot and account: bot threads hidden, AU", + ); + }); + + it("names a dropped account the same way the menu row does", () => { + expect( + sidebarThreadFilterButtonLabel({ + hideBotThreads: false, + selectedAccountKey: '["env-gone","codex-work"]', + accountOptions: [{ key: '["env-one","codex-work"]', label: "AU" }], + }), + ).toBe(`Filter threads by bot and account: ${SIDEBAR_UNAVAILABLE_ACCOUNT_LABEL}`); + }); +}); + describe("resolveSidebarRowAccessibility", () => { it.each([ { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a63b728cd4fa..70fe83007b85 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -821,6 +821,74 @@ export type SidebarThreadStatus = | "failed" | "ready"; +// t3-dispatch titles begin with `bot---`. +const BOT_THREAD_TITLE_PATTERN = /^bot-[a-z0-9]+(?:-[a-z0-9]+)*--/; + +export function isBotThreadTitle(title: string): boolean { + return BOT_THREAD_TITLE_PATTERN.test(title); +} + +type SidebarFilterThread = { + readonly title: string; + readonly environmentId: string; + readonly modelSelection: { readonly instanceId: string }; + readonly session: { readonly providerInstanceId?: string | undefined } | null; +}; + +export function sidebarThreadAccountKey(thread: SidebarFilterThread): string { + return JSON.stringify([ + thread.environmentId, + thread.session?.providerInstanceId ?? thread.modelSelection.instanceId, + ]); +} + +export function matchesSidebarThreadFilters( + thread: SidebarFilterThread, + filters: { + readonly hideBotThreads: boolean; + readonly accountKey: string | null; + readonly isOpen: boolean; + }, +): boolean { + return ( + filters.isOpen || + ((!filters.hideBotThreads || !isBotThreadTitle(thread.title)) && + (filters.accountKey === null || sidebarThreadAccountKey(thread) === filters.accountKey)) + ); +} + +/** + * Shown for a persisted account the current environments no longer offer, both + * as the menu's own row and inside the trigger's name, so what a screen reader + * hears matches what the menu shows. + */ +export const SIDEBAR_UNAVAILABLE_ACCOUNT_LABEL = "Unavailable account"; + +/** + * Accessible name for the filter trigger. It sits beside the project scope + * trigger, whose name is "Filter threads by project", so this one has to name + * what it filters too. The active-state dot is decorative, so the name is the + * only place a screen reader learns a filter is on. + */ +export function sidebarThreadFilterButtonLabel(input: { + readonly hideBotThreads: boolean; + readonly selectedAccountKey: string | null; + readonly accountOptions: ReadonlyArray<{ readonly key: string; readonly label: string }>; +}): string { + const active = [ + ...(input.hideBotThreads ? ["bot threads hidden"] : []), + ...(input.selectedAccountKey === null + ? [] + : [ + input.accountOptions.find((option) => option.key === input.selectedAccountKey)?.label ?? + SIDEBAR_UNAVAILABLE_ACCOUNT_LABEL, + ]), + ]; + return active.length === 0 + ? "Filter threads by bot and account" + : `Filter threads by bot and account: ${active.join(", ")}`; +} + export function shouldRecedeSidebarThread(input: { status: SidebarThreadStatus; isUnread: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 29849561efe2..8b44fb16e6b9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -167,6 +167,7 @@ import { hasUnseenCompletion, isSidebarNestedLinkClick, isSidebarThreadWorking, + matchesSidebarThreadFilters, isTrailingDoubleClick, orderItemsByPreferredIds, planSidebarThreadDrop, @@ -183,6 +184,7 @@ import { shouldRecedeSidebarThread, resolveWorkingStartedAt, sidebarListItemId, + sidebarThreadAccountKey, sidebarMarkerId, sortInboxThreadsByReturn, sortLogicalProjectsForSidebar, @@ -261,6 +263,9 @@ const SETTLED_TAIL_PAGE_COUNT = 25; const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; +const HIDE_BOT_THREADS_KEY = "t3code:sidebar:hide-bot-threads"; +const SIDEBAR_ACCOUNT_FILTER_KEY = "t3code:sidebar:account-filter"; +const SIDEBAR_ACCOUNT_FILTER_SCHEMA = Schema.NullOr(Schema.String); // Working beta: when this client saw each thread leave the Working shelf. // Module scope keeps the inbox order across routes that unmount the sidebar. @@ -2407,6 +2412,25 @@ export default function Sidebar() { ), [serverConfigs], ); + const accountOptions = useMemo(() => { + const options = new Map(); + for (const thread of threads) { + if (thread.archivedAt !== null) continue; + const key = sidebarThreadAccountKey(thread); + if (options.has(key)) continue; + const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const name = + providerEntriesByEnvironment.get(thread.environmentId)?.get(instanceId)?.displayName ?? + thread.session?.providerName ?? + instanceId; + const environment = environmentLabelById.get(thread.environmentId); + options.set(key, { + key, + label: environments.length > 1 && environment ? `${name} ยท ${environment}` : name, + }); + } + return [...options.values()].toSorted((left, right) => left.label.localeCompare(right.label)); + }, [environmentLabelById, environments.length, providerEntriesByEnvironment, threads]); // Rows read the project record for its icon and cwd. Group labels can include // a repository owner or a different title, so they travel separately. const projectByKey = useMemo( @@ -2517,6 +2541,16 @@ export default function Sidebar() { setProjectScopeKey(null); } }, [allProjectSnapshotsReady, projectScopeKey, scopedProjectGroup, setProjectScopeKey]); + const [hideBotThreads, setHideBotThreads] = useLocalStorage( + HIDE_BOT_THREADS_KEY, + false, + Schema.Boolean, + ); + const [selectedAccountKey, setSelectedAccountKey] = useLocalStorage( + SIDEBAR_ACCOUNT_FILTER_KEY, + null as string | null, + SIDEBAR_ACCOUNT_FILTER_SCHEMA, + ); // Count-only subscription: the parent needs "are there draft rows" for the // empty state, while SidebarDraftBlock owns the per-keystroke content // subscription. Selecting a number keeps typing in a draft composer from @@ -2543,11 +2577,10 @@ export default function Sidebar() { } return count; }); - // Scope flips drop the selection: rows selected under the old scope may be - // hidden now, and bulk actions must never count or touch invisible rows. + // Filter changes drop the selection: bulk actions must never touch hidden rows. useEffect(() => { clearSelection(); - }, [clearSelection, projectScopeKey]); + }, [clearSelection, hideBotThreads, projectScopeKey, selectedAccountKey]); const openProjectSettings = useCallback( (projectGroup: SidebarProjectSnapshot) => { @@ -2598,6 +2631,8 @@ export default function Sidebar() { override holds until all of them appear in canonical state. */ readonly assignedKeys: ReadonlyMap; } | null>(null); + const filterExemptThreadKey = + hideBotThreads || selectedAccountKey !== null ? routeThreadKey : null; const { pinnedThreads, draggableThreadKeys, @@ -2618,7 +2653,14 @@ export default function Sidebar() { (thread) => thread.archivedAt === null && (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) && + matchesSidebarThreadFilters(thread, { + hideBotThreads, + accountKey: selectedAccountKey, + isOpen: + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + filterExemptThreadKey, + }), ); observeInboxReturns(workingShelfEnabled ? threads : null); const pinned: EnvironmentThreadShell[] = []; @@ -2719,9 +2761,12 @@ export default function Sidebar() { snoozeNow: preciseNow, }; }, [ + filterExemptThreadKey, + hideBotThreads, nowMinute, optimisticDrop, scopedProjectKeys, + selectedAccountKey, serverConfigs, snoozeWakeTick, threads, @@ -4584,6 +4629,11 @@ export default function Sidebar() { 0} + hideBotThreads={hideBotThreads} + onHideBotThreadsChange={setHideBotThreads} + accountOptions={accountOptions} + selectedAccountKey={selectedAccountKey} + onAccountChange={setSelectedAccountKey} projectScope={ + ) : hideBotThreads || selectedAccountKey !== null ? ( + "No threads match filters" ) : scopedProjectGroup ? ( `No threads in ${scopedProjectGroup.displayName} yet` ) : ( diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index e2bd4bfb74e5..daf7e7d5a15f 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -10,7 +10,7 @@ * of the sidebar's scope logic. `searchFieldRef` lands on the search field so * the picker's popup can anchor to that width rather than to its 28px trigger. */ -import { FolderPlusIcon, SearchIcon, SquarePenIcon, XIcon } from "lucide-react"; +import { FolderPlusIcon, ListFilterIcon, SearchIcon, SquarePenIcon, XIcon } from "lucide-react"; import { type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, @@ -20,7 +20,23 @@ import { } from "react"; import { cn } from "~/lib/utils"; +import { + SIDEBAR_UNAVAILABLE_ACCOUNT_LABEL, + sidebarThreadFilterButtonLabel, +} from "../Sidebar.logic"; import { Button } from "../ui/button"; +import { + Menu, + MenuCheckboxItem, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuRadioItemIndicator, + MenuSeparator, + MenuTrigger, +} from "../ui/menu"; import { SidebarInput, SidebarMenuButton } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -31,6 +47,11 @@ export interface SidebarThreadHeaderProps { hasProjects: boolean; /** The project scope combobox, rendered as the first icon of the group. */ projectScope: ReactNode; + hideBotThreads: boolean; + onHideBotThreadsChange: (hide: boolean) => void; + accountOptions: ReadonlyArray<{ readonly key: string; readonly label: string }>; + selectedAccountKey: string | null; + onAccountChange: (key: string | null) => void; onNewProject: () => void; /** Receives the click so Shift+click can skip the project picker. */ onNewThread: (event: ReactMouseEvent) => void; @@ -53,6 +74,11 @@ export function SidebarThreadHeader({ searchFieldRef, hasProjects, projectScope, + hideBotThreads, + onHideBotThreadsChange, + accountOptions, + selectedAccountKey, + onAccountChange, onNewProject, onNewThread, newThreadDisabled, @@ -76,6 +102,12 @@ export function SidebarThreadHeader({ const newThreadLabel = newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"; + const filtersActive = hideBotThreads || selectedAccountKey !== null; + const filterButtonLabel = sidebarThreadFilterButtonLabel({ + hideBotThreads, + selectedAccountKey, + accountOptions, + }); return (
@@ -124,6 +156,76 @@ export function SidebarThreadHeader({ hover states, and a background well reads far louder on themed palettes than on the base light and dark ones. */}
+ + }> + + {filtersActive ? ( + + ) : null} + + + + Hide dispatched bot threads + + + onAccountChange(value || null)} + > + LLM account + + + All accounts + + + + {accountOptions.map((option) => ( + + + {option.label} + + + + ))} + {selectedAccountKey !== null && + !accountOptions.some((option) => option.key === selectedAccountKey) ? ( + + + {SIDEBAR_UNAVAILABLE_ACCOUNT_LABEL} + + + + ) : null} + + {filtersActive ? ( + <> + + { + onHideBotThreadsChange(false); + onAccountChange(null); + }} + > + Clear bot and account filters + + + ) : null} + + {hasProjects ? ( <> {projectScope} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 603578e8c5b7..a72bd71f6e79 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -151,6 +151,20 @@ On web and desktop, right-click a pull request link in a thread and choose same link to return to the branch PR, if one exists. The linked pull request participates in automatic settlement. +## Filter the thread list + +On web and desktop, the filter button beside the sidebar search narrows the list +two ways, and you can use both at once. **Hide dispatched bot threads** drops the +threads an agent started on your behalf. **LLM account** keeps only the threads +that run on one account; the same account on two machines is listed separately, +with the machine named beside it. Search and the section counts follow the filters. + +The thread you have open stays visible even when a filter would hide it, so you +never lose your place. Your choices are remembered on this device until you +change them. Choose **Clear bot and account filters** to see everything again. If +the selected account is no longer available, it appears as **Unavailable +account** until you clear it. + ## Find and reference work On web and desktop, open the command palette with `Cmd/Ctrl+K` to search threads diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 0d1ea8563447..1c01a23b08c6 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.44", + "version": "0.0.99-gitcache.6", "private": true, "files": [ "dist"