authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-08 15:49:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-08 15:49:41-07:00
log63859aa072249c64153ef20faeb46018154724b8
treeb0f1b726f5b9c6cdf2719f0083338a3d39aaa750
parent7bfacd55d89524ee2bce68e5ae863222c4609f68
signature Signed by PGP key B5690EEEBB952194

perf: Add archived shell snapshot support (#2593)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

25 files changed, 761 insertions(+), 75 deletions(-)

apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+6
...@@ -94,6 +94,8 @@ describe("CheckpointDiffQueryLive", () => {...@@ -94,6 +94,8 @@ describe("CheckpointDiffQueryLive", () => {
94 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),94 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
95 getShellSnapshot: () =>95 getShellSnapshot: () =>
96 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),96 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
97 getArchivedShellSnapshot: () =>
98 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
97 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),99 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
98 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),100 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
99 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),101 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
...@@ -174,6 +176,8 @@ describe("CheckpointDiffQueryLive", () => {...@@ -174,6 +176,8 @@ describe("CheckpointDiffQueryLive", () => {
174 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),176 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
175 getShellSnapshot: () =>177 getShellSnapshot: () =>
176 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),178 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
179 getArchivedShellSnapshot: () =>
180 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
177 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),181 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
178 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),182 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
179 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),183 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
...@@ -222,6 +226,8 @@ describe("CheckpointDiffQueryLive", () => {...@@ -222,6 +226,8 @@ describe("CheckpointDiffQueryLive", () => {
222 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),226 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
223 getShellSnapshot: () =>227 getShellSnapshot: () =>
224 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),228 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
229 getArchivedShellSnapshot: () =>
230 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
225 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),231 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
226 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),232 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
227 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),233 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+7
...@@ -183,6 +183,13 @@ describe("OrchestrationEngine", () => {...@@ -183,6 +183,13 @@ describe("OrchestrationEngine", () => {
183 threads: [],183 threads: [],
184 updatedAt: projectionSnapshot.updatedAt,184 updatedAt: projectionSnapshot.updatedAt,
185 }),185 }),
186 getArchivedShellSnapshot: () =>
187 Effect.succeed({
188 snapshotSequence: projectionSnapshot.snapshotSequence,
189 projects: [],
190 threads: [],
191 updatedAt: projectionSnapshot.updatedAt,
192 }),
186 getSnapshotSequence: () =>193 getSnapshotSequence: () =>
187 Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }),194 Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }),
188 getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }),195 getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+120
...@@ -441,6 +441,126 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -441,6 +441,126 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
441 }),441 }),
442 );442 );
443443
444 it.effect("keeps archived threads out of the main shell snapshot", () =>
445 Effect.gen(function* () {
446 const snapshotQuery = yield* ProjectionSnapshotQuery;
447 const sql = yield* SqlClient.SqlClient;
448
449 yield* sql`DELETE FROM projection_projects`;
450 yield* sql`DELETE FROM projection_threads`;
451 yield* sql`DELETE FROM projection_state`;
452
453 yield* sql`
454 INSERT INTO projection_projects (
455 project_id,
456 title,
457 workspace_root,
458 default_model_selection_json,
459 scripts_json,
460 created_at,
461 updated_at,
462 deleted_at
463 )
464 VALUES (
465 'project-archive-test',
466 'Archive Test',
467 '/tmp/archive-test',
468 '{"provider":"codex","model":"gpt-5-codex"}',
469 '[]',
470 '2026-04-06T00:00:00.000Z',
471 '2026-04-06T00:00:01.000Z',
472 NULL
473 )
474 `;
475
476 yield* sql`
477 INSERT INTO projection_threads (
478 thread_id,
479 project_id,
480 title,
481 model_selection_json,
482 runtime_mode,
483 interaction_mode,
484 branch,
485 worktree_path,
486 latest_turn_id,
487 latest_user_message_at,
488 pending_approval_count,
489 pending_user_input_count,
490 has_actionable_proposed_plan,
491 created_at,
492 updated_at,
493 archived_at,
494 deleted_at
495 )
496 VALUES
497 (
498 'thread-active',
499 'project-archive-test',
500 'Active Thread',
501 '{"provider":"codex","model":"gpt-5-codex"}',
502 'full-access',
503 'default',
504 NULL,
505 NULL,
506 NULL,
507 NULL,
508 0,
509 0,
510 0,
511 '2026-04-06T00:00:02.000Z',
512 '2026-04-06T00:00:03.000Z',
513 NULL,
514 NULL
515 ),
516 (
517 'thread-archived',
518 'project-archive-test',
519 'Archived Thread',
520 '{"provider":"codex","model":"gpt-5-codex"}',
521 'full-access',
522 'default',
523 NULL,
524 NULL,
525 NULL,
526 NULL,
527 0,
528 0,
529 0,
530 '2026-04-06T00:00:04.000Z',
531 '2026-04-06T00:00:05.000Z',
532 '2026-04-06T00:00:06.000Z',
533 NULL
534 )
535 `;
536
537 yield* sql`
538 INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
539 VALUES
540 (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:07.000Z'),
541 (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:07.000Z'),
542 (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:07.000Z'),
543 (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'),
544 (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'),
545 (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'),
546 (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z')
547 `;
548
549 const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
550 assert.deepEqual(
551 shellSnapshot.threads.map((thread) => thread.id),
552 [ThreadId.make("thread-active")],
553 );
554
555 const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot();
556 assert.deepEqual(
557 archivedShellSnapshot.threads.map((thread) => thread.id),
558 [ThreadId.make("thread-archived")],
559 );
560 assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z");
561 }),
562 );
563
444 it.effect(564 it.effect(
445 "reads targeted project, thread, and count queries without hydrating the full snapshot",565 "reads targeted project, thread, and count queries without hydrating the full snapshot",
446 () =>566 () =>
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+276-2
...@@ -327,6 +327,66 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -327,6 +327,66 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
327 `,327 `,
328 });328 });
329329
330 const listActiveThreadRows = SqlSchema.findAll({
331 Request: Schema.Void,
332 Result: ProjectionThreadDbRowSchema,
333 execute: () =>
334 sql`
335 SELECT
336 thread_id AS "threadId",
337 project_id AS "projectId",
338 title,
339 model_selection_json AS "modelSelection",
340 runtime_mode AS "runtimeMode",
341 interaction_mode AS "interactionMode",
342 branch,
343 worktree_path AS "worktreePath",
344 latest_turn_id AS "latestTurnId",
345 created_at AS "createdAt",
346 updated_at AS "updatedAt",
347 archived_at AS "archivedAt",
348 latest_user_message_at AS "latestUserMessageAt",
349 pending_approval_count AS "pendingApprovalCount",
350 pending_user_input_count AS "pendingUserInputCount",
351 has_actionable_proposed_plan AS "hasActionableProposedPlan",
352 deleted_at AS "deletedAt"
353 FROM projection_threads
354 WHERE deleted_at IS NULL
355 AND archived_at IS NULL
356 ORDER BY project_id ASC, created_at ASC, thread_id ASC
357 `,
358 });
359
360 const listArchivedThreadRows = SqlSchema.findAll({
361 Request: Schema.Void,
362 Result: ProjectionThreadDbRowSchema,
363 execute: () =>
364 sql`
365 SELECT
366 thread_id AS "threadId",
367 project_id AS "projectId",
368 title,
369 model_selection_json AS "modelSelection",
370 runtime_mode AS "runtimeMode",
371 interaction_mode AS "interactionMode",
372 branch,
373 worktree_path AS "worktreePath",
374 latest_turn_id AS "latestTurnId",
375 created_at AS "createdAt",
376 updated_at AS "updatedAt",
377 archived_at AS "archivedAt",
378 latest_user_message_at AS "latestUserMessageAt",
379 pending_approval_count AS "pendingApprovalCount",
380 pending_user_input_count AS "pendingUserInputCount",
381 has_actionable_proposed_plan AS "hasActionableProposedPlan",
382 deleted_at AS "deletedAt"
383 FROM projection_threads
384 WHERE deleted_at IS NULL
385 AND archived_at IS NOT NULL
386 ORDER BY project_id ASC, archived_at DESC, thread_id DESC
387 `,
388 });
389
330 const listThreadMessageRows = SqlSchema.findAll({390 const listThreadMessageRows = SqlSchema.findAll({
331 Request: Schema.Void,391 Request: Schema.Void,
332 Result: ProjectionThreadMessageDbRowSchema,392 Result: ProjectionThreadMessageDbRowSchema,
...@@ -411,6 +471,56 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -411,6 +471,56 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
411 `,471 `,
412 });472 });
413473
474 const listActiveThreadSessionRows = SqlSchema.findAll({
475 Request: Schema.Void,
476 Result: ProjectionThreadSessionDbRowSchema,
477 execute: () =>
478 sql`
479 SELECT
480 sessions.thread_id AS "threadId",
481 sessions.status,
482 sessions.provider_name AS "providerName",
483 sessions.provider_instance_id AS "providerInstanceId",
484 sessions.provider_session_id AS "providerSessionId",
485 sessions.provider_thread_id AS "providerThreadId",
486 sessions.runtime_mode AS "runtimeMode",
487 sessions.active_turn_id AS "activeTurnId",
488 sessions.last_error AS "lastError",
489 sessions.updated_at AS "updatedAt"
490 FROM projection_thread_sessions sessions
491 INNER JOIN projection_threads threads
492 ON threads.thread_id = sessions.thread_id
493 WHERE threads.deleted_at IS NULL
494 AND threads.archived_at IS NULL
495 ORDER BY sessions.thread_id ASC
496 `,
497 });
498
499 const listArchivedThreadSessionRows = SqlSchema.findAll({
500 Request: Schema.Void,
501 Result: ProjectionThreadSessionDbRowSchema,
502 execute: () =>
503 sql`
504 SELECT
505 sessions.thread_id AS "threadId",
506 sessions.status,
507 sessions.provider_name AS "providerName",
508 sessions.provider_instance_id AS "providerInstanceId",
509 sessions.provider_session_id AS "providerSessionId",
510 sessions.provider_thread_id AS "providerThreadId",
511 sessions.runtime_mode AS "runtimeMode",
512 sessions.active_turn_id AS "activeTurnId",
513 sessions.last_error AS "lastError",
514 sessions.updated_at AS "updatedAt"
515 FROM projection_thread_sessions sessions
516 INNER JOIN projection_threads threads
517 ON threads.thread_id = sessions.thread_id
518 WHERE threads.deleted_at IS NULL
519 AND threads.archived_at IS NOT NULL
520 ORDER BY sessions.thread_id ASC
521 `,
522 });
523
414 const listCheckpointRows = SqlSchema.findAll({524 const listCheckpointRows = SqlSchema.findAll({
415 Request: Schema.Void,525 Request: Schema.Void,
416 Result: ProjectionCheckpointDbRowSchema,526 Result: ProjectionCheckpointDbRowSchema,
...@@ -475,6 +585,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -475,6 +585,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
475 ON turns.thread_id = threads.thread_id585 ON turns.thread_id = threads.thread_id
476 AND turns.turn_id = threads.latest_turn_id586 AND turns.turn_id = threads.latest_turn_id
477 WHERE threads.deleted_at IS NULL587 WHERE threads.deleted_at IS NULL
588 AND threads.archived_at IS NULL
589 AND threads.latest_turn_id IS NOT NULL
590 ORDER BY turns.thread_id ASC
591 `,
592 });
593
594 const listArchivedLatestTurnRows = SqlSchema.findAll({
595 Request: Schema.Void,
596 Result: ProjectionLatestTurnDbRowSchema,
597 execute: () =>
598 sql`
599 SELECT
600 turns.thread_id AS "threadId",
601 turns.turn_id AS "turnId",
602 turns.state,
603 turns.requested_at AS "requestedAt",
604 turns.started_at AS "startedAt",
605 turns.completed_at AS "completedAt",
606 turns.assistant_message_id AS "assistantMessageId",
607 turns.source_proposed_plan_thread_id AS "sourceProposedPlanThreadId",
608 turns.source_proposed_plan_id AS "sourceProposedPlanId"
609 FROM projection_threads threads
610 JOIN projection_turns turns
611 ON turns.thread_id = threads.thread_id
612 AND turns.turn_id = threads.latest_turn_id
613 WHERE threads.deleted_at IS NULL
614 AND threads.archived_at IS NOT NULL
478 AND threads.latest_turn_id IS NOT NULL615 AND threads.latest_turn_id IS NOT NULL
479 ORDER BY turns.thread_id ASC616 ORDER BY turns.thread_id ASC
480 `,617 `,
...@@ -557,6 +694,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -557,6 +694,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
557 FROM projection_threads694 FROM projection_threads
558 WHERE project_id = ${projectId}695 WHERE project_id = ${projectId}
559 AND deleted_at IS NULL696 AND deleted_at IS NULL
697 AND archived_at IS NULL
560 ORDER BY created_at ASC, thread_id ASC698 ORDER BY created_at ASC, thread_id ASC
561 LIMIT 1699 LIMIT 1
562 `,700 `,
...@@ -607,6 +745,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -607,6 +745,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
607 FROM projection_threads745 FROM projection_threads
608 WHERE thread_id = ${threadId}746 WHERE thread_id = ${threadId}
609 AND deleted_at IS NULL747 AND deleted_at IS NULL
748 AND archived_at IS NULL
610 LIMIT 1749 LIMIT 1
611 `,750 `,
612 });751 });
...@@ -717,6 +856,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -717,6 +856,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
717 AND turns.turn_id = threads.latest_turn_id856 AND turns.turn_id = threads.latest_turn_id
718 WHERE threads.thread_id = ${threadId}857 WHERE threads.thread_id = ${threadId}
719 AND threads.deleted_at IS NULL858 AND threads.deleted_at IS NULL
859 AND threads.archived_at IS NULL
720 LIMIT 1860 LIMIT 1
721 `,861 `,
722 });862 });
...@@ -1232,7 +1372,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1232,7 +1372,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1232 ),1372 ),
1233 ),1373 ),
1234 ),1374 ),
1235 listThreadRows(undefined).pipe(1375 listActiveThreadRows(undefined).pipe(
1236 Effect.mapError(1376 Effect.mapError(
1237 toPersistenceSqlOrDecodeError(1377 toPersistenceSqlOrDecodeError(
1238 "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query",1378 "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query",
...@@ -1240,7 +1380,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1240,7 +1380,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1240 ),1380 ),
1241 ),1381 ),
1242 ),1382 ),
1243 listThreadSessionRows(undefined).pipe(1383 listActiveThreadSessionRows(undefined).pipe(
1244 Effect.mapError(1384 Effect.mapError(
1245 toPersistenceSqlOrDecodeError(1385 toPersistenceSqlOrDecodeError(
1246 "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query",1386 "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query",
...@@ -1350,6 +1490,139 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1350,6 +1490,139 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1350 }),1490 }),
1351 );1491 );
13521492
1493 const getArchivedShellSnapshot: ProjectionSnapshotQueryShape["getArchivedShellSnapshot"] = () =>
1494 sql
1495 .withTransaction(
1496 Effect.all([
1497 listProjectRows(undefined).pipe(
1498 Effect.mapError(
1499 toPersistenceSqlOrDecodeError(
1500 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjects:query",
1501 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjects:decodeRows",
1502 ),
1503 ),
1504 ),
1505 listArchivedThreadRows(undefined).pipe(
1506 Effect.mapError(
1507 toPersistenceSqlOrDecodeError(
1508 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreads:query",
1509 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreads:decodeRows",
1510 ),
1511 ),
1512 ),
1513 listArchivedThreadSessionRows(undefined).pipe(
1514 Effect.mapError(
1515 toPersistenceSqlOrDecodeError(
1516 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadSessions:query",
1517 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadSessions:decodeRows",
1518 ),
1519 ),
1520 ),
1521 listArchivedLatestTurnRows(undefined).pipe(
1522 Effect.mapError(
1523 toPersistenceSqlOrDecodeError(
1524 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listLatestTurns:query",
1525 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listLatestTurns:decodeRows",
1526 ),
1527 ),
1528 ),
1529 listProjectionStateRows(undefined).pipe(
1530 Effect.mapError(
1531 toPersistenceSqlOrDecodeError(
1532 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjectionState:query",
1533 "ProjectionSnapshotQuery.getArchivedShellSnapshot:listProjectionState:decodeRows",
1534 ),
1535 ),
1536 ),
1537 ]),
1538 )
1539 .pipe(
1540 Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) =>
1541 Effect.gen(function* () {
1542 let updatedAt: string | null = null;
1543 for (const row of projectRows) {
1544 updatedAt = maxIso(updatedAt, row.updatedAt);
1545 }
1546 for (const row of threadRows) {
1547 updatedAt = maxIso(updatedAt, row.updatedAt);
1548 }
1549 for (const row of sessionRows) {
1550 updatedAt = maxIso(updatedAt, row.updatedAt);
1551 }
1552 for (const row of latestTurnRows) {
1553 updatedAt = maxIso(updatedAt, row.requestedAt);
1554 if (row.startedAt !== null) {
1555 updatedAt = maxIso(updatedAt, row.startedAt);
1556 }
1557 if (row.completedAt !== null) {
1558 updatedAt = maxIso(updatedAt, row.completedAt);
1559 }
1560 }
1561 for (const row of stateRows) {
1562 updatedAt = maxIso(updatedAt, row.updatedAt);
1563 }
1564
1565 const activeProjectIds = new Set(threadRows.map((row) => row.projectId));
1566 const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(
1567 projectRows.filter((row) => activeProjectIds.has(row.projectId)),
1568 );
1569 const latestTurnByThread = new Map(
1570 latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const),
1571 );
1572 const sessionByThread = new Map(
1573 sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const),
1574 );
1575
1576 const snapshot = {
1577 snapshotSequence: computeSnapshotSequence(stateRows),
1578 projects: projectRows
1579 .filter((row) => row.deletedAt === null && activeProjectIds.has(row.projectId))
1580 .map((row) =>
1581 mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null),
1582 ),
1583 threads: threadRows.map(
1584 (row): OrchestrationThreadShell => ({
1585 id: row.threadId,
1586 projectId: row.projectId,
1587 title: row.title,
1588 modelSelection: row.modelSelection,
1589 runtimeMode: row.runtimeMode,
1590 interactionMode: row.interactionMode,
1591 branch: row.branch,
1592 worktreePath: row.worktreePath,
1593 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
1594 createdAt: row.createdAt,
1595 updatedAt: row.updatedAt,
1596 archivedAt: row.archivedAt,
1597 session: sessionByThread.get(row.threadId) ?? null,
1598 latestUserMessageAt: row.latestUserMessageAt,
1599 hasPendingApprovals: row.pendingApprovalCount > 0,
1600 hasPendingUserInput: row.pendingUserInputCount > 0,
1601 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
1602 }),
1603 ),
1604 updatedAt: updatedAt ?? new Date(0).toISOString(),
1605 };
1606
1607 return yield* decodeShellSnapshot(snapshot).pipe(
1608 Effect.mapError(
1609 toPersistenceDecodeError(
1610 "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot",
1611 ),
1612 ),
1613 );
1614 }),
1615 ),
1616 Effect.mapError((error) => {
1617 if (isPersistenceError(error)) {
1618 return error;
1619 }
1620 return toPersistenceSqlError("ProjectionSnapshotQuery.getArchivedShellSnapshot:query")(
1621 error,
1622 );
1623 }),
1624 );
1625
1353 const getSnapshotSequence: ProjectionSnapshotQueryShape["getSnapshotSequence"] = () =>1626 const getSnapshotSequence: ProjectionSnapshotQueryShape["getSnapshotSequence"] = () =>
1354 listProjectionStateRows(undefined).pipe(1627 listProjectionStateRows(undefined).pipe(
1355 Effect.mapError(1628 Effect.mapError(
...@@ -1683,6 +1956,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1683,6 +1956,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1683 getCommandReadModel,1956 getCommandReadModel,
1684 getSnapshot,1957 getSnapshot,
1685 getShellSnapshot,1958 getShellSnapshot,
1959 getArchivedShellSnapshot,
1686 getSnapshotSequence,1960 getSnapshotSequence,
1687 getCounts,1961 getCounts,
1688 getActiveProjectByWorkspaceRoot,1962 getActiveProjectByWorkspaceRoot,
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+11
...@@ -72,6 +72,17 @@ export interface ProjectionSnapshotQueryShape {...@@ -72,6 +72,17 @@ export interface ProjectionSnapshotQueryShape {
72 ProjectionRepositoryError72 ProjectionRepositoryError
73 >;73 >;
7474
75 /**
76 * Read archived thread shell summaries for the archive page.
77 *
78 * This query is separate from the main shell snapshot so archived threads
79 * are never bootstrapped into normal navigation state.
80 */
81 readonly getArchivedShellSnapshot: () => Effect.Effect<
82 OrchestrationShellSnapshot,
83 ProjectionRepositoryError
84 >;
85
75 /**86 /**
76 * Read the latest projection snapshot sequence without hydrating read-model87 * Read the latest projection snapshot sequence without hydrating read-model
77 * entities.88 * entities.
apps/server/src/persistence/Migrations.ts+2
...@@ -42,6 +42,7 @@ import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts...@@ -42,6 +42,7 @@ import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts
42import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts";42import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts";
43import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts";43import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts";
44import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";44import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";
45import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts";
4546
46/**47/**
47 * Migration loader with all migrations defined inline.48 * Migration loader with all migrations defined inline.
...@@ -83,6 +84,7 @@ export const migrationEntries = [...@@ -83,6 +84,7 @@ export const migrationEntries = [
83 [27, "ProviderSessionRuntimeInstanceId", Migration0027],84 [27, "ProviderSessionRuntimeInstanceId", Migration0027],
84 [28, "ProjectionThreadSessionInstanceId", Migration0028],85 [28, "ProjectionThreadSessionInstanceId", Migration0028],
85 [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],86 [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],
87 [30, "ProjectionThreadShellArchiveIndexes", Migration0030],
86] as const;88] as const;
8789
88export const makeMigrationLoader = (throughId?: number) =>90export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/030_ProjectionThreadShellArchiveIndexes.ts created+16
...@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 CREATE INDEX IF NOT EXISTS idx_projection_threads_shell_active
9 ON projection_threads(deleted_at, archived_at, project_id, created_at, thread_id)
10 `;
11
12 yield* sql`
13 CREATE INDEX IF NOT EXISTS idx_projection_threads_shell_archived
14 ON projection_threads(deleted_at, archived_at, project_id, thread_id)
15 `;
16});
apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts+1
...@@ -25,6 +25,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>...@@ -25,6 +25,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
25 getCommandReadModel: () => Effect.die("unused"),25 getCommandReadModel: () => Effect.die("unused"),
26 getSnapshot: () => Effect.die("unused"),26 getSnapshot: () => Effect.die("unused"),
27 getShellSnapshot: () => Effect.die("unused"),27 getShellSnapshot: () => Effect.die("unused"),
28 getArchivedShellSnapshot: () => Effect.die("unused"),
28 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }),29 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }),
29 getCounts: () => Effect.die("unused"),30 getCounts: () => Effect.die("unused"),
30 getActiveProjectByWorkspaceRoot: (workspaceRoot) =>31 getActiveProjectByWorkspaceRoot: (workspaceRoot) =>
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
...@@ -194,6 +194,7 @@ describe("ProviderSessionReaper", () => {...@@ -194,6 +194,7 @@ describe("ProviderSessionReaper", () => {
194 getCommandReadModel: () => Effect.die("unused"),194 getCommandReadModel: () => Effect.die("unused"),
195 getSnapshot: () => Effect.die("unused"),195 getSnapshot: () => Effect.die("unused"),
196 getShellSnapshot: () => Effect.die("unused"),196 getShellSnapshot: () => Effect.die("unused"),
197 getArchivedShellSnapshot: () => Effect.die("unused"),
197 getSnapshotSequence: () =>198 getSnapshotSequence: () =>
198 Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }),199 Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }),
199 getCounts: () => Effect.die("unused"),200 getCounts: () => Effect.die("unused"),
apps/server/src/server.test.ts+7
...@@ -630,6 +630,13 @@ const buildAppUnderTest = (options?: {...@@ -630,6 +630,13 @@ const buildAppUnderTest = (options?: {
630 threads: [],630 threads: [],
631 updatedAt: "1970-01-01T00:00:00.000Z",631 updatedAt: "1970-01-01T00:00:00.000Z",
632 }),632 }),
633 getArchivedShellSnapshot: () =>
634 Effect.succeed({
635 snapshotSequence: 0,
636 projects: [],
637 threads: [],
638 updatedAt: new Date(0).toISOString(),
639 }),
633 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),640 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
634 getProjectShellById: () => Effect.succeed(Option.none()),641 getProjectShellById: () => Effect.succeed(Option.none()),
635 getThreadShellById: () => Effect.succeed(Option.none()),642 getThreadShellById: () => Effect.succeed(Option.none()),
apps/server/src/serverRuntimeStartup.test.ts+3
...@@ -85,6 +85,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa...@@ -85,6 +85,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa
85 getCommandReadModel: () => Effect.die("unused"),85 getCommandReadModel: () => Effect.die("unused"),
86 getSnapshot: () => Effect.die("unused"),86 getSnapshot: () => Effect.die("unused"),
87 getShellSnapshot: () => Effect.die("unused"),87 getShellSnapshot: () => Effect.die("unused"),
88 getArchivedShellSnapshot: () => Effect.die("unused"),
88 getSnapshotSequence: () => Effect.die("unused"),89 getSnapshotSequence: () => Effect.die("unused"),
89 getCounts: () =>90 getCounts: () =>
90 Deferred.await(releaseCounts).pipe(91 Deferred.await(releaseCounts).pipe(
...@@ -139,6 +140,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -139,6 +140,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
139 getCommandReadModel: () => Effect.die("unused"),140 getCommandReadModel: () => Effect.die("unused"),
140 getSnapshot: () => Effect.die("unused"),141 getSnapshot: () => Effect.die("unused"),
141 getShellSnapshot: () => Effect.die("unused"),142 getShellSnapshot: () => Effect.die("unused"),
143 getArchivedShellSnapshot: () => Effect.die("unused"),
142 getSnapshotSequence: () => Effect.die("unused"),144 getSnapshotSequence: () => Effect.die("unused"),
143 getCounts: () => Effect.die("unused"),145 getCounts: () => Effect.die("unused"),
144 getActiveProjectByWorkspaceRoot: () =>146 getActiveProjectByWorkspaceRoot: () =>
...@@ -191,6 +193,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -191,6 +193,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
191 getCommandReadModel: () => Effect.die("unused"),193 getCommandReadModel: () => Effect.die("unused"),
192 getSnapshot: () => Effect.die("unused"),194 getSnapshot: () => Effect.die("unused"),
193 getShellSnapshot: () => Effect.die("unused"),195 getShellSnapshot: () => Effect.die("unused"),
196 getArchivedShellSnapshot: () => Effect.die("unused"),
194 getSnapshotSequence: () => Effect.die("unused"),197 getSnapshotSequence: () => Effect.die("unused"),
195 getCounts: () => Effect.die("unused"),198 getCounts: () => Effect.die("unused"),
196 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),199 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
apps/server/src/ws.ts+29
...@@ -308,6 +308,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -308,6 +308,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
308 }),308 }),
309 );309 );
310 case "thread.deleted":310 case "thread.deleted":
311 case "thread.archived":
311 return Effect.succeed(312 return Effect.succeed(
312 Option.some({313 Option.some({
313 kind: "thread-removed" as const,314 kind: "thread-removed" as const,
...@@ -315,6 +316,17 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -315,6 +316,17 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
315 threadId: event.payload.threadId,316 threadId: event.payload.threadId,
316 }),317 }),
317 );318 );
319 case "thread.unarchived":
320 return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe(
321 Effect.map((thread) =>
322 Option.map(thread, (nextThread) => ({
323 kind: "thread-upserted" as const,
324 sequence: event.sequence,
325 thread: nextThread,
326 })),
327 ),
328 Effect.catch(() => Effect.succeed(Option.none())),
329 );
318 default:330 default:
319 if (event.aggregateKind !== "thread") {331 if (event.aggregateKind !== "thread") {
320 return Effect.succeed(Option.none());332 return Effect.succeed(Option.none());
...@@ -734,6 +746,23 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>...@@ -734,6 +746,23 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
734 }),746 }),
735 { "rpc.aggregate": "orchestration" },747 { "rpc.aggregate": "orchestration" },
736 ),748 ),
749 [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: (_input) =>
750 observeRpcEffect(
751 ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot,
752 projectionSnapshotQuery.getArchivedShellSnapshot().pipe(
753 Effect.tapError((cause) =>
754 Effect.logError("orchestration archived shell snapshot load failed", { cause }),
755 ),
756 Effect.mapError(
757 (cause) =>
758 new OrchestrationGetSnapshotError({
759 message: "Failed to load archived orchestration shell snapshot",
760 cause,
761 }),
762 ),
763 ),
764 { "rpc.aggregate": "orchestration" },
765 ),
737 [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) =>766 [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) =>
738 observeRpcStreamEffect(767 observeRpcStreamEffect(
739 ORCHESTRATION_WS_METHODS.subscribeThread,768 ORCHESTRATION_WS_METHODS.subscribeThread,
apps/web/src/components/ChatView.browser.tsx+3
...@@ -218,6 +218,9 @@ function createMockEnvironmentApi(input: {...@@ -218,6 +218,9 @@ function createMockEnvironmentApi(input: {
218 getFullThreadDiff: (() => {218 getFullThreadDiff: (() => {
219 throw new Error("Not implemented in browser test.");219 throw new Error("Not implemented in browser test.");
220 }) as EnvironmentApi["orchestration"]["getFullThreadDiff"],220 }) as EnvironmentApi["orchestration"]["getFullThreadDiff"],
221 getArchivedShellSnapshot: (() => {
222 throw new Error("Not implemented in browser test.");
223 }) as EnvironmentApi["orchestration"]["getArchivedShellSnapshot"],
221 subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"],224 subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"],
222 subscribeThread: (() => () =>225 subscribeThread: (() => () =>
223 undefined) as EnvironmentApi["orchestration"]["subscribeThread"],226 undefined) as EnvironmentApi["orchestration"]["subscribeThread"],
apps/web/src/components/settings/ConnectionsSettings.tsx+20-8
...@@ -2662,14 +2662,26 @@ export function ConnectionsSettings() {...@@ -2662,14 +2662,26 @@ export function ConnectionsSettings() {
2662 }2662 }
2663 }}2663 }}
2664 >2664 >
2665 <DialogTrigger2665 <Tooltip>
2666 render={2666 <TooltipTrigger
2667 <Button size="xs" variant="outline">2667 render={
2668 <PlusIcon className="size-3" />2668 <DialogTrigger
2669 Add environment2669 render={
2670 </Button>2670 <Button
2671 }2671 size="xs"
2672 />2672 variant="ghost"
2673 className="h-5 gap-1 rounded-sm px-1 text-[11px] font-normal text-muted-foreground/60 hover:text-muted-foreground"
2674 aria-label="Add environment"
2675 >
2676 <PlusIcon className="size-3" />
2677 <span>Add environment</span>
2678 </Button>
2679 }
2680 />
2681 }
2682 />
2683 <TooltipPopup side="top">Add environment</TooltipPopup>
2684 </Tooltip>
2673 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">2685 <DialogPopup className="max-h-[80dvh] sm:max-w-3xl">
2674 <DialogHeader>2686 <DialogHeader>
2675 <DialogTitle>Add Environment</DialogTitle>2687 <DialogTitle>Add Environment</DialogTitle>
apps/web/src/components/settings/ProviderInstanceCard.tsx+1-1
...@@ -648,7 +648,7 @@ export function ProviderInstanceCard({...@@ -648,7 +648,7 @@ export function ProviderInstanceCard({
648 );648 );
649649
650 const authRowNode = (650 const authRowNode = (
651 <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground">651 <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground/80">
652 {hasAuthenticatedEmail ? (652 {hasAuthenticatedEmail ? (
653 <>653 <>
654 <span>Authenticated as</span>654 <span>Authenticated as</span>
apps/web/src/components/settings/SettingsPanels.tsx+98-52
...@@ -44,14 +44,10 @@ import {...@@ -44,14 +44,10 @@ import {
44} from "../../providerInstances";44} from "../../providerInstances";
45import { ensureLocalApi, readLocalApi } from "../../localApi";45import { ensureLocalApi, readLocalApi } from "../../localApi";
46import { useShallow } from "zustand/react/shallow";46import { useShallow } from "zustand/react/shallow";
47import {47import { selectProjectsAcrossEnvironments, useStore } from "../../store";
48 selectProjectsAcrossEnvironments,48import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
49 selectThreadShellsAcrossEnvironments,
50 useStore,
51} from "../../store";
52import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";49import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
53import { Button } from "../ui/button";50import { Button } from "../ui/button";
54import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
55import { DraftInput } from "../ui/draft-input";51import { DraftInput } from "../ui/draft-input";
56import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";52import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
57import { Switch } from "../ui/switch";53import { Switch } from "../ui/switch";
...@@ -1330,14 +1326,50 @@ export function ProviderSettingsPanel() {...@@ -1330,14 +1326,50 @@ export function ProviderSettingsPanel() {
13301326
1331export function ArchivedThreadsPanel() {1327export function ArchivedThreadsPanel() {
1332 const projects = useStore(useShallow(selectProjectsAcrossEnvironments));1328 const projects = useStore(useShallow(selectProjectsAcrossEnvironments));
1333 const threads = useStore(useShallow(selectThreadShellsAcrossEnvironments));
1334 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();1329 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
1330 const environmentIds = useMemo(
1331 () => [...new Set(projects.map((project) => project.environmentId))],
1332 [projects],
1333 );
1334 const {
1335 snapshots: archivedSnapshots,
1336 error: archiveError,
1337 isLoading: isLoadingArchive,
1338 refresh: refreshArchivedThreads,
1339 } = useArchivedThreadSnapshots(environmentIds);
1340
1335 const archivedGroups = useMemo(() => {1341 const archivedGroups = useMemo(() => {
1336 return projects1342 const projectsByEnvironmentAndId = new Map(
1343 archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
1344 snapshot.projects.map(
1345 (project) =>
1346 [
1347 `${environmentId}:${project.id}`,
1348 {
1349 id: project.id,
1350 environmentId,
1351 name: project.title,
1352 cwd: project.workspaceRoot,
1353 },
1354 ] as const,
1355 ),
1356 ),
1357 );
1358 const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
1359 snapshot.threads.map((thread) => ({
1360 ...thread,
1361 environmentId,
1362 })),
1363 );
1364
1365 return [...projectsByEnvironmentAndId.values()]
1337 .map((project) => ({1366 .map((project) => ({
1338 project,1367 project,
1339 threads: threads1368 threads: threads
1340 .filter((thread) => thread.projectId === project.id && thread.archivedAt !== null)1369 .filter(
1370 (thread) =>
1371 thread.projectId === project.id && thread.environmentId === project.environmentId,
1372 )
1341 .toSorted((left, right) => {1373 .toSorted((left, right) => {
1342 const leftKey = left.archivedAt ?? left.createdAt;1374 const leftKey = left.archivedAt ?? left.createdAt;
1343 const rightKey = right.archivedAt ?? right.createdAt;1375 const rightKey = right.archivedAt ?? right.createdAt;
...@@ -1345,7 +1377,7 @@ export function ArchivedThreadsPanel() {...@@ -1345,7 +1377,7 @@ export function ArchivedThreadsPanel() {
1345 }),1377 }),
1346 }))1378 }))
1347 .filter((group) => group.threads.length > 0);1379 .filter((group) => group.threads.length > 0);
1348 }, [projects, threads]);1380 }, [archivedSnapshots]);
13491381
1350 const handleArchivedThreadContextMenu = useCallback(1382 const handleArchivedThreadContextMenu = useCallback(
1351 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {1383 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
...@@ -1362,6 +1394,7 @@ export function ArchivedThreadsPanel() {...@@ -1362,6 +1394,7 @@ export function ArchivedThreadsPanel() {
1362 if (clicked === "unarchive") {1394 if (clicked === "unarchive") {
1363 try {1395 try {
1364 await unarchiveThread(threadRef);1396 await unarchiveThread(threadRef);
1397 refreshArchivedThreads();
1365 } catch (error) {1398 } catch (error) {
1366 toastManager.add(1399 toastManager.add(
1367 stackedThreadToast({1400 stackedThreadToast({
...@@ -1376,24 +1409,37 @@ export function ArchivedThreadsPanel() {...@@ -1376,24 +1409,37 @@ export function ArchivedThreadsPanel() {
13761409
1377 if (clicked === "delete") {1410 if (clicked === "delete") {
1378 await confirmAndDeleteThread(threadRef);1411 await confirmAndDeleteThread(threadRef);
1412 refreshArchivedThreads();
1379 }1413 }
1380 },1414 },
1381 [confirmAndDeleteThread, unarchiveThread],1415 [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread],
1382 );1416 );
13831417
1384 return (1418 return (
1385 <SettingsPageContainer>1419 <SettingsPageContainer>
1386 {archivedGroups.length === 0 ? (1420 {archivedGroups.length === 0 ? (
1387 <SettingsSection title="Archived threads">1421 <SettingsSection title="Archived threads">
1388 <Empty className="min-h-88">1422 <SettingsRow
1389 <EmptyMedia variant="icon">1423 title={
1390 <ArchiveIcon />1424 <span className="inline-flex items-center gap-2">
1391 </EmptyMedia>1425 {isLoadingArchive ? (
1392 <EmptyHeader>1426 <LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
1393 <EmptyTitle>No archived threads</EmptyTitle>1427 ) : (
1394 <EmptyDescription>Archived threads will appear here.</EmptyDescription>1428 <ArchiveIcon className="size-3.5 text-muted-foreground" />
1395 </EmptyHeader>1429 )}
1396 </Empty>1430 {isLoadingArchive
1431 ? "Loading archived threads"
1432 : archiveError
1433 ? "Could not load archived threads"
1434 : "No archived threads"}
1435 </span>
1436 }
1437 description={
1438 isLoadingArchive
1439 ? "Checking connected environments."
1440 : (archiveError ?? "Archived threads will appear here.")
1441 }
1442 />
1397 </SettingsSection>1443 </SettingsSection>
1398 ) : (1444 ) : (
1399 archivedGroups.map(({ project, threads: projectThreads }) => (1445 archivedGroups.map(({ project, threads: projectThreads }) => (
...@@ -1403,9 +1449,8 @@ export function ArchivedThreadsPanel() {...@@ -1403,9 +1449,8 @@ export function ArchivedThreadsPanel() {
1403 icon={<ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />}1449 icon={<ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />}
1404 >1450 >
1405 {projectThreads.map((thread) => (1451 {projectThreads.map((thread) => (
1406 <div1452 <SettingsRow
1407 key={thread.id}1453 key={thread.id}
1408 className="flex items-center justify-between gap-3 border-t border-border px-4 py-3 first:border-t-0 sm:px-5"
1409 onContextMenu={(event) => {1454 onContextMenu={(event) => {
1410 event.preventDefault();1455 event.preventDefault();
1411 void handleArchivedThreadContextMenu(1456 void handleArchivedThreadContextMenu(
...@@ -1416,39 +1461,40 @@ export function ArchivedThreadsPanel() {...@@ -1416,39 +1461,40 @@ export function ArchivedThreadsPanel() {
1416 },1461 },
1417 );1462 );
1418 }}1463 }}
1419 >1464 title={thread.title}
1420 <div className="min-w-0 flex-1">1465 description={
1421 <h3 className="truncate text-sm font-medium text-foreground">{thread.title}</h3>1466 <>
1422 <p className="text-xs text-muted-foreground">
1423 Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)}1467 Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)}
1424 {" \u00b7 Created "}1468 {" \u00b7 Created "}
1425 {formatRelativeTimeLabel(thread.createdAt)}1469 {formatRelativeTimeLabel(thread.createdAt)}
1426 </p>1470 </>
1427 </div>1471 }
1428 <Button1472 control={
1429 type="button"1473 <Button
1430 variant="outline"1474 type="button"
1431 size="sm"1475 variant="outline"
1432 className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5"1476 size="sm"
1433 onClick={() =>1477 className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5"
1434 void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch(1478 onClick={() =>
1435 (error) => {1479 void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id))
1436 toastManager.add(1480 .then(() => refreshArchivedThreads())
1437 stackedThreadToast({1481 .catch((error) => {
1438 type: "error",1482 toastManager.add(
1439 title: "Failed to unarchive thread",1483 stackedThreadToast({
1440 description:1484 type: "error",
1441 error instanceof Error ? error.message : "An error occurred.",1485 title: "Failed to unarchive thread",
1442 }),1486 description:
1443 );1487 error instanceof Error ? error.message : "An error occurred.",
1444 },1488 }),
1445 )1489 );
1446 }1490 })
1447 >1491 }
1448 <ArchiveX className="size-3.5" />1492 >
1449 <span>Unarchive</span>1493 <ArchiveX className="size-3.5" />
1450 </Button>1494 <span>Unarchive</span>
1451 </div>1495 </Button>
1496 }
1497 />
1452 ))}1498 ))}
1453 </SettingsSection>1499 </SettingsSection>
1454 ))1500 ))
apps/web/src/components/settings/SourceControlSettings.tsx+1-1
...@@ -226,7 +226,7 @@ function DiscoveryItemRow({...@@ -226,7 +226,7 @@ function DiscoveryItemRow({
226 </Badge>226 </Badge>
227 ) : null}227 ) : null}
228 </div>228 </div>
229 <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground">229 <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground/80">
230 {itemSummary({ item, auth, authAccount })}230 {itemSummary({ item, auth, authAccount })}
231 </p>231 </p>
232 </div>232 </div>
apps/web/src/components/settings/settingsLayout.tsx+5-1
...@@ -52,7 +52,9 @@ export function SettingsRow({...@@ -52,7 +52,9 @@ export function SettingsRow({
52 resetAction,52 resetAction,
53 control,53 control,
54 children,54 children,
55}: {55 className,
56 ...rowProps
57}: Omit<ComponentPropsWithoutRef<"div">, "title"> & {
56 title: ReactNode;58 title: ReactNode;
57 description: ReactNode;59 description: ReactNode;
58 status?: ReactNode;60 status?: ReactNode;
...@@ -62,9 +64,11 @@ export function SettingsRow({...@@ -62,9 +64,11 @@ export function SettingsRow({
62}) {64}) {
63 return (65 return (
64 <div66 <div
67 {...rowProps}
65 className={cn(68 className={cn(
66 "border-t border-border/60 px-4 first:border-t-0 sm:px-5",69 "border-t border-border/60 px-4 first:border-t-0 sm:px-5",
67 children ? "pt-3.5 pb-0" : "py-3.5",70 children ? "pt-3.5 pb-0" : "py-3.5",
71 className,
68 )}72 )}
69 >73 >
70 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">74 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
apps/web/src/environmentApi.ts+1
...@@ -47,6 +47,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {...@@ -47,6 +47,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
47 dispatchCommand: rpcClient.orchestration.dispatchCommand,47 dispatchCommand: rpcClient.orchestration.dispatchCommand,
48 getTurnDiff: rpcClient.orchestration.getTurnDiff,48 getTurnDiff: rpcClient.orchestration.getTurnDiff,
49 getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff,49 getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff,
50 getArchivedShellSnapshot: rpcClient.orchestration.getArchivedShellSnapshot,
50 subscribeShell: (callback, options) =>51 subscribeShell: (callback, options) =>
51 rpcClient.orchestration.subscribeShell(callback, options),52 rpcClient.orchestration.subscribeShell(callback, options),
52 subscribeThread: (input, callback, options) =>53 subscribeThread: (input, callback, options) =>
apps/web/src/hooks/useThreadActions.ts+24-10
...@@ -9,6 +9,7 @@ import { useComposerDraftStore } from "../composerDraftStore";...@@ -9,6 +9,7 @@ import { useComposerDraftStore } from "../composerDraftStore";
9import { useNewThreadHandler } from "./useHandleNewThread";9import { useNewThreadHandler } from "./useHandleNewThread";
10import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";10import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";
11import { invalidateGitQueries } from "../lib/gitReactQuery";11import { invalidateGitQueries } from "../lib/gitReactQuery";
12import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
12import { newCommandId } from "../lib/utils";13import { newCommandId } from "../lib/utils";
13import { readLocalApi } from "../localApi";14import { readLocalApi } from "../localApi";
14import {15import {
...@@ -68,19 +69,22 @@ export function useThreadActions() {...@@ -68,19 +69,22 @@ export function useThreadActions() {
68 throw new Error("Cannot archive a running thread.");69 throw new Error("Cannot archive a running thread.");
69 }70 }
7071
71 await api.orchestration.dispatchCommand({72 const currentRouteThreadRef = getCurrentRouteThreadRef();
73 const shouldNavigateToDraft =
74 currentRouteThreadRef?.threadId === threadRef.threadId &&
75 currentRouteThreadRef.environmentId === threadRef.environmentId;
76 const archiveCommand = api.orchestration.dispatchCommand({
72 type: "thread.archive",77 type: "thread.archive",
73 commandId: newCommandId(),78 commandId: newCommandId(),
74 threadId: threadRef.threadId,79 threadId: threadRef.threadId,
75 });80 });
76 const currentRouteThreadRef = getCurrentRouteThreadRef();
7781
78 if (82 if (shouldNavigateToDraft) {
79 currentRouteThreadRef?.threadId === threadRef.threadId &&
80 currentRouteThreadRef.environmentId === threadRef.environmentId
81 ) {
82 await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId));83 await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId));
83 }84 }
85
86 await archiveCommand;
87 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
84 },88 },
85 [getCurrentRouteThreadRef, resolveThreadTarget],89 [getCurrentRouteThreadRef, resolveThreadTarget],
86 );90 );
...@@ -93,6 +97,7 @@ export function useThreadActions() {...@@ -93,6 +97,7 @@ export function useThreadActions() {
93 commandId: newCommandId(),97 commandId: newCommandId(),
94 threadId: target.threadId,98 threadId: target.threadId,
95 });99 });
100 refreshArchivedThreadsForEnvironment(target.environmentId);
96 }, []);101 }, []);
97102
98 const deleteThread = useCallback(103 const deleteThread = useCallback(
...@@ -100,7 +105,16 @@ export function useThreadActions() {...@@ -100,7 +105,16 @@ export function useThreadActions() {
100 const api = readEnvironmentApi(target.environmentId);105 const api = readEnvironmentApi(target.environmentId);
101 if (!api) return;106 if (!api) return;
102 const resolved = resolveThreadTarget(target);107 const resolved = resolveThreadTarget(target);
103 if (!resolved) return;108 if (!resolved) {
109 // Thread not in main store (e.g. archived thread) — dispatch delete directly.
110 await api.orchestration.dispatchCommand({
111 type: "thread.delete",
112 commandId: newCommandId(),
113 threadId: target.threadId,
114 });
115 refreshArchivedThreadsForEnvironment(target.environmentId);
116 return;
117 }
104 const { thread, threadRef } = resolved;118 const { thread, threadRef } = resolved;
105 const state = useStore.getState();119 const state = useStore.getState();
106 const threads = selectThreadsForEnvironment(state, threadRef.environmentId);120 const threads = selectThreadsForEnvironment(state, threadRef.environmentId);
...@@ -175,6 +189,7 @@ export function useThreadActions() {...@@ -175,6 +189,7 @@ export function useThreadActions() {
175 commandId: newCommandId(),189 commandId: newCommandId(),
176 threadId: threadRef.threadId,190 threadId: threadRef.threadId,
177 });191 });
192 refreshArchivedThreadsForEnvironment(threadRef.environmentId);
178 clearComposerDraftForThread(threadRef);193 clearComposerDraftForThread(threadRef);
179 clearProjectDraftThreadById(194 clearProjectDraftThreadById(
180 scopeProjectRef(threadRef.environmentId, thread.projectId),195 scopeProjectRef(threadRef.environmentId, thread.projectId),
...@@ -252,13 +267,12 @@ export function useThreadActions() {...@@ -252,13 +267,12 @@ export function useThreadActions() {
252 if (!api) return;267 if (!api) return;
253 const localApi = readLocalApi();268 const localApi = readLocalApi();
254 const resolved = resolveThreadTarget(target);269 const resolved = resolveThreadTarget(target);
255 if (!resolved) return;
256 const { thread } = resolved;
257270
258 if (confirmThreadDelete && localApi) {271 if (confirmThreadDelete && localApi) {
272 const title = resolved?.thread.title ?? "this thread";
259 const confirmed = await localApi.dialogs.confirm(273 const confirmed = await localApi.dialogs.confirm(
260 [274 [
261 `Delete thread "${thread.title}"?`,275 `Delete thread "${title}"?`,
262 "This permanently clears conversation history for this thread.",276 "This permanently clears conversation history for this thread.",
263 ].join("\n"),277 ].join("\n"),
264 );278 );
apps/web/src/lib/archivedThreadsState.ts created+105
...@@ -0,0 +1,105 @@
1import { useAtomValue } from "@effect/atom-react";
2import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
3import { Cause, Effect, Option } from "effect";
4import { AsyncResult, Atom } from "effect/unstable/reactivity";
5import { useCallback, useMemo } from "react";
6
7import { readEnvironmentApi } from "../environmentApi";
8import { appAtomRegistry } from "../rpc/atomRegistry";
9
10const ARCHIVED_THREADS_STALE_TIME_MS = 5_000;
11const ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000;
12const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f";
13
14export type ArchivedSnapshotEntry = {
15 readonly environmentId: EnvironmentId;
16 readonly snapshot: OrchestrationShellSnapshot;
17};
18
19const knownArchivedThreadEnvironmentKeys = new Set<string>();
20
21function makeArchivedThreadsEnvironmentKey(environmentIds: ReadonlyArray<EnvironmentId>): string {
22 return environmentIds.toSorted().join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR);
23}
24
25function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray<EnvironmentId> {
26 if (key.length === 0) {
27 return [];
28 }
29 return key
30 .split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR)
31 .map((environmentId) => EnvironmentId.make(environmentId));
32}
33
34const archivedThreadSnapshotsAtom = Atom.family((environmentKey: string) => {
35 knownArchivedThreadEnvironmentKeys.add(environmentKey);
36 return Atom.make(
37 Effect.promise(async (): Promise<ReadonlyArray<ArchivedSnapshotEntry>> => {
38 const environmentIds = parseArchivedThreadsEnvironmentKey(environmentKey);
39 const snapshots = await Promise.all(
40 environmentIds.map(async (environmentId) => {
41 const api = readEnvironmentApi(environmentId);
42 if (!api) {
43 return null;
44 }
45 return {
46 environmentId,
47 snapshot: await api.orchestration.getArchivedShellSnapshot(),
48 };
49 }),
50 );
51 return snapshots.filter((snapshot) => snapshot !== null);
52 }),
53 ).pipe(
54 Atom.swr({
55 staleTime: ARCHIVED_THREADS_STALE_TIME_MS,
56 revalidateOnMount: true,
57 }),
58 Atom.setIdleTTL(ARCHIVED_THREADS_IDLE_TTL_MS),
59 Atom.withLabel(`archived-thread-snapshots:${environmentKey}`),
60 );
61});
62
63function readArchivedThreadsError(
64 result: AsyncResult.AsyncResult<ReadonlyArray<ArchivedSnapshotEntry>, unknown>,
65): string | null {
66 if (result._tag !== "Failure") {
67 return null;
68 }
69
70 const error = Cause.squash(result.cause);
71 return error instanceof Error ? error.message : "Failed to load archived threads.";
72}
73
74export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void {
75 for (const key of knownArchivedThreadEnvironmentKeys) {
76 if (parseArchivedThreadsEnvironmentKey(key).includes(environmentId)) {
77 appAtomRegistry.refresh(archivedThreadSnapshotsAtom(key));
78 }
79 }
80}
81
82export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray<EnvironmentId>): {
83 readonly snapshots: ReadonlyArray<ArchivedSnapshotEntry>;
84 readonly error: string | null;
85 readonly isLoading: boolean;
86 readonly refresh: () => void;
87} {
88 const environmentKey = useMemo(
89 () => makeArchivedThreadsEnvironmentKey(environmentIds),
90 [environmentIds],
91 );
92 const atom = archivedThreadSnapshotsAtom(environmentKey);
93 const result = useAtomValue(atom);
94 const snapshots = Option.getOrElse(AsyncResult.value(result), () => []);
95 const refresh = useCallback(() => {
96 appAtomRegistry.refresh(atom);
97 }, [atom]);
98
99 return {
100 snapshots,
101 error: readArchivedThreadsError(result),
102 isLoading: result.waiting,
103 refresh,
104 };
105}
apps/web/src/rpc/wsRpcClient.ts+7
...@@ -143,6 +143,9 @@ export interface WsRpcClient {...@@ -143,6 +143,9 @@ export interface WsRpcClient {
143 readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>;143 readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>;
144 readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>;144 readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>;
145 readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>;145 readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>;
146 readonly getArchivedShellSnapshot: RpcUnaryNoArgMethod<
147 typeof ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot
148 >;
146 readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>;149 readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>;
147 readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>;150 readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>;
148 };151 };
...@@ -288,6 +291,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {...@@ -288,6 +291,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
288 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)),291 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)),
289 getFullThreadDiff: (input) =>292 getFullThreadDiff: (input) =>
290 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)),293 transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)),
294 getArchivedShellSnapshot: () =>
295 transport.request((client) =>
296 client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]({}),
297 ),
291 subscribeShell: (listener, options) =>298 subscribeShell: (listener, options) =>
292 transport.subscribe(299 transport.subscribe(
293 (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}),300 (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}),
packages/contracts/src/ipc.ts+2
...@@ -55,6 +55,7 @@ import type {...@@ -55,6 +55,7 @@ import type {
55 OrchestrationGetFullThreadDiffResult,55 OrchestrationGetFullThreadDiffResult,
56 OrchestrationGetTurnDiffInput,56 OrchestrationGetTurnDiffInput,
57 OrchestrationGetTurnDiffResult,57 OrchestrationGetTurnDiffResult,
58 OrchestrationShellSnapshot,
58 OrchestrationShellStreamItem,59 OrchestrationShellStreamItem,
59 OrchestrationSubscribeThreadInput,60 OrchestrationSubscribeThreadInput,
60 OrchestrationThreadStreamItem,61 OrchestrationThreadStreamItem,
...@@ -544,6 +545,7 @@ export interface EnvironmentApi {...@@ -544,6 +545,7 @@ export interface EnvironmentApi {
544 getFullThreadDiff: (545 getFullThreadDiff: (
545 input: OrchestrationGetFullThreadDiffInput,546 input: OrchestrationGetFullThreadDiffInput,
546 ) => Promise<OrchestrationGetFullThreadDiffResult>;547 ) => Promise<OrchestrationGetFullThreadDiffResult>;
548 getArchivedShellSnapshot: () => Promise<OrchestrationShellSnapshot>;
547 subscribeShell: (549 subscribeShell: (
548 callback: (event: OrchestrationShellStreamItem) => void,550 callback: (event: OrchestrationShellStreamItem) => void,
549 options?: {551 options?: {
packages/contracts/src/orchestration.ts+5
...@@ -27,6 +27,7 @@ export const ORCHESTRATION_WS_METHODS = {...@@ -27,6 +27,7 @@ export const ORCHESTRATION_WS_METHODS = {
27 getTurnDiff: "orchestration.getTurnDiff",27 getTurnDiff: "orchestration.getTurnDiff",
28 getFullThreadDiff: "orchestration.getFullThreadDiff",28 getFullThreadDiff: "orchestration.getFullThreadDiff",
29 replayEvents: "orchestration.replayEvents",29 replayEvents: "orchestration.replayEvents",
30 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
30 subscribeShell: "orchestration.subscribeShell",31 subscribeShell: "orchestration.subscribeShell",
31 subscribeThread: "orchestration.subscribeThread",32 subscribeThread: "orchestration.subscribeThread",
32} as const;33} as const;
...@@ -1222,6 +1223,10 @@ export const OrchestrationRpcSchemas = {...@@ -1222,6 +1223,10 @@ export const OrchestrationRpcSchemas = {
1222 input: OrchestrationReplayEventsInput,1223 input: OrchestrationReplayEventsInput,
1223 output: OrchestrationReplayEventsResult,1224 output: OrchestrationReplayEventsResult,
1224 },1225 },
1226 getArchivedShellSnapshot: {
1227 input: Schema.Struct({}),
1228 output: OrchestrationShellSnapshot,
1229 },
1225 subscribeThread: {1230 subscribeThread: {
1226 input: OrchestrationSubscribeThreadInput,1231 input: OrchestrationSubscribeThreadInput,
1227 output: OrchestrationThreadStreamItem,1232 output: OrchestrationThreadStreamItem,
packages/contracts/src/rpc.ts+10
...@@ -410,6 +410,15 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS....@@ -410,6 +410,15 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.
410 error: OrchestrationReplayEventsError,410 error: OrchestrationReplayEventsError,
411});411});
412412
413export const WsOrchestrationGetArchivedShellSnapshotRpc = Rpc.make(
414 ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot,
415 {
416 payload: OrchestrationRpcSchemas.getArchivedShellSnapshot.input,
417 success: OrchestrationRpcSchemas.getArchivedShellSnapshot.output,
418 error: OrchestrationGetSnapshotError,
419 },
420);
421
413export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, {422export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, {
414 payload: OrchestrationRpcSchemas.subscribeShell.input,423 payload: OrchestrationRpcSchemas.subscribeShell.input,
415 success: OrchestrationRpcSchemas.subscribeShell.output,424 success: OrchestrationRpcSchemas.subscribeShell.output,
...@@ -497,6 +506,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -497,6 +506,7 @@ export const WsRpcGroup = RpcGroup.make(
497 WsOrchestrationGetTurnDiffRpc,506 WsOrchestrationGetTurnDiffRpc,
498 WsOrchestrationGetFullThreadDiffRpc,507 WsOrchestrationGetFullThreadDiffRpc,
499 WsOrchestrationReplayEventsRpc,508 WsOrchestrationReplayEventsRpc,
509 WsOrchestrationGetArchivedShellSnapshotRpc,
500 WsOrchestrationSubscribeShellRpc,510 WsOrchestrationSubscribeShellRpc,
501 WsOrchestrationSubscribeThreadRpc,511 WsOrchestrationSubscribeThreadRpc,
502);512);