From 2f3b92c006633db8e83d81358759118375c8c854 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 22:32:00 -0700 Subject: [PATCH 1/7] Add archived shell snapshot handling - Split archived threads into a dedicated shell snapshot query and RPC - Update server and web clients to surface archived threads separately - Add migration indexes and coverage for archived thread flows --- .../Layers/CheckpointDiffQuery.test.ts | 6 + .../Layers/OrchestrationEngine.test.ts | 8 +- .../Layers/ProjectionSnapshotQuery.test.ts | 120 ++++++++ .../Layers/ProjectionSnapshotQuery.ts | 278 +++++++++++++++++- .../Services/ProjectionSnapshotQuery.ts | 11 + apps/server/src/persistence/Migrations.ts | 2 + ...030_ProjectionThreadShellArchiveIndexes.ts | 16 + .../Layers/ProjectSetupScriptRunner.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + apps/server/src/server.test.ts | 7 + apps/server/src/serverRuntimeStartup.test.ts | 3 + apps/server/src/ws.ts | 29 ++ apps/web/src/components/ChatView.browser.tsx | 3 + .../components/settings/SettingsPanels.tsx | 110 +++++-- apps/web/src/environmentApi.ts | 1 + apps/web/src/rpc/wsRpcClient.ts | 7 + packages/contracts/src/ipc.ts | 2 + packages/contracts/src/orchestration.ts | 5 + packages/contracts/src/rpc.ts | 10 + 19 files changed, 599 insertions(+), 21 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/030_ProjectionThreadShellArchiveIndexes.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 041bc402034..3f08e1df23b 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -92,6 +92,8 @@ describe("CheckpointDiffQueryLive", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), @@ -172,6 +174,8 @@ describe("CheckpointDiffQueryLive", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), @@ -220,6 +224,8 @@ describe("CheckpointDiffQueryLive", () => { Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), + getArchivedShellSnapshot: () => + Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 90d849fd826..38c957d9a42 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -8,7 +8,6 @@ import { TurnId, type OrchestrationEvent, ProviderInstanceId, - type OrchestrationReadModel, } from "@t3tools/contracts"; import { Effect, Layer, ManagedRuntime, Metric, Option, Queue, Stream } from "effect"; import { describe, expect, it } from "vitest"; @@ -178,6 +177,13 @@ describe("OrchestrationEngine", () => { threads: [], updatedAt: projectionSnapshot.updatedAt, }), + getArchivedShellSnapshot: () => + Effect.succeed({ + snapshotSequence: projectionSnapshot.snapshotSequence, + projects: [], + threads: [], + updatedAt: projectionSnapshot.updatedAt, + }), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 4538ab4b6b5..0a9f5ba11c0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -440,6 +440,126 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("keeps archived threads out of the main shell snapshot", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-archive-test', + 'Archive Test', + '/tmp/archive-test', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-06T00:00:00.000Z', + '2026-04-06T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at + ) + VALUES + ( + 'thread-active', + 'project-archive-test', + 'Active Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:02.000Z', + '2026-04-06T00:00:03.000Z', + NULL, + NULL + ), + ( + 'thread-archived', + 'project-archive-test', + 'Archived Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:04.000Z', + '2026-04-06T00:00:05.000Z', + '2026-04-06T00:00:06.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z') + `; + + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.deepEqual( + shellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-active")], + ); + + const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot(); + assert.deepEqual( + archivedShellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-archived")], + ); + assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z"); + }), + ); + it.effect( "reads targeted project, thread, and count queries without hydrating the full snapshot", () => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 27b067e8f57..96196a3bad8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -323,6 +323,66 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listActiveThreadRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE deleted_at IS NULL + AND archived_at IS NULL + ORDER BY project_id ASC, archived_at ASC, created_at ASC, thread_id ASC + `, + }); + + const listArchivedThreadRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + branch, + worktree_path AS "worktreePath", + latest_turn_id AS "latestTurnId", + created_at AS "createdAt", + updated_at AS "updatedAt", + archived_at AS "archivedAt", + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan", + deleted_at AS "deletedAt" + FROM projection_threads + WHERE deleted_at IS NULL + AND archived_at IS NOT NULL + ORDER BY project_id ASC, archived_at DESC, thread_id DESC + `, + }); + const listThreadMessageRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadMessageDbRowSchema, @@ -407,6 +467,56 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listActiveThreadSessionRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadSessionDbRowSchema, + execute: () => + sql` + SELECT + sessions.thread_id AS "threadId", + sessions.status, + sessions.provider_name AS "providerName", + sessions.provider_instance_id AS "providerInstanceId", + sessions.provider_session_id AS "providerSessionId", + sessions.provider_thread_id AS "providerThreadId", + sessions.runtime_mode AS "runtimeMode", + sessions.active_turn_id AS "activeTurnId", + sessions.last_error AS "lastError", + sessions.updated_at AS "updatedAt" + FROM projection_thread_sessions sessions + INNER JOIN projection_threads threads + ON threads.thread_id = sessions.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + ORDER BY sessions.thread_id ASC + `, + }); + + const listArchivedThreadSessionRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadSessionDbRowSchema, + execute: () => + sql` + SELECT + sessions.thread_id AS "threadId", + sessions.status, + sessions.provider_name AS "providerName", + sessions.provider_instance_id AS "providerInstanceId", + sessions.provider_session_id AS "providerSessionId", + sessions.provider_thread_id AS "providerThreadId", + sessions.runtime_mode AS "runtimeMode", + sessions.active_turn_id AS "activeTurnId", + sessions.last_error AS "lastError", + sessions.updated_at AS "updatedAt" + FROM projection_thread_sessions sessions + INNER JOIN projection_threads threads + ON threads.thread_id = sessions.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NOT NULL + ORDER BY sessions.thread_id ASC + `, + }); + const listCheckpointRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionCheckpointDbRowSchema, @@ -471,6 +581,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON turns.thread_id = threads.thread_id AND turns.turn_id = threads.latest_turn_id WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND threads.latest_turn_id IS NOT NULL + ORDER BY turns.thread_id ASC + `, + }); + + const listArchivedLatestTurnRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionLatestTurnDbRowSchema, + execute: () => + sql` + SELECT + turns.thread_id AS "threadId", + turns.turn_id AS "turnId", + turns.state, + turns.requested_at AS "requestedAt", + turns.started_at AS "startedAt", + turns.completed_at AS "completedAt", + turns.assistant_message_id AS "assistantMessageId", + turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + turns.source_proposed_plan_id AS "sourceProposedPlanId" + FROM projection_threads threads + JOIN projection_turns turns + ON turns.thread_id = threads.thread_id + AND turns.turn_id = threads.latest_turn_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NOT NULL AND threads.latest_turn_id IS NOT NULL ORDER BY turns.thread_id ASC `, @@ -553,6 +690,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_threads WHERE project_id = ${projectId} AND deleted_at IS NULL + AND archived_at IS NULL ORDER BY created_at ASC, thread_id ASC LIMIT 1 `, @@ -603,6 +741,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_threads WHERE thread_id = ${threadId} AND deleted_at IS NULL + AND archived_at IS NULL LIMIT 1 `, }); @@ -713,6 +852,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { AND turns.turn_id = threads.latest_turn_id WHERE threads.thread_id = ${threadId} AND threads.deleted_at IS NULL + AND threads.archived_at IS NULL LIMIT 1 `, }); @@ -1228,7 +1368,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadRows(undefined).pipe( + listActiveThreadRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", @@ -1236,7 +1376,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadSessionRows(undefined).pipe( + listActiveThreadSessionRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query", @@ -1346,6 +1486,139 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }), ); + const getArchivedShellSnapshot: ProjectionSnapshotQueryShape["getArchivedShellSnapshot"] = () => + sql + .withTransaction( + Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjects:decodeRows", + ), + ), + ), + listArchivedThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreads:decodeRows", + ), + ), + ), + listArchivedThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadSessions:decodeRows", + ), + ), + ), + listArchivedLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listLatestTurns:decodeRows", + ), + ), + ), + listProjectionStateRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjectionState:decodeRows", + ), + ), + ), + ]), + ) + .pipe( + Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( + projectRows.filter((row) => activeProjectIds.has(row.projectId)), + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: projectRows + .filter((row) => row.deletedAt === null && activeProjectIds.has(row.projectId)) + .map((row) => + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ), + threads: threadRows.map( + (row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + }), + ), + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; + + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + ), + ), + ); + }), + ), + Effect.mapError((error) => { + if (isPersistenceError(error)) { + return error; + } + return toPersistenceSqlError("ProjectionSnapshotQuery.getArchivedShellSnapshot:query")( + error, + ); + }), + ); + const getSnapshotSequence: ProjectionSnapshotQueryShape["getSnapshotSequence"] = () => listProjectionStateRows(undefined).pipe( Effect.mapError( @@ -1679,6 +1952,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getCommandReadModel, getSnapshot, getShellSnapshot, + getArchivedShellSnapshot, getSnapshotSequence, getCounts, getActiveProjectByWorkspaceRoot, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 9d64307d3dd..ae5575e939f 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -72,6 +72,17 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Read archived thread shell summaries for the archive page. + * + * This query is separate from the main shell snapshot so archived threads + * are never bootstrapped into normal navigation state. + */ + readonly getArchivedShellSnapshot: () => Effect.Effect< + OrchestrationShellSnapshot, + ProjectionRepositoryError + >; + /** * Read the latest projection snapshot sequence without hydrating read-model * entities. diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c0918de8493..cc5024d5f51 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -42,6 +42,7 @@ import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"; import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; +import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; /** * Migration loader with all migrations defined inline. @@ -83,6 +84,7 @@ export const migrationEntries = [ [27, "ProviderSessionRuntimeInstanceId", Migration0027], [28, "ProjectionThreadSessionInstanceId", Migration0028], [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], + [30, "ProjectionThreadShellArchiveIndexes", Migration0030], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/030_ProjectionThreadShellArchiveIndexes.ts b/apps/server/src/persistence/Migrations/030_ProjectionThreadShellArchiveIndexes.ts new file mode 100644 index 00000000000..3b7bf51f04b --- /dev/null +++ b/apps/server/src/persistence/Migrations/030_ProjectionThreadShellArchiveIndexes.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_shell_active + ON projection_threads(deleted_at, archived_at, project_id, created_at, thread_id) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_shell_archived + ON projection_threads(deleted_at, archived_at, project_id, thread_id) + `; +}); diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 28c74aa7fb8..b20ffcdadd0 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -23,6 +23,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: (workspaceRoot) => diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index b2b9af2c1ec..b5c16474ff1 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -182,6 +182,7 @@ describe("ProviderSessionReaper", () => { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }), getCounts: () => Effect.die("unused"), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 32261dd618b..8ca806b0aae 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -631,6 +631,13 @@ const buildAppUnderTest = (options?: { threads: [], updatedAt: new Date(0).toISOString(), }), + getArchivedShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: [], + threads: [], + updatedAt: new Date(0).toISOString(), + }), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getProjectShellById: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 7f13693289c..46c451dbc55 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -80,6 +80,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Deferred.await(releaseCounts).pipe( @@ -134,6 +135,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => @@ -186,6 +188,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 476140dd3ae..f3b5a2c7027 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -295,6 +295,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }), ); case "thread.deleted": + case "thread.archived": return Effect.succeed( Option.some({ kind: "thread-removed" as const, @@ -302,6 +303,17 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => threadId: event.payload.threadId, }), ); + case "thread.unarchived": + return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe( + Effect.map((thread) => + Option.map(thread, (nextThread) => ({ + kind: "thread-upserted" as const, + sequence: event.sequence, + thread: nextThread, + })), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); default: if (event.aggregateKind !== "thread") { return Effect.succeed(Option.none()); @@ -718,6 +730,23 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: (_input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, + projectionSnapshotQuery.getArchivedShellSnapshot().pipe( + Effect.tapError((cause) => + Effect.logError("orchestration archived shell snapshot load failed", { cause }), + ), + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load archived orchestration shell snapshot", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeThread, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 578dc7c045d..4cde2e0aa8c 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -218,6 +218,9 @@ function createMockEnvironmentApi(input: { getFullThreadDiff: (() => { throw new Error("Not implemented in browser test."); }) as EnvironmentApi["orchestration"]["getFullThreadDiff"], + getArchivedShellSnapshot: (() => { + throw new Error("Not implemented in browser test."); + }) as EnvironmentApi["orchestration"]["getArchivedShellSnapshot"], subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"], subscribeThread: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeThread"], diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 3c0a25d18b0..0223ed5240e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,10 +1,12 @@ import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { defaultInstanceIdForDriver, type DesktopUpdateChannel, + type EnvironmentId, + type OrchestrationShellSnapshot, PROVIDER_DISPLAY_NAMES, ProviderDriverKind, type ProviderInstanceConfig, @@ -42,12 +44,9 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; +import { readEnvironmentApi } from "../../environmentApi"; import { useShallow } from "zustand/react/shallow"; -import { - selectProjectsAcrossEnvironments, - selectThreadShellsAcrossEnvironments, - useStore, -} from "../../store"; +import { selectProjectsAcrossEnvironments, useStore } from "../../store"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; @@ -1298,14 +1297,81 @@ export function ProviderSettingsPanel() { export function ArchivedThreadsPanel() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const threads = useStore(useShallow(selectThreadShellsAcrossEnvironments)); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const [archivedSnapshots, setArchivedSnapshots] = useState< + ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly snapshot: OrchestrationShellSnapshot; + }> + >([]); + const [isLoadingArchive, setIsLoadingArchive] = useState(false); + const environmentIds = useMemo( + () => [...new Set(projects.map((project) => project.environmentId))], + [projects], + ); + + const refreshArchivedThreads = useCallback(async () => { + setIsLoadingArchive(true); + try { + const snapshots = await Promise.all( + environmentIds.map(async (environmentId) => { + const api = readEnvironmentApi(environmentId); + if (!api) { + return null; + } + return { + environmentId, + snapshot: await api.orchestration.getArchivedShellSnapshot(), + }; + }), + ); + setArchivedSnapshots(snapshots.filter((snapshot) => snapshot !== null)); + } finally { + setIsLoadingArchive(false); + } + }, [environmentIds]); + + useEffect(() => { + void refreshArchivedThreads().catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to load archived threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }, [refreshArchivedThreads]); + const archivedGroups = useMemo(() => { - return projects + const projectsByEnvironmentAndId = new Map( + archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.projects.map( + (project) => + [ + `${environmentId}:${project.id}`, + { + id: project.id, + environmentId, + name: project.title, + cwd: project.workspaceRoot, + }, + ] as const, + ), + ), + ); + const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => ({ + ...thread, + environmentId, + })), + ); + + return [...projectsByEnvironmentAndId.values()] .map((project) => ({ project, threads: threads - .filter((thread) => thread.projectId === project.id && thread.archivedAt !== null) + .filter((thread) => thread.projectId === project.id) .toSorted((left, right) => { const leftKey = left.archivedAt ?? left.createdAt; const rightKey = right.archivedAt ?? right.createdAt; @@ -1313,7 +1379,7 @@ export function ArchivedThreadsPanel() { }), })) .filter((group) => group.threads.length > 0); - }, [projects, threads]); + }, [archivedSnapshots]); const handleArchivedThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { @@ -1330,6 +1396,7 @@ export function ArchivedThreadsPanel() { if (clicked === "unarchive") { try { await unarchiveThread(threadRef); + await refreshArchivedThreads(); } catch (error) { toastManager.add( stackedThreadToast({ @@ -1344,9 +1411,10 @@ export function ArchivedThreadsPanel() { if (clicked === "delete") { await confirmAndDeleteThread(threadRef); + await refreshArchivedThreads(); } }, - [confirmAndDeleteThread, unarchiveThread], + [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], ); return ( @@ -1355,11 +1423,17 @@ export function ArchivedThreadsPanel() { - + {isLoadingArchive ? : } - No archived threads - Archived threads will appear here. + + {isLoadingArchive ? "Loading archived threads" : "No archived threads"} + + + {isLoadingArchive + ? "Checking connected environments." + : "Archived threads will appear here."} + @@ -1399,8 +1473,9 @@ export function ArchivedThreadsPanel() { size="sm" className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" onClick={() => - void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch( - (error) => { + void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)) + .then(refreshArchivedThreads) + .catch((error) => { toastManager.add( stackedThreadToast({ type: "error", @@ -1409,8 +1484,7 @@ export function ArchivedThreadsPanel() { error instanceof Error ? error.message : "An error occurred.", }), ); - }, - ) + }) } > diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 335d68d3720..9e7b0c939a9 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -47,6 +47,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { dispatchCommand: rpcClient.orchestration.dispatchCommand, getTurnDiff: rpcClient.orchestration.getTurnDiff, getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, + getArchivedShellSnapshot: rpcClient.orchestration.getArchivedShellSnapshot, subscribeShell: (callback, options) => rpcClient.orchestration.subscribeShell(callback, options), subscribeThread: (input, callback, options) => diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index ca56b6143c0..520cd859115 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -142,6 +142,9 @@ export interface WsRpcClient { readonly dispatchCommand: RpcUnaryMethod; readonly getTurnDiff: RpcUnaryMethod; readonly getFullThreadDiff: RpcUnaryMethod; + readonly getArchivedShellSnapshot: RpcUnaryNoArgMethod< + typeof ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot + >; readonly subscribeShell: RpcStreamMethod; readonly subscribeThread: RpcInputStreamMethod; }; @@ -287,6 +290,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), getFullThreadDiff: (input) => transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), + getArchivedShellSnapshot: () => + transport.request((client) => + client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]({}), + ), subscribeShell: (listener, options) => transport.subscribe( (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index abe8af5022f..58894adac1a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -55,6 +55,7 @@ import type { OrchestrationGetFullThreadDiffResult, OrchestrationGetTurnDiffInput, OrchestrationGetTurnDiffResult, + OrchestrationShellSnapshot, OrchestrationShellStreamItem, OrchestrationSubscribeThreadInput, OrchestrationThreadStreamItem, @@ -544,6 +545,7 @@ export interface EnvironmentApi { getFullThreadDiff: ( input: OrchestrationGetFullThreadDiffInput, ) => Promise; + getArchivedShellSnapshot: () => Promise; subscribeShell: ( callback: (event: OrchestrationShellStreamItem) => void, options?: { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 44d840d1499..401928171c8 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -27,6 +27,7 @@ export const ORCHESTRATION_WS_METHODS = { getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", replayEvents: "orchestration.replayEvents", + getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", } as const; @@ -1222,6 +1223,10 @@ export const OrchestrationRpcSchemas = { input: OrchestrationReplayEventsInput, output: OrchestrationReplayEventsResult, }, + getArchivedShellSnapshot: { + input: Schema.Struct({}), + output: OrchestrationShellSnapshot, + }, subscribeThread: { input: OrchestrationSubscribeThreadInput, output: OrchestrationThreadStreamItem, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index eb72b14f9e5..705621b5dac 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -410,6 +410,15 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS. error: OrchestrationReplayEventsError, }); +export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make( + ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, + { + payload: OrchestrationRpcSchemas.getArchivedShellSnapshot.input, + success: OrchestrationRpcSchemas.getArchivedShellSnapshot.output, + error: OrchestrationGetSnapshotError, + }, +); + export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { payload: OrchestrationRpcSchemas.subscribeShell.input, success: OrchestrationRpcSchemas.subscribeShell.output, @@ -497,6 +506,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, WsOrchestrationReplayEventsRpc, + WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, ); From 98e7c10fdc1a743f1d680a8a805cdaf424e3b7c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 23:19:04 -0700 Subject: [PATCH 2/7] Refactor archived thread settings refresh flow - Extract shared archived-thread snapshot state - Refresh archived lists after archive, unarchive, and delete actions - Tighten settings row and add-environment button presentation --- .../settings/ConnectionsSettings.tsx | 27 ++- .../components/settings/SettingsPanels.tsx | 155 +++++++----------- .../components/settings/settingsLayout.tsx | 6 +- apps/web/src/hooks/useThreadActions.ts | 18 +- apps/web/src/lib/archivedThreadsState.ts | 105 ++++++++++++ 5 files changed, 203 insertions(+), 108 deletions(-) create mode 100644 apps/web/src/lib/archivedThreadsState.ts diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 98c02d92f64..24a42059659 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -2662,14 +2662,25 @@ export function ConnectionsSettings() { } }} > - - - Add environment - - } - /> + + + + + } + /> + } + /> + Add environment + Add Environment diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 0223ed5240e..f97a060873d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,12 +1,10 @@ import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { defaultInstanceIdForDriver, type DesktopUpdateChannel, - type EnvironmentId, - type OrchestrationShellSnapshot, PROVIDER_DISPLAY_NAMES, ProviderDriverKind, type ProviderInstanceConfig, @@ -44,12 +42,11 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; -import { readEnvironmentApi } from "../../environmentApi"; import { useShallow } from "zustand/react/shallow"; import { selectProjectsAcrossEnvironments, useStore } from "../../store"; +import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { DraftInput } from "../ui/draft-input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; @@ -1298,50 +1295,16 @@ export function ProviderSettingsPanel() { export function ArchivedThreadsPanel() { const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); - const [archivedSnapshots, setArchivedSnapshots] = useState< - ReadonlyArray<{ - readonly environmentId: EnvironmentId; - readonly snapshot: OrchestrationShellSnapshot; - }> - >([]); - const [isLoadingArchive, setIsLoadingArchive] = useState(false); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], [projects], ); - - const refreshArchivedThreads = useCallback(async () => { - setIsLoadingArchive(true); - try { - const snapshots = await Promise.all( - environmentIds.map(async (environmentId) => { - const api = readEnvironmentApi(environmentId); - if (!api) { - return null; - } - return { - environmentId, - snapshot: await api.orchestration.getArchivedShellSnapshot(), - }; - }), - ); - setArchivedSnapshots(snapshots.filter((snapshot) => snapshot !== null)); - } finally { - setIsLoadingArchive(false); - } - }, [environmentIds]); - - useEffect(() => { - void refreshArchivedThreads().catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to load archived threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }); - }, [refreshArchivedThreads]); + const { + snapshots: archivedSnapshots, + error: archiveError, + isLoading: isLoadingArchive, + refresh: refreshArchivedThreads, + } = useArchivedThreadSnapshots(environmentIds); const archivedGroups = useMemo(() => { const projectsByEnvironmentAndId = new Map( @@ -1396,7 +1359,7 @@ export function ArchivedThreadsPanel() { if (clicked === "unarchive") { try { await unarchiveThread(threadRef); - await refreshArchivedThreads(); + refreshArchivedThreads(); } catch (error) { toastManager.add( stackedThreadToast({ @@ -1411,7 +1374,7 @@ export function ArchivedThreadsPanel() { if (clicked === "delete") { await confirmAndDeleteThread(threadRef); - await refreshArchivedThreads(); + refreshArchivedThreads(); } }, [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], @@ -1421,21 +1384,27 @@ export function ArchivedThreadsPanel() { {archivedGroups.length === 0 ? ( - - - {isLoadingArchive ? : } - - - - {isLoadingArchive ? "Loading archived threads" : "No archived threads"} - - + + {isLoadingArchive ? ( + + ) : ( + + )} {isLoadingArchive - ? "Checking connected environments." - : "Archived threads will appear here."} - - - + ? "Loading archived threads" + : archiveError + ? "Could not load archived threads" + : "No archived threads"} + + } + description={ + isLoadingArchive + ? "Checking connected environments." + : (archiveError ?? "Archived threads will appear here.") + } + /> ) : ( archivedGroups.map(({ project, threads: projectThreads }) => ( @@ -1445,9 +1414,8 @@ export function ArchivedThreadsPanel() { icon={} > {projectThreads.map((thread) => ( -
{ event.preventDefault(); void handleArchivedThreadContextMenu( @@ -1458,39 +1426,40 @@ export function ArchivedThreadsPanel() { }, ); }} - > -
-

{thread.title}

-

+ title={thread.title} + description={ + <> Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} {" \u00b7 Created "} {formatRelativeTimeLabel(thread.createdAt)} -

-
- -
+ + } + control={ + + } + /> ))} )) diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 54c2c99b865..391898b1110 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -52,7 +52,9 @@ export function SettingsRow({ resetAction, control, children, -}: { + className, + ...rowProps +}: Omit, "title"> & { title: ReactNode; description: ReactNode; status?: ReactNode; @@ -62,9 +64,11 @@ export function SettingsRow({ }) { return (
diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 46a1955cc49..5ecdd118d4c 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -9,6 +9,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "./useHandleNewThread"; import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; import { invalidateGitQueries } from "../lib/gitReactQuery"; +import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; import { newCommandId } from "../lib/utils"; import { readLocalApi } from "../localApi"; import { @@ -68,19 +69,22 @@ export function useThreadActions() { throw new Error("Cannot archive a running thread."); } - await api.orchestration.dispatchCommand({ + const currentRouteThreadRef = getCurrentRouteThreadRef(); + const shouldNavigateToDraft = + currentRouteThreadRef?.threadId === threadRef.threadId && + currentRouteThreadRef.environmentId === threadRef.environmentId; + const archiveCommand = api.orchestration.dispatchCommand({ type: "thread.archive", commandId: newCommandId(), threadId: threadRef.threadId, }); - const currentRouteThreadRef = getCurrentRouteThreadRef(); - if ( - currentRouteThreadRef?.threadId === threadRef.threadId && - currentRouteThreadRef.environmentId === threadRef.environmentId - ) { + if (shouldNavigateToDraft) { await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)); } + + await archiveCommand; + refreshArchivedThreadsForEnvironment(threadRef.environmentId); }, [getCurrentRouteThreadRef, resolveThreadTarget], ); @@ -93,6 +97,7 @@ export function useThreadActions() { commandId: newCommandId(), threadId: target.threadId, }); + refreshArchivedThreadsForEnvironment(target.environmentId); }, []); const deleteThread = useCallback( @@ -175,6 +180,7 @@ export function useThreadActions() { commandId: newCommandId(), threadId: threadRef.threadId, }); + refreshArchivedThreadsForEnvironment(threadRef.environmentId); clearComposerDraftForThread(threadRef); clearProjectDraftThreadById( scopeProjectRef(threadRef.environmentId, thread.projectId), diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts new file mode 100644 index 00000000000..542d5bb641b --- /dev/null +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -0,0 +1,105 @@ +import { useAtomValue } from "@effect/atom-react"; +import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; +import { Cause, Effect, Option } from "effect"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; + +import { readEnvironmentApi } from "../environmentApi"; +import { appAtomRegistry } from "../rpc/atomRegistry"; + +const ARCHIVED_THREADS_STALE_TIME_MS = 5_000; +const ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000; +const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; + +export type ArchivedSnapshotEntry = { + readonly environmentId: EnvironmentId; + readonly snapshot: OrchestrationShellSnapshot; +}; + +const knownArchivedThreadEnvironmentKeys = new Set(); + +function makeArchivedThreadsEnvironmentKey(environmentIds: ReadonlyArray): string { + return environmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR); +} + +function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray { + if (key.length === 0) { + return []; + } + return key + .split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR) + .map((environmentId) => EnvironmentId.make(environmentId)); +} + +const archivedThreadSnapshotsAtom = Atom.family((environmentKey: string) => { + knownArchivedThreadEnvironmentKeys.add(environmentKey); + return Atom.make( + Effect.promise(async (): Promise> => { + const environmentIds = parseArchivedThreadsEnvironmentKey(environmentKey); + const snapshots = await Promise.all( + environmentIds.map(async (environmentId) => { + const api = readEnvironmentApi(environmentId); + if (!api) { + return null; + } + return { + environmentId, + snapshot: await api.orchestration.getArchivedShellSnapshot(), + }; + }), + ); + return snapshots.filter((snapshot) => snapshot !== null); + }), + ).pipe( + Atom.swr({ + staleTime: ARCHIVED_THREADS_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(ARCHIVED_THREADS_IDLE_TTL_MS), + Atom.withLabel(`archived-thread-snapshots:${environmentKey}`), + ); +}); + +function readArchivedThreadsError( + result: AsyncResult.AsyncResult, unknown>, +): string | null { + if (result._tag !== "Failure") { + return null; + } + + const error = Cause.squash(result.cause); + return error instanceof Error ? error.message : "Failed to load archived threads."; +} + +export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { + for (const key of knownArchivedThreadEnvironmentKeys) { + if (parseArchivedThreadsEnvironmentKey(key).includes(environmentId)) { + appAtomRegistry.refresh(archivedThreadSnapshotsAtom(key)); + } + } +} + +export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray): { + readonly snapshots: ReadonlyArray; + readonly error: string | null; + readonly isLoading: boolean; + readonly refresh: () => void; +} { + const environmentKey = useMemo( + () => makeArchivedThreadsEnvironmentKey(environmentIds), + [environmentIds], + ); + const atom = archivedThreadSnapshotsAtom(environmentKey); + const result = useAtomValue(atom); + const snapshots = Option.getOrElse(AsyncResult.value(result), () => []); + const refresh = useCallback(() => { + appAtomRegistry.refresh(atom); + }, [atom]); + + return { + snapshots, + error: readArchivedThreadsError(result), + isLoading: result.waiting, + refresh, + }; +} From 81d67b3853cfac726c498243604c95c966549893 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 23:23:32 -0700 Subject: [PATCH 3/7] Expand add-environment settings button - Switch the environment trigger from icon-only to a labeled compact button - Adjust sizing and muted styling to better fit the connections settings UI --- apps/web/src/components/settings/ConnectionsSettings.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 24a42059659..79fb086ad36 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -2668,12 +2668,13 @@ export function ConnectionsSettings() { + Add environment } /> From 2e56b195f4c2ba74de392432818f4ec94e1e2ec8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 23:29:46 -0700 Subject: [PATCH 4/7] Soften settings metadata text color - Reduce muted foreground opacity in provider and source control settings - Improve readability of auth/status rows without changing layout --- apps/web/src/components/settings/ProviderInstanceCard.tsx | 2 +- apps/web/src/components/settings/SourceControlSettings.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 44ba093615f..9a00ccb75ae 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -648,7 +648,7 @@ export function ProviderInstanceCard({ ); const authRowNode = ( -

+

{hasAuthenticatedEmail ? ( <> Authenticated as diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index c58a4468363..ca6c01fce37 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -226,7 +226,7 @@ function DiscoveryItemRow({ ) : null}

-

+

{itemSummary({ item, auth, authAccount })}

From d7c1c450ede13b7bdfb4fc54072be3ed5dd23982 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 8 May 2026 06:27:21 +0000 Subject: [PATCH 5/7] Fix cross-environment thread mixing and unstable atom family key - Add environmentId check to thread filter in ArchivedThreadsPanel to prevent threads from different environments being grouped together when they share the same projectId. - Sort environment IDs before joining in makeArchivedThreadsEnvironmentKey to produce a stable atom family key regardless of input order. --- apps/web/src/components/settings/SettingsPanels.tsx | 5 ++++- apps/web/src/lib/archivedThreadsState.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f97a060873d..fb4e9b19a80 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1334,7 +1334,10 @@ export function ArchivedThreadsPanel() { .map((project) => ({ project, threads: threads - .filter((thread) => thread.projectId === project.id) + .filter( + (thread) => + thread.projectId === project.id && thread.environmentId === project.environmentId, + ) .toSorted((left, right) => { const leftKey = left.archivedAt ?? left.createdAt; const rightKey = right.archivedAt ?? right.createdAt; diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index 542d5bb641b..befaeb7a9ea 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -19,7 +19,7 @@ export type ArchivedSnapshotEntry = { const knownArchivedThreadEnvironmentKeys = new Set(); function makeArchivedThreadsEnvironmentKey(environmentIds: ReadonlyArray): string { - return environmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR); + return [...environmentIds].sort().join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR); } function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray { From e78f20c91b64abfe28b2939bdffbfc252c35ddae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 8 May 2026 06:37:17 +0000 Subject: [PATCH 6/7] Use toSorted() instead of spread+sort --- apps/web/src/lib/archivedThreadsState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index befaeb7a9ea..c978e7690eb 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -19,7 +19,7 @@ export type ArchivedSnapshotEntry = { const knownArchivedThreadEnvironmentKeys = new Set(); function makeArchivedThreadsEnvironmentKey(environmentIds: ReadonlyArray): string { - return [...environmentIds].sort().join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR); + return environmentIds.toSorted().join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR); } function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray { From f9fc1ecddb5cb9008a8045e29dffdeaeae4c3d75 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 8 May 2026 06:55:29 +0000 Subject: [PATCH 7/7] fix: allow deleting archived threads not present in main store - deleteThread: dispatch delete command directly when thread is not found in the main store (e.g. archived threads excluded by the new WHERE clause) - confirmAndDeleteThread: remove early-return on missing resolved thread; fall back to generic title for confirmation dialog - Remove redundant ORDER BY archived_at ASC from listActiveThreadRows query (WHERE clause already guarantees archived_at IS NULL) Applied via @cursor push command --- .../Layers/ProjectionSnapshotQuery.ts | 2 +- apps/web/src/hooks/useThreadActions.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 96196a3bad8..0de43f6a6c0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -349,7 +349,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NULL - ORDER BY project_id ASC, archived_at ASC, created_at ASC, thread_id ASC + ORDER BY project_id ASC, created_at ASC, thread_id ASC `, }); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 5ecdd118d4c..7fc4bb2efd1 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -105,7 +105,16 @@ export function useThreadActions() { const api = readEnvironmentApi(target.environmentId); if (!api) return; const resolved = resolveThreadTarget(target); - if (!resolved) return; + if (!resolved) { + // Thread not in main store (e.g. archived thread) — dispatch delete directly. + await api.orchestration.dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId: target.threadId, + }); + refreshArchivedThreadsForEnvironment(target.environmentId); + return; + } const { thread, threadRef } = resolved; const state = useStore.getState(); const threads = selectThreadsForEnvironment(state, threadRef.environmentId); @@ -258,13 +267,12 @@ export function useThreadActions() { if (!api) return; const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); - if (!resolved) return; - const { thread } = resolved; if (confirmThreadDelete && localApi) { + const title = resolved?.thread.title ?? "this thread"; const confirmed = await localApi.dialogs.confirm( [ - `Delete thread "${thread.title}"?`, + `Delete thread "${title}"?`, "This permanently clears conversation history for this thread.", ].join("\n"), );