From ec68b874e0d86c05f2961276886d5b0c4efea467 Mon Sep 17 00:00:00 2001 From: Will Sheldon Date: Wed, 2 Sep 2026 09:07:40 -0700 Subject: [PATCH 1/7] fix(server): keep git repository detection cached for minutes Repository detection spawns three git processes per cwd, and the result was only cached for two seconds. Every VCS call past that window re-ran them, which dominates latency when many projects refresh at once. Whether a directory is a git repository is stable, so the positive result now holds for five minutes. Negative results are still never cached, so a fresh `git init` is picked up on the next call. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/vcs/VcsDriverRegistry.test.ts | 50 +++++++++++++++++++ apps/server/src/vcs/VcsDriverRegistry.ts | 7 ++- 2 files changed, 56 insertions(+), 1 deletion(-) 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; From 9897d0fc6871eb4971ed5a2d54a718bd8c832f65 Mon Sep 17 00:00:00 2001 From: Will Sheldon Date: Wed, 2 Sep 2026 09:07:54 -0700 Subject: [PATCH 2/7] fix(server): forget a deleted thread's provider binding The provider runtime table keeps one row per thread that has ever run an agent, and nothing pruned it. A deleted thread kept its binding for the life of the install, so the table grew without bound and every full scan of it paid for threads the user removed long ago. The thread deletion reactor now removes the binding after it stops the provider session. The order matters: stopping a session ends in a directory upsert, so removing first would re-insert the row this cleanup exists to drop. Removal reuses the repository's existing `deleteByThreadId`, which until now had no production caller, and failures are logged like the reactor's other cleanup steps rather than failing the deletion. Co-Authored-By: Claude Opus 5 (1M context) --- .../Layers/ThreadDeletionReactor.test.ts | 48 +++++++++++++++++++ .../Layers/ThreadDeletionReactor.ts | 12 +++++ .../src/project/AgentSessionImporter.test.ts | 5 ++ .../src/provider/Layers/CodexAdapter.test.ts | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 1 + .../provider/Layers/ProviderService.test.ts | 1 + .../Layers/ProviderSessionDirectory.ts | 8 ++++ .../Services/ProviderSessionDirectory.ts | 5 ++ .../serverRuntimeStartup.reconcile.test.ts | 12 ++++- 9 files changed, 92 insertions(+), 1 deletion(-) 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([]), From 5c957a77497487bbbc82d664fbcae54473885d6d Mon Sep 17 00:00:00 2001 From: Will Sheldon Date: Mon, 28 Sep 2026 08:53:56 -0700 Subject: [PATCH 3/7] feat(web): filter sidebar threads by bot and account --- apps/web/src/components/Sidebar.logic.test.ts | 43 +++++++++ apps/web/src/components/Sidebar.logic.ts | 36 ++++++++ apps/web/src/components/Sidebar.tsx | 60 ++++++++++++- .../sidebar/SidebarThreadHeader.tsx | 88 ++++++++++++++++++- 4 files changed, 222 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4a606bb46c7..d73c9a44e458 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -21,6 +21,9 @@ import { isContextMenuPointerDown, isSidebarNestedLinkClick, isSidebarThreadWorking, + isBotThreadTitle, + matchesSidebarThreadFilters, + sidebarThreadAccountKey, isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, @@ -71,6 +74,46 @@ 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); + }); +}); + describe("resolveSidebarRowAccessibility", () => { it.each([ { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index a63b728cd4fa..09226b560390 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -821,6 +821,42 @@ 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)) + ); +} + 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..f443222925c7 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, @@ -21,6 +21,18 @@ import { import { cn } from "~/lib/utils"; 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 +43,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 +70,11 @@ export function SidebarThreadHeader({ searchFieldRef, hasProjects, projectScope, + hideBotThreads, + onHideBotThreadsChange, + accountOptions, + selectedAccountKey, + onAccountChange, onNewProject, onNewThread, newThreadDisabled, @@ -76,6 +98,7 @@ export function SidebarThreadHeader({ const newThreadLabel = newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"; + const filtersActive = hideBotThreads || selectedAccountKey !== null; return (
@@ -124,6 +147,69 @@ 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 + + + LLM account + onAccountChange(value || null)} + > + + + All accounts + + + + {accountOptions.map((option) => ( + + + {option.label} + + + + ))} + {selectedAccountKey !== null && + !accountOptions.some((option) => option.key === selectedAccountKey) ? ( + + + Unavailable account + + + + ) : null} + + {filtersActive ? ( + <> + + { + onHideBotThreadsChange(false); + onAccountChange(null); + }} + > + Clear bot and account filters + + + ) : null} + + {hasProjects ? ( <> {projectScope} From d4d8cbe5cc5822599061dc73465e2f7207eed9bb Mon Sep 17 00:00:00 2001 From: Will Sheldon Date: Mon, 28 Sep 2026 09:12:15 -0700 Subject: [PATCH 4/7] fix(web): keep account label inside filter group --- apps/web/src/components/sidebar/SidebarThreadHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index f443222925c7..286e3804c0bb 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -166,11 +166,11 @@ export function SidebarThreadHeader({ Hide dispatched bot threads - LLM account onAccountChange(value || null)} > + LLM account All accounts From b6303af1405bc864452877189a3590365a629411 Mon Sep 17 00:00:00 2001 From: Will Sheldon Date: Mon, 28 Sep 2026 09:42:24 -0700 Subject: [PATCH 5/7] fix(web): bound the thread filter menu and name its trigger The filter popup had no width cap, so a long account label grew the menu past the sidebar and over the conversation (242px to 544px with a realistic name, capped only by the viewport). The truncate class on the account labels never engaged. Cap it at 18rem like the project scope popup beside it. The trigger was named "Filter threads", next to the project scope trigger's "Filter threads by project". The name now says what it filters and which filters are on; the active-state dot is decorative, so the name was the only thing left for a screen reader. Document the filter in the thread sidebar user guide. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/Sidebar.logic.test.ts | 16 +++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 19 ++++++++++++++++++ .../sidebar/SidebarThreadHeader.tsx | 20 +++++++++++++++++-- docs/user/thread-sidebar.md | 14 +++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d73c9a44e458..8536842c1642 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -24,6 +24,7 @@ import { isBotThreadTitle, matchesSidebarThreadFilters, sidebarThreadAccountKey, + sidebarThreadFilterButtonLabel, isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, @@ -112,6 +113,21 @@ describe("sidebar conversation filters", () => { 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", () => { + expect(sidebarThreadFilterButtonLabel({ hideBotThreads: false, accountLabel: null })).toBe( + "Filter threads by bot and account", + ); + expect(sidebarThreadFilterButtonLabel({ hideBotThreads: true, accountLabel: null })).toBe( + "Filter threads by bot and account: bot threads hidden", + ); + expect(sidebarThreadFilterButtonLabel({ hideBotThreads: false, accountLabel: "AU" })).toBe( + "Filter threads by bot and account: AU", + ); + expect(sidebarThreadFilterButtonLabel({ hideBotThreads: true, accountLabel: "AU" })).toBe( + "Filter threads by bot and account: bot threads hidden, AU", + ); + }); }); describe("resolveSidebarRowAccessibility", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 09226b560390..79d69134418c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -857,6 +857,25 @@ export function matchesSidebarThreadFilters( ); } +/** + * 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 accountLabel: string | null; +}): string { + const active = [ + ...(input.hideBotThreads ? ["bot threads hidden"] : []), + ...(input.accountLabel === null ? [] : [input.accountLabel]), + ]; + 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/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index 286e3804c0bb..20093ef39003 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -20,6 +20,7 @@ import { } from "react"; import { cn } from "~/lib/utils"; +import { sidebarThreadFilterButtonLabel } from "../Sidebar.logic"; import { Button } from "../ui/button"; import { Menu, @@ -99,6 +100,14 @@ export function SidebarThreadHeader({ ? `New thread (${newThreadShortcutLabel})` : "New thread"; const filtersActive = hideBotThreads || selectedAccountKey !== null; + const filterButtonLabel = sidebarThreadFilterButtonLabel({ + hideBotThreads, + accountLabel: + selectedAccountKey === null + ? null + : (accountOptions.find((option) => option.key === selectedAccountKey)?.label ?? + "unavailable account"), + }); return (
@@ -148,7 +157,7 @@ export function SidebarThreadHeader({ palettes than on the base light and dark ones. */}
- }> + }> {filtersActive ? ( ) : null} - + Date: Mon, 28 Sep 2026 09:49:20 -0700 Subject: [PATCH 6/7] fix(web): share one unavailable-account label with the filter trigger The menu row read "Unavailable account" while the trigger's name said "unavailable account", and the lookup behind it sat in the component where no test reaches it. Both now read one exported constant, and the trigger's name is resolved inside the tested helper. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/Sidebar.logic.test.ts | 29 ++++++++++++------- apps/web/src/components/Sidebar.logic.ts | 17 +++++++++-- .../sidebar/SidebarThreadHeader.tsx | 14 ++++----- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 8536842c1642..6d7c02722dbf 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -25,6 +25,7 @@ import { matchesSidebarThreadFilters, sidebarThreadAccountKey, sidebarThreadFilterButtonLabel, + SIDEBAR_UNAVAILABLE_ACCOUNT_LABEL, isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, @@ -115,19 +116,27 @@ describe("sidebar conversation filters", () => { }); it("names what the trigger filters and which filters are on", () => { - expect(sidebarThreadFilterButtonLabel({ hideBotThreads: false, accountLabel: null })).toBe( - "Filter threads by bot and account", - ); - expect(sidebarThreadFilterButtonLabel({ hideBotThreads: true, accountLabel: null })).toBe( - "Filter threads by bot and account: bot threads hidden", - ); - expect(sidebarThreadFilterButtonLabel({ hideBotThreads: false, accountLabel: "AU" })).toBe( - "Filter threads by bot and account: AU", - ); - expect(sidebarThreadFilterButtonLabel({ hideBotThreads: true, accountLabel: "AU" })).toBe( + 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", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 79d69134418c..70fe83007b85 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -857,6 +857,13 @@ export function matchesSidebarThreadFilters( ); } +/** + * 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 @@ -865,11 +872,17 @@ export function matchesSidebarThreadFilters( */ export function sidebarThreadFilterButtonLabel(input: { readonly hideBotThreads: boolean; - readonly accountLabel: string | null; + readonly selectedAccountKey: string | null; + readonly accountOptions: ReadonlyArray<{ readonly key: string; readonly label: string }>; }): string { const active = [ ...(input.hideBotThreads ? ["bot threads hidden"] : []), - ...(input.accountLabel === null ? [] : [input.accountLabel]), + ...(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" diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index 20093ef39003..daf7e7d5a15f 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -20,7 +20,10 @@ import { } from "react"; import { cn } from "~/lib/utils"; -import { sidebarThreadFilterButtonLabel } from "../Sidebar.logic"; +import { + SIDEBAR_UNAVAILABLE_ACCOUNT_LABEL, + sidebarThreadFilterButtonLabel, +} from "../Sidebar.logic"; import { Button } from "../ui/button"; import { Menu, @@ -102,11 +105,8 @@ export function SidebarThreadHeader({ const filtersActive = hideBotThreads || selectedAccountKey !== null; const filterButtonLabel = sidebarThreadFilterButtonLabel({ hideBotThreads, - accountLabel: - selectedAccountKey === null - ? null - : (accountOptions.find((option) => option.key === selectedAccountKey)?.label ?? - "unavailable account"), + selectedAccountKey, + accountOptions, }); return ( @@ -205,7 +205,7 @@ export function SidebarThreadHeader({ !accountOptions.some((option) => option.key === selectedAccountKey) ? ( - Unavailable account + {SIDEBAR_UNAVAILABLE_ACCOUNT_LABEL} From 6373ec1ec4cf566c3ccc655878163ddfb05fcd39 Mon Sep 17 00:00:00 2001 From: Will Sheldon Date: Mon, 28 Sep 2026 09:57:37 -0700 Subject: [PATCH 7/7] chore: mark local patched build version Bump 0.0.99-gitcache.5 -> 0.0.99-gitcache.6 across the four package manifests so this build reports as gitcache.6, which now includes upstream's 71 commits since the previous rebase base. Co-Authored-By: Claude Sonnet 5.5 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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/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/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"