| 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,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 | ); |
| 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 | 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 | }); |
| 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 | 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 | }); |
| 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 | 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_id | 585 | ON turns.thread_id = threads.thread_id |
| 476 | AND turns.turn_id = threads.latest_turn_id | 586 | AND turns.turn_id = threads.latest_turn_id |
| 477 | WHERE threads.deleted_at IS NULL | 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 | AND threads.latest_turn_id IS NOT NULL | 615 | AND threads.latest_turn_id IS NOT NULL |
| 479 | ORDER BY turns.thread_id ASC | 616 | 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_threads | 694 | FROM projection_threads |
| 558 | WHERE project_id = ${projectId} | 695 | WHERE project_id = ${projectId} |
| 559 | AND deleted_at IS NULL | 696 | AND deleted_at IS NULL |
| 697 | AND archived_at IS NULL | ||
| 560 | ORDER BY created_at ASC, thread_id ASC | 698 | ORDER BY created_at ASC, thread_id ASC |
| 561 | LIMIT 1 | 699 | 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_threads | 745 | FROM projection_threads |
| 608 | WHERE thread_id = ${threadId} | 746 | WHERE thread_id = ${threadId} |
| 609 | AND deleted_at IS NULL | 747 | AND deleted_at IS NULL |
| 748 | AND archived_at IS NULL | ||
| 610 | LIMIT 1 | 749 | 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_id | 856 | 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 NULL | 858 | AND threads.deleted_at IS NULL |
| 859 | AND threads.archived_at IS NULL | ||
| 720 | LIMIT 1 | 860 | 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 | ); |
| 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 | 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 | ProjectionRepositoryError | 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 | * Read the latest projection snapshot sequence without hydrating read-model | 87 | * 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 |
| 42 | import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"; | 42 | import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts"; |
| 43 | import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; | 43 | import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; |
| 44 | import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; | 44 | import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; |
| 45 | import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; | ||
| 45 | 46 | ||
| 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; |
| 87 | 89 | ||
| 88 | export const makeMigrationLoader = (throughId?: number) => | 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,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 | <DialogTrigger | 2665 | <Tooltip> |
| 2666 | render={ | 2666 | <TooltipTrigger |
| 2667 | <Button size="xs" variant="outline"> | 2667 | render={ |
| 2668 | <PlusIcon className="size-3" /> | 2668 | <DialogTrigger |
| 2669 | Add environment | 2669 | 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 | ); |
| 649 | 649 | ||
| 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"; |
| 45 | import { ensureLocalApi, readLocalApi } from "../../localApi"; | 45 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 46 | import { useShallow } from "zustand/react/shallow"; | 46 | import { useShallow } from "zustand/react/shallow"; |
| 47 | import { | 47 | import { selectProjectsAcrossEnvironments, useStore } from "../../store"; |
| 48 | selectProjectsAcrossEnvironments, | 48 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 49 | selectThreadShellsAcrossEnvironments, | ||
| 50 | useStore, | ||
| 51 | } from "../../store"; | ||
| 52 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; | 49 | import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; |
| 53 | import { Button } from "../ui/button"; | 50 | import { Button } from "../ui/button"; |
| 54 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; | ||
| 55 | import { DraftInput } from "../ui/draft-input"; | 51 | import { DraftInput } from "../ui/draft-input"; |
| 56 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 52 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 57 | import { Switch } from "../ui/switch"; | 53 | import { Switch } from "../ui/switch"; |
| ... | @@ -1330,14 +1326,50 @@ export function ProviderSettingsPanel() { | ... | @@ -1330,14 +1326,50 @@ export function ProviderSettingsPanel() { |
| 1330 | 1326 | ||
| 1331 | export function ArchivedThreadsPanel() { | 1327 | export 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 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 | .map((project) => ({ | 1366 | .map((project) => ({ |
| 1338 | project, | 1367 | project, |
| 1339 | threads: threads | 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 | .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]); |
| 1349 | 1381 | ||
| 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() { |
| 1376 | 1409 | ||
| 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 | ); |
| 1383 | 1417 | ||
| 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 | <div | 1452 | <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 | <Button | 1472 | 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 | <div | 66 | <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"; |
| 9 | import { useNewThreadHandler } from "./useHandleNewThread"; | 9 | import { useNewThreadHandler } from "./useHandleNewThread"; |
| 10 | import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; | 10 | import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; |
| 11 | import { invalidateGitQueries } from "../lib/gitReactQuery"; | 11 | import { invalidateGitQueries } from "../lib/gitReactQuery"; |
| 12 | import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; | ||
| 12 | import { newCommandId } from "../lib/utils"; | 13 | import { newCommandId } from "../lib/utils"; |
| 13 | import { readLocalApi } from "../localApi"; | 14 | import { readLocalApi } from "../localApi"; |
| 14 | import { | 15 | import { |
| ... | @@ -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 | } |
| 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 | 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(); | ||
| 77 | 81 | ||
| 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 | }, []); |
| 97 | 102 | ||
| 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; | ||
| 257 | 270 | ||
| 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 @@ | ||
| 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,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 | }); |
| 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 | export const WsOrchestrationSubscribeShellRpc = Rpc.make(ORCHESTRATION_WS_METHODS.subscribeShell, { | 422 | export 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 | ); |