| author | |
| committer | |
| log | 63859aa072249c64153ef20faeb46018154724b8 |
| tree | b0f1b726f5b9c6cdf2719f0083338a3d39aaa750 |
| parent | 7bfacd55d89524ee2bce68e5ae863222c4609f68 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 94 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), |
| 95 | 95 | getShellSnapshot: () => |
| 96 | 96 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), |
| 97 | getArchivedShellSnapshot: () => | |
| 98 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 97 | 99 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), |
| 98 | 100 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 99 | 101 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| ... | ... | @@ -174,6 +176,8 @@ describe("CheckpointDiffQueryLive", () => { |
| 174 | 176 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), |
| 175 | 177 | getShellSnapshot: () => |
| 176 | 178 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), |
| 179 | getArchivedShellSnapshot: () => | |
| 180 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 177 | 181 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), |
| 178 | 182 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 179 | 183 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| ... | ... | @@ -222,6 +226,8 @@ describe("CheckpointDiffQueryLive", () => { |
| 222 | 226 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), |
| 223 | 227 | getShellSnapshot: () => |
| 224 | 228 | Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), |
| 229 | getArchivedShellSnapshot: () => | |
| 230 | Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), | |
| 225 | 231 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), |
| 226 | 232 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 227 | 233 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+7| ... | ... | @@ -183,6 +183,13 @@ describe("OrchestrationEngine", () => { |
| 183 | 183 | threads: [], |
| 184 | 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 | 193 | getSnapshotSequence: () => |
| 187 | 194 | Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }), |
| 188 | 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 | 441 | }), |
| 442 | 442 | ); |
| 443 | 443 | |
| 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 | 564 | it.effect( |
| 445 | 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 | 327 | `, |
| 328 | 328 | }); |
| 329 | 329 | |
| 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 | 390 | const listThreadMessageRows = SqlSchema.findAll({ |
| 331 | 391 | Request: Schema.Void, |
| 332 | 392 | Result: ProjectionThreadMessageDbRowSchema, |
| ... | ... | @@ -411,6 +471,56 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 411 | 471 | `, |
| 412 | 472 | }); |
| 413 | 473 | |
| 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 | 524 | const listCheckpointRows = SqlSchema.findAll({ |
| 415 | 525 | Request: Schema.Void, |
| 416 | 526 | Result: ProjectionCheckpointDbRowSchema, |
| ... | ... | @@ -475,6 +585,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 475 | 585 | ON turns.thread_id = threads.thread_id |
| 476 | 586 | AND turns.turn_id = threads.latest_turn_id |
| 477 | 587 | 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 | 615 | AND threads.latest_turn_id IS NOT NULL |
| 479 | 616 | ORDER BY turns.thread_id ASC |
| 480 | 617 | `, |
| ... | ... | @@ -557,6 +694,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 557 | 694 | FROM projection_threads |
| 558 | 695 | WHERE project_id = ${projectId} |
| 559 | 696 | AND deleted_at IS NULL |
| 697 | AND archived_at IS NULL | |
| 560 | 698 | ORDER BY created_at ASC, thread_id ASC |
| 561 | 699 | LIMIT 1 |
| 562 | 700 | `, |
| ... | ... | @@ -607,6 +745,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 607 | 745 | FROM projection_threads |
| 608 | 746 | WHERE thread_id = ${threadId} |
| 609 | 747 | AND deleted_at IS NULL |
| 748 | AND archived_at IS NULL | |
| 610 | 749 | LIMIT 1 |
| 611 | 750 | `, |
| 612 | 751 | }); |
| ... | ... | @@ -717,6 +856,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 717 | 856 | AND turns.turn_id = threads.latest_turn_id |
| 718 | 857 | WHERE threads.thread_id = ${threadId} |
| 719 | 858 | AND threads.deleted_at IS NULL |
| 859 | AND threads.archived_at IS NULL | |
| 720 | 860 | LIMIT 1 |
| 721 | 861 | `, |
| 722 | 862 | }); |
| ... | ... | @@ -1232,7 +1372,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1232 | 1372 | ), |
| 1233 | 1373 | ), |
| 1234 | 1374 | ), |
| 1235 | listThreadRows(undefined).pipe( | |
| 1375 | listActiveThreadRows(undefined).pipe( | |
| 1236 | 1376 | Effect.mapError( |
| 1237 | 1377 | toPersistenceSqlOrDecodeError( |
| 1238 | 1378 | "ProjectionSnapshotQuery.getShellSnapshot:listThreads:query", |
| ... | ... | @@ -1240,7 +1380,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1240 | 1380 | ), |
| 1241 | 1381 | ), |
| 1242 | 1382 | ), |
| 1243 | listThreadSessionRows(undefined).pipe( | |
| 1383 | listActiveThreadSessionRows(undefined).pipe( | |
| 1244 | 1384 | Effect.mapError( |
| 1245 | 1385 | toPersistenceSqlOrDecodeError( |
| 1246 | 1386 | "ProjectionSnapshotQuery.getShellSnapshot:listThreadSessions:query", |
| ... | ... | @@ -1350,6 +1490,139 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1350 | 1490 | }), |
| 1351 | 1491 | ); |
| 1352 | 1492 | |
| 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 | 1626 | const getSnapshotSequence: ProjectionSnapshotQueryShape["getSnapshotSequence"] = () => |
| 1354 | 1627 | listProjectionStateRows(undefined).pipe( |
| 1355 | 1628 | Effect.mapError( |
| ... | ... | @@ -1683,6 +1956,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 1683 | 1956 | getCommandReadModel, |
| 1684 | 1957 | getSnapshot, |
| 1685 | 1958 | getShellSnapshot, |
| 1959 | getArchivedShellSnapshot, | |
| 1686 | 1960 | getSnapshotSequence, |
| 1687 | 1961 | getCounts, |
| 1688 | 1962 | getActiveProjectByWorkspaceRoot, |
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+11| ... | ... | @@ -72,6 +72,17 @@ export interface ProjectionSnapshotQueryShape { |
| 72 | 72 | ProjectionRepositoryError |
| 73 | 73 | >; |
| 74 | 74 | |
| 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 | 87 | * Read the latest projection snapshot sequence without hydrating read-model |
| 77 | 88 | * entities. |
apps/server/src/persistence/Migrations.ts+2| ... | ... | @@ -42,6 +42,7 @@ import Migration0026 from "./Migrations/026_CanonicalizeModelSelectionOptions.ts |
| 42 | 42 | import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"; |
| 43 | 43 | import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; |
| 44 | 44 | import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; |
| 45 | import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; | |
| 45 | 46 | |
| 46 | 47 | /** |
| 47 | 48 | * Migration loader with all migrations defined inline. |
| ... | ... | @@ -83,6 +84,7 @@ export const migrationEntries = [ |
| 83 | 84 | [27, "ProviderSessionRuntimeInstanceId", Migration0027], |
| 84 | 85 | [28, "ProjectionThreadSessionInstanceId", Migration0028], |
| 85 | 86 | [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], |
| 87 | [30, "ProjectionThreadShellArchiveIndexes", Migration0030], | |
| 86 | 88 | ] as const; |
| 87 | 89 | |
| 88 | 90 | export const makeMigrationLoader = (throughId?: number) => |
apps/server/src/persistence/Migrations/030_ProjectionThreadShellArchiveIndexes.ts created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | ||
| 4 | export 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 | 25 | getCommandReadModel: () => Effect.die("unused"), |
| 26 | 26 | getSnapshot: () => Effect.die("unused"), |
| 27 | 27 | getShellSnapshot: () => Effect.die("unused"), |
| 28 | getArchivedShellSnapshot: () => Effect.die("unused"), | |
| 28 | 29 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), |
| 29 | 30 | getCounts: () => Effect.die("unused"), |
| 30 | 31 | getActiveProjectByWorkspaceRoot: (workspaceRoot) => |
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1| ... | ... | @@ -194,6 +194,7 @@ describe("ProviderSessionReaper", () => { |
| 194 | 194 | getCommandReadModel: () => Effect.die("unused"), |
| 195 | 195 | getSnapshot: () => Effect.die("unused"), |
| 196 | 196 | getShellSnapshot: () => Effect.die("unused"), |
| 197 | getArchivedShellSnapshot: () => Effect.die("unused"), | |
| 197 | 198 | getSnapshotSequence: () => |
| 198 | 199 | Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }), |
| 199 | 200 | getCounts: () => Effect.die("unused"), |
apps/server/src/server.test.ts+7| ... | ... | @@ -630,6 +630,13 @@ const buildAppUnderTest = (options?: { |
| 630 | 630 | threads: [], |
| 631 | 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 | 640 | getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), |
| 634 | 641 | getProjectShellById: () => Effect.succeed(Option.none()), |
| 635 | 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 | 85 | getCommandReadModel: () => Effect.die("unused"), |
| 86 | 86 | getSnapshot: () => Effect.die("unused"), |
| 87 | 87 | getShellSnapshot: () => Effect.die("unused"), |
| 88 | getArchivedShellSnapshot: () => Effect.die("unused"), | |
| 88 | 89 | getSnapshotSequence: () => Effect.die("unused"), |
| 89 | 90 | getCounts: () => |
| 90 | 91 | Deferred.await(releaseCounts).pipe( |
| ... | ... | @@ -139,6 +140,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa |
| 139 | 140 | getCommandReadModel: () => Effect.die("unused"), |
| 140 | 141 | getSnapshot: () => Effect.die("unused"), |
| 141 | 142 | getShellSnapshot: () => Effect.die("unused"), |
| 143 | getArchivedShellSnapshot: () => Effect.die("unused"), | |
| 142 | 144 | getSnapshotSequence: () => Effect.die("unused"), |
| 143 | 145 | getCounts: () => Effect.die("unused"), |
| 144 | 146 | getActiveProjectByWorkspaceRoot: () => |
| ... | ... | @@ -191,6 +193,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 191 | 193 | getCommandReadModel: () => Effect.die("unused"), |
| 192 | 194 | getSnapshot: () => Effect.die("unused"), |
| 193 | 195 | getShellSnapshot: () => Effect.die("unused"), |
| 196 | getArchivedShellSnapshot: () => Effect.die("unused"), | |
| 194 | 197 | getSnapshotSequence: () => Effect.die("unused"), |
| 195 | 198 | getCounts: () => Effect.die("unused"), |
| 196 | 199 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
apps/server/src/ws.ts+29| ... | ... | @@ -308,6 +308,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 308 | 308 | }), |
| 309 | 309 | ); |
| 310 | 310 | case "thread.deleted": |
| 311 | case "thread.archived": | |
| 311 | 312 | return Effect.succeed( |
| 312 | 313 | Option.some({ |
| 313 | 314 | kind: "thread-removed" as const, |
| ... | ... | @@ -315,6 +316,17 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 315 | 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 | 330 | default: |
| 319 | 331 | if (event.aggregateKind !== "thread") { |
| 320 | 332 | return Effect.succeed(Option.none()); |
| ... | ... | @@ -734,6 +746,23 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 734 | 746 | }), |
| 735 | 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 | 766 | [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => |
| 738 | 767 | observeRpcStreamEffect( |
| 739 | 768 | ORCHESTRATION_WS_METHODS.subscribeThread, |
apps/web/src/components/ChatView.browser.tsx+3| ... | ... | @@ -218,6 +218,9 @@ function createMockEnvironmentApi(input: { |
| 218 | 218 | getFullThreadDiff: (() => { |
| 219 | 219 | throw new Error("Not implemented in browser test."); |
| 220 | 220 | }) as EnvironmentApi["orchestration"]["getFullThreadDiff"], |
| 221 | getArchivedShellSnapshot: (() => { | |
| 222 | throw new Error("Not implemented in browser test."); | |
| 223 | }) as EnvironmentApi["orchestration"]["getArchivedShellSnapshot"], | |
| 221 | 224 | subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"], |
| 222 | 225 | subscribeThread: (() => () => |
| 223 | 226 | undefined) as EnvironmentApi["orchestration"]["subscribeThread"], |
apps/web/src/components/settings/ConnectionsSettings.tsx+20-8| ... | ... | @@ -2662,14 +2662,26 @@ export function ConnectionsSettings() { |
| 2662 | 2662 | } |
| 2663 | 2663 | }} |
| 2664 | 2664 | > |
| 2665 | <DialogTrigger | |
| 2666 | render={ | |
| 2667 | <Button size="xs" variant="outline"> | |
| 2668 | <PlusIcon className="size-3" /> | |
| 2669 | Add environment | |
| 2670 | </Button> | |
| 2671 | } | |
| 2672 | /> | |
| 2665 | <Tooltip> | |
| 2666 | <TooltipTrigger | |
| 2667 | render={ | |
| 2668 | <DialogTrigger | |
| 2669 | render={ | |
| 2670 | <Button | |
| 2671 | size="xs" | |
| 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 | 2685 | <DialogPopup className="max-h-[80dvh] sm:max-w-3xl"> |
| 2674 | 2686 | <DialogHeader> |
| 2675 | 2687 | <DialogTitle>Add Environment</DialogTitle> |
apps/web/src/components/settings/ProviderInstanceCard.tsx+1-1| ... | ... | @@ -648,7 +648,7 @@ export function ProviderInstanceCard({ |
| 648 | 648 | ); |
| 649 | 649 | |
| 650 | 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 | 652 | {hasAuthenticatedEmail ? ( |
| 653 | 653 | <> |
| 654 | 654 | <span>Authenticated as</span> |
apps/web/src/components/settings/SettingsPanels.tsx+98-52| ... | ... | @@ -44,14 +44,10 @@ import { |
| 44 | 44 | } from "../../providerInstances"; |
| 45 | 45 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 46 | 46 | import { useShallow } from "zustand/react/shallow"; |
| 47 | import { | |
| 48 | selectProjectsAcrossEnvironments, | |
| 49 | selectThreadShellsAcrossEnvironments, | |
| 50 | useStore, | |
| 51 | } from "../../store"; | |
| 47 | import { selectProjectsAcrossEnvironments, useStore } from "../../store"; | |
| 48 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; | |
| 52 | 49 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; |
| 53 | 50 | import { Button } from "../ui/button"; |
| 54 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; | |
| 55 | 51 | import { DraftInput } from "../ui/draft-input"; |
| 56 | 52 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 57 | 53 | import { Switch } from "../ui/switch"; |
| ... | ... | @@ -1330,14 +1326,50 @@ export function ProviderSettingsPanel() { |
| 1330 | 1326 | |
| 1331 | 1327 | export function ArchivedThreadsPanel() { |
| 1332 | 1328 | const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); |
| 1333 | const threads = useStore(useShallow(selectThreadShellsAcrossEnvironments)); | |
| 1334 | 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 | 1341 | const archivedGroups = useMemo(() => { |
| 1336 | return projects | |
| 1342 | 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 | 1366 | .map((project) => ({ |
| 1338 | 1367 | project, |
| 1339 | 1368 | 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 | 1373 | .toSorted((left, right) => { |
| 1342 | 1374 | const leftKey = left.archivedAt ?? left.createdAt; |
| 1343 | 1375 | const rightKey = right.archivedAt ?? right.createdAt; |
| ... | ... | @@ -1345,7 +1377,7 @@ export function ArchivedThreadsPanel() { |
| 1345 | 1377 | }), |
| 1346 | 1378 | })) |
| 1347 | 1379 | .filter((group) => group.threads.length > 0); |
| 1348 | }, [projects, threads]); | |
| 1380 | }, [archivedSnapshots]); | |
| 1349 | 1381 | |
| 1350 | 1382 | const handleArchivedThreadContextMenu = useCallback( |
| 1351 | 1383 | async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { |
| ... | ... | @@ -1362,6 +1394,7 @@ export function ArchivedThreadsPanel() { |
| 1362 | 1394 | if (clicked === "unarchive") { |
| 1363 | 1395 | try { |
| 1364 | 1396 | await unarchiveThread(threadRef); |
| 1397 | refreshArchivedThreads(); | |
| 1365 | 1398 | } catch (error) { |
| 1366 | 1399 | toastManager.add( |
| 1367 | 1400 | stackedThreadToast({ |
| ... | ... | @@ -1376,24 +1409,37 @@ export function ArchivedThreadsPanel() { |
| 1376 | 1409 | |
| 1377 | 1410 | if (clicked === "delete") { |
| 1378 | 1411 | await confirmAndDeleteThread(threadRef); |
| 1412 | refreshArchivedThreads(); | |
| 1379 | 1413 | } |
| 1380 | 1414 | }, |
| 1381 | [confirmAndDeleteThread, unarchiveThread], | |
| 1415 | [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], | |
| 1382 | 1416 | ); |
| 1383 | 1417 | |
| 1384 | 1418 | return ( |
| 1385 | 1419 | <SettingsPageContainer> |
| 1386 | 1420 | {archivedGroups.length === 0 ? ( |
| 1387 | 1421 | <SettingsSection title="Archived threads"> |
| 1388 | <Empty className="min-h-88"> | |
| 1389 | <EmptyMedia variant="icon"> | |
| 1390 | <ArchiveIcon /> | |
| 1391 | </EmptyMedia> | |
| 1392 | <EmptyHeader> | |
| 1393 | <EmptyTitle>No archived threads</EmptyTitle> | |
| 1394 | <EmptyDescription>Archived threads will appear here.</EmptyDescription> | |
| 1395 | </EmptyHeader> | |
| 1396 | </Empty> | |
| 1422 | <SettingsRow | |
| 1423 | title={ | |
| 1424 | <span className="inline-flex items-center gap-2"> | |
| 1425 | {isLoadingArchive ? ( | |
| 1426 | <LoaderIcon className="size-3.5 animate-spin text-muted-foreground" /> | |
| 1427 | ) : ( | |
| 1428 | <ArchiveIcon className="size-3.5 text-muted-foreground" /> | |
| 1429 | )} | |
| 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 | 1443 | </SettingsSection> |
| 1398 | 1444 | ) : ( |
| 1399 | 1445 | archivedGroups.map(({ project, threads: projectThreads }) => ( |
| ... | ... | @@ -1403,9 +1449,8 @@ export function ArchivedThreadsPanel() { |
| 1403 | 1449 | icon={<ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />} |
| 1404 | 1450 | > |
| 1405 | 1451 | {projectThreads.map((thread) => ( |
| 1406 | <div | |
| 1452 | <SettingsRow | |
| 1407 | 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 | 1454 | onContextMenu={(event) => { |
| 1410 | 1455 | event.preventDefault(); |
| 1411 | 1456 | void handleArchivedThreadContextMenu( |
| ... | ... | @@ -1416,39 +1461,40 @@ export function ArchivedThreadsPanel() { |
| 1416 | 1461 | }, |
| 1417 | 1462 | ); |
| 1418 | 1463 | }} |
| 1419 | > | |
| 1420 | <div className="min-w-0 flex-1"> | |
| 1421 | <h3 className="truncate text-sm font-medium text-foreground">{thread.title}</h3> | |
| 1422 | <p className="text-xs text-muted-foreground"> | |
| 1464 | title={thread.title} | |
| 1465 | description={ | |
| 1466 | <> | |
| 1423 | 1467 | Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} |
| 1424 | 1468 | {" \u00b7 Created "} |
| 1425 | 1469 | {formatRelativeTimeLabel(thread.createdAt)} |
| 1426 | </p> | |
| 1427 | </div> | |
| 1428 | <Button | |
| 1429 | type="button" | |
| 1430 | variant="outline" | |
| 1431 | size="sm" | |
| 1432 | className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" | |
| 1433 | onClick={() => | |
| 1434 | void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch( | |
| 1435 | (error) => { | |
| 1436 | toastManager.add( | |
| 1437 | stackedThreadToast({ | |
| 1438 | type: "error", | |
| 1439 | title: "Failed to unarchive thread", | |
| 1440 | description: | |
| 1441 | error instanceof Error ? error.message : "An error occurred.", | |
| 1442 | }), | |
| 1443 | ); | |
| 1444 | }, | |
| 1445 | ) | |
| 1446 | } | |
| 1447 | > | |
| 1448 | <ArchiveX className="size-3.5" /> | |
| 1449 | <span>Unarchive</span> | |
| 1450 | </Button> | |
| 1451 | </div> | |
| 1470 | </> | |
| 1471 | } | |
| 1472 | control={ | |
| 1473 | <Button | |
| 1474 | type="button" | |
| 1475 | variant="outline" | |
| 1476 | size="sm" | |
| 1477 | className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" | |
| 1478 | onClick={() => | |
| 1479 | void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)) | |
| 1480 | .then(() => refreshArchivedThreads()) | |
| 1481 | .catch((error) => { | |
| 1482 | toastManager.add( | |
| 1483 | stackedThreadToast({ | |
| 1484 | type: "error", | |
| 1485 | title: "Failed to unarchive thread", | |
| 1486 | description: | |
| 1487 | error instanceof Error ? error.message : "An error occurred.", | |
| 1488 | }), | |
| 1489 | ); | |
| 1490 | }) | |
| 1491 | } | |
| 1492 | > | |
| 1493 | <ArchiveX className="size-3.5" /> | |
| 1494 | <span>Unarchive</span> | |
| 1495 | </Button> | |
| 1496 | } | |
| 1497 | /> | |
| 1452 | 1498 | ))} |
| 1453 | 1499 | </SettingsSection> |
| 1454 | 1500 | )) |
apps/web/src/components/settings/SourceControlSettings.tsx+1-1| ... | ... | @@ -226,7 +226,7 @@ function DiscoveryItemRow({ |
| 226 | 226 | </Badge> |
| 227 | 227 | ) : null} |
| 228 | 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 | 230 | {itemSummary({ item, auth, authAccount })} |
| 231 | 231 | </p> |
| 232 | 232 | </div> |
apps/web/src/components/settings/settingsLayout.tsx+5-1| ... | ... | @@ -52,7 +52,9 @@ export function SettingsRow({ |
| 52 | 52 | resetAction, |
| 53 | 53 | control, |
| 54 | 54 | children, |
| 55 | }: { | |
| 55 | className, | |
| 56 | ...rowProps | |
| 57 | }: Omit<ComponentPropsWithoutRef<"div">, "title"> & { | |
| 56 | 58 | title: ReactNode; |
| 57 | 59 | description: ReactNode; |
| 58 | 60 | status?: ReactNode; |
| ... | ... | @@ -62,9 +64,11 @@ export function SettingsRow({ |
| 62 | 64 | }) { |
| 63 | 65 | return ( |
| 64 | 66 | <div |
| 67 | {...rowProps} | |
| 65 | 68 | className={cn( |
| 66 | 69 | "border-t border-border/60 px-4 first:border-t-0 sm:px-5", |
| 67 | 70 | children ? "pt-3.5 pb-0" : "py-3.5", |
| 71 | className, | |
| 68 | 72 | )} |
| 69 | 73 | > |
| 70 | 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 | 47 | dispatchCommand: rpcClient.orchestration.dispatchCommand, |
| 48 | 48 | getTurnDiff: rpcClient.orchestration.getTurnDiff, |
| 49 | 49 | getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, |
| 50 | getArchivedShellSnapshot: rpcClient.orchestration.getArchivedShellSnapshot, | |
| 50 | 51 | subscribeShell: (callback, options) => |
| 51 | 52 | rpcClient.orchestration.subscribeShell(callback, options), |
| 52 | 53 | subscribeThread: (input, callback, options) => |
apps/web/src/hooks/useThreadActions.ts+24-10| ... | ... | @@ -9,6 +9,7 @@ import { useComposerDraftStore } from "../composerDraftStore"; |
| 9 | 9 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 10 | 10 | import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; |
| 11 | 11 | import { invalidateGitQueries } from "../lib/gitReactQuery"; |
| 12 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; | |
| 12 | 13 | import { newCommandId } from "../lib/utils"; |
| 13 | 14 | import { readLocalApi } from "../localApi"; |
| 14 | 15 | import { |
| ... | ... | @@ -68,19 +69,22 @@ export function useThreadActions() { |
| 68 | 69 | throw new Error("Cannot archive a running thread."); |
| 69 | 70 | } |
| 70 | 71 | |
| 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 | 77 | type: "thread.archive", |
| 73 | 78 | commandId: newCommandId(), |
| 74 | 79 | threadId: threadRef.threadId, |
| 75 | 80 | }); |
| 76 | const currentRouteThreadRef = getCurrentRouteThreadRef(); | |
| 77 | 81 | |
| 78 | if ( | |
| 79 | currentRouteThreadRef?.threadId === threadRef.threadId && | |
| 80 | currentRouteThreadRef.environmentId === threadRef.environmentId | |
| 81 | ) { | |
| 82 | if (shouldNavigateToDraft) { | |
| 82 | 83 | await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)); |
| 83 | 84 | } |
| 85 | ||
| 86 | await archiveCommand; | |
| 87 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); | |
| 84 | 88 | }, |
| 85 | 89 | [getCurrentRouteThreadRef, resolveThreadTarget], |
| 86 | 90 | ); |
| ... | ... | @@ -93,6 +97,7 @@ export function useThreadActions() { |
| 93 | 97 | commandId: newCommandId(), |
| 94 | 98 | threadId: target.threadId, |
| 95 | 99 | }); |
| 100 | refreshArchivedThreadsForEnvironment(target.environmentId); | |
| 96 | 101 | }, []); |
| 97 | 102 | |
| 98 | 103 | const deleteThread = useCallback( |
| ... | ... | @@ -100,7 +105,16 @@ export function useThreadActions() { |
| 100 | 105 | const api = readEnvironmentApi(target.environmentId); |
| 101 | 106 | if (!api) return; |
| 102 | 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 | 118 | const { thread, threadRef } = resolved; |
| 105 | 119 | const state = useStore.getState(); |
| 106 | 120 | const threads = selectThreadsForEnvironment(state, threadRef.environmentId); |
| ... | ... | @@ -175,6 +189,7 @@ export function useThreadActions() { |
| 175 | 189 | commandId: newCommandId(), |
| 176 | 190 | threadId: threadRef.threadId, |
| 177 | 191 | }); |
| 192 | refreshArchivedThreadsForEnvironment(threadRef.environmentId); | |
| 178 | 193 | clearComposerDraftForThread(threadRef); |
| 179 | 194 | clearProjectDraftThreadById( |
| 180 | 195 | scopeProjectRef(threadRef.environmentId, thread.projectId), |
| ... | ... | @@ -252,13 +267,12 @@ export function useThreadActions() { |
| 252 | 267 | if (!api) return; |
| 253 | 268 | const localApi = readLocalApi(); |
| 254 | 269 | const resolved = resolveThreadTarget(target); |
| 255 | if (!resolved) return; | |
| 256 | const { thread } = resolved; | |
| 257 | 270 | |
| 258 | 271 | if (confirmThreadDelete && localApi) { |
| 272 | const title = resolved?.thread.title ?? "this thread"; | |
| 259 | 273 | const confirmed = await localApi.dialogs.confirm( |
| 260 | 274 | [ |
| 261 | `Delete thread "${thread.title}"?`, | |
| 275 | `Delete thread "${title}"?`, | |
| 262 | 276 | "This permanently clears conversation history for this thread.", |
| 263 | 277 | ].join("\n"), |
| 264 | 278 | ); |
apps/web/src/lib/archivedThreadsState.ts created+105| ... | ... | @@ -0,0 +1,105 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | |
| 3 | import { Cause, Effect, Option } from "effect"; | |
| 4 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 5 | import { useCallback, useMemo } from "react"; | |
| 6 | ||
| 7 | import { readEnvironmentApi } from "../environmentApi"; | |
| 8 | import { appAtomRegistry } from "../rpc/atomRegistry"; | |
| 9 | ||
| 10 | const ARCHIVED_THREADS_STALE_TIME_MS = 5_000; | |
| 11 | const ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000; | |
| 12 | const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; | |
| 13 | ||
| 14 | export type ArchivedSnapshotEntry = { | |
| 15 | readonly environmentId: EnvironmentId; | |
| 16 | readonly snapshot: OrchestrationShellSnapshot; | |
| 17 | }; | |
| 18 | ||
| 19 | const knownArchivedThreadEnvironmentKeys = new Set<string>(); | |
| 20 | ||
| 21 | function makeArchivedThreadsEnvironmentKey(environmentIds: ReadonlyArray<EnvironmentId>): string { | |
| 22 | return environmentIds.toSorted().join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR); | |
| 23 | } | |
| 24 | ||
| 25 | function 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 | ||
| 34 | const 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 | ||
| 63 | function 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 | ||
| 74 | export 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 | ||
| 82 | export 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 | 143 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; |
| 144 | 144 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; |
| 145 | 145 | readonly getFullThreadDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getFullThreadDiff>; |
| 146 | readonly getArchivedShellSnapshot: RpcUnaryNoArgMethod< | |
| 147 | typeof ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot | |
| 148 | >; | |
| 146 | 149 | readonly subscribeShell: RpcStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeShell>; |
| 147 | 150 | readonly subscribeThread: RpcInputStreamMethod<typeof ORCHESTRATION_WS_METHODS.subscribeThread>; |
| 148 | 151 | }; |
| ... | ... | @@ -288,6 +291,10 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 288 | 291 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), |
| 289 | 292 | getFullThreadDiff: (input) => |
| 290 | 293 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), |
| 294 | getArchivedShellSnapshot: () => | |
| 295 | transport.request((client) => | |
| 296 | client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]({}), | |
| 297 | ), | |
| 291 | 298 | subscribeShell: (listener, options) => |
| 292 | 299 | transport.subscribe( |
| 293 | 300 | (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), |
packages/contracts/src/ipc.ts+2| ... | ... | @@ -55,6 +55,7 @@ import type { |
| 55 | 55 | OrchestrationGetFullThreadDiffResult, |
| 56 | 56 | OrchestrationGetTurnDiffInput, |
| 57 | 57 | OrchestrationGetTurnDiffResult, |
| 58 | OrchestrationShellSnapshot, | |
| 58 | 59 | OrchestrationShellStreamItem, |
| 59 | 60 | OrchestrationSubscribeThreadInput, |
| 60 | 61 | OrchestrationThreadStreamItem, |
| ... | ... | @@ -544,6 +545,7 @@ export interface EnvironmentApi { |
| 544 | 545 | getFullThreadDiff: ( |
| 545 | 546 | input: OrchestrationGetFullThreadDiffInput, |
| 546 | 547 | ) => Promise<OrchestrationGetFullThreadDiffResult>; |
| 548 | getArchivedShellSnapshot: () => Promise<OrchestrationShellSnapshot>; | |
| 547 | 549 | subscribeShell: ( |
| 548 | 550 | callback: (event: OrchestrationShellStreamItem) => void, |
| 549 | 551 | options?: { |
packages/contracts/src/orchestration.ts+5| ... | ... | @@ -27,6 +27,7 @@ export const ORCHESTRATION_WS_METHODS = { |
| 27 | 27 | getTurnDiff: "orchestration.getTurnDiff", |
| 28 | 28 | getFullThreadDiff: "orchestration.getFullThreadDiff", |
| 29 | 29 | replayEvents: "orchestration.replayEvents", |
| 30 | getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", | |
| 30 | 31 | subscribeShell: "orchestration.subscribeShell", |
| 31 | 32 | subscribeThread: "orchestration.subscribeThread", |
| 32 | 33 | } as const; |
| ... | ... | @@ -1222,6 +1223,10 @@ export const OrchestrationRpcSchemas = { |
| 1222 | 1223 | input: OrchestrationReplayEventsInput, |
| 1223 | 1224 | output: OrchestrationReplayEventsResult, |
| 1224 | 1225 | }, |
| 1226 | getArchivedShellSnapshot: { | |
| 1227 | input: Schema.Struct({}), | |
| 1228 | output: OrchestrationShellSnapshot, | |
| 1229 | }, | |
| 1225 | 1230 | subscribeThread: { |
| 1226 | 1231 | input: OrchestrationSubscribeThreadInput, |
| 1227 | 1232 | output: OrchestrationThreadStreamItem, |
packages/contracts/src/rpc.ts+10| ... | ... | @@ -410,6 +410,15 @@ export const WsOrchestrationReplayEventsRpc = Rpc.make(ORCHESTRATION_WS_METHODS. |
| 410 | 410 | error: OrchestrationReplayEventsError, |
| 411 | 411 | }); |
| 412 | 412 | |
| 413 | export 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 | ||
| 413 | 422 | export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { |
| 414 | 423 | payload: OrchestrationRpcSchemas.subscribeShell.input, |
| 415 | 424 | success: OrchestrationRpcSchemas.subscribeShell.output, |
| ... | ... | @@ -497,6 +506,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 497 | 506 | WsOrchestrationGetTurnDiffRpc, |
| 498 | 507 | WsOrchestrationGetFullThreadDiffRpc, |
| 499 | 508 | WsOrchestrationReplayEventsRpc, |
| 509 | WsOrchestrationGetArchivedShellSnapshotRpc, | |
| 500 | 510 | WsOrchestrationSubscribeShellRpc, |
| 501 | 511 | WsOrchestrationSubscribeThreadRpc, |
| 502 | 512 | ); |