@tea-agent/loop-agent 0.42.0-next.1 → 0.42.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +319 -1
- package/dist/application/dag/generate-task-dag.js +75 -19
- package/dist/application/dag/run-dag.js +41 -0
- package/dist/application/task-lifecycle/advance.js +24 -5
- package/dist/application/task-lifecycle/observe.js +171 -17
- package/dist/application/task-lifecycle/plan-transitions.js +42 -7
- package/dist/application/task-lifecycle/recommendations.js +13 -2
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +7 -0
- package/dist/cli/program.js +6 -1
- package/dist/commands/client-recovery.js +3 -0
- package/dist/commands/dag-follow-up.js +138 -0
- package/dist/commands/dag-rerun-task.js +2 -0
- package/dist/commands/init.js +27 -1
- package/dist/commands/task-advance.js +19 -0
- package/dist/executors/dag-pi-executor.js +3976 -76
- package/dist/executors/pi-executor.js +15 -4
- package/dist/executors/pi-read-budget-policy.js +239 -0
- package/dist/executors/shell-executor.js +978 -188
- package/dist/executors/shell-write-guard.js +7 -0
- package/dist/infrastructure/console/operation-store.js +226 -6
- package/dist/shared/dag-failure-category.js +12 -0
- package/dist/shared/openspec-spec.js +70 -4
- package/dist/shared/operator/capabilities.js +7 -0
- package/dist/task/config-types.js +105 -5
- package/dist/task/contract/adopt.js +4 -0
- package/dist/task/contract/import-revision.js +4 -0
- package/dist/task/contract/project.js +3 -0
- package/dist/task/contract/schema.js +2 -1
- package/dist/task/frontend-project-capability.js +203 -20
- package/dist/task/runtime.js +5 -2
- package/dist/task/source-prepare/build-draft.js +3 -3
- package/dist/task/source-prepare/fragment-inventory.js +64 -25
- package/dist/task/source-prepare/prepare.js +126 -21
- package/dist/task/source-prepare/semantic-intake.js +6 -2
- package/dist/task/source-references.js +22 -1
- package/dist/worker/console/chat/chat-ui-policy.js +4 -2
- package/dist/worker/console/chat/model-resolver.js +7 -4
- package/dist/worker/console/chat/pi-runtime.js +258 -11
- package/dist/worker/console/chat/repo-browser.js +10 -3
- package/dist/worker/console/chat/routes.js +165 -71
- package/dist/worker/console/chat/sdd-data-alignment.js +222 -0
- package/dist/worker/console/chat/session-catalog.js +2 -0
- package/dist/worker/console/chat/session-store.js +159 -70
- package/dist/worker/console/chat/shortcuts.js +19 -3
- package/dist/worker/console/chat/turn-process.js +111 -56
- package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
- package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
- package/dist/worker/console/index.js +3 -0
- package/dist/worker/console/operator-actions.js +169 -7
- package/dist/worker/console/pi-readiness.js +3 -2
- package/dist/worker/console/server.js +23 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DZZ8m3PO.js → abnfDiagram-N423BO3Z-CcS17TBr.js} +1 -1
- package/dist/worker/console/static/assets/{arc-D6PvaVd-.js → arc-COptKq2S.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-B_OTOiI8.js → architectureDiagram-T3A2C74G-h4LKMHjP.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-Bv6rqHBg.js → blockDiagram-VBNYF7ZC-COA1MOH0.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B8eHr0oz.js → c4Diagram-5PPSVZJV-DJUf0QPm.js} +1 -1
- package/dist/worker/console/static/assets/channel-DdBCaOJ6.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DYwR0im2.js → chunk-2GRJ4B5K-mtWfKrUX.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D2WPGqXt.js → chunk-2Q5K7J3B-B8pXsxDQ.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CQISJ_I7.js → chunk-5RXB4S5H-ipKzByl1.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-C0o2Du1e.js → chunk-5VM5RSS4-DYi3Ald_.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-4f8kr-U7.js → chunk-6Q2QTUOP-DQtGYoty.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D_OWvXzX.js → chunk-GF5L2VYU-BU0qS2YV.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CasdPz5X.js → chunk-JWPE2WC7-DVH9dIGE.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Cj8lRrla.js → chunk-KBJHAD2P-Dt60SmgM.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CUvD1FSd.js → chunk-RYQCIY6F-BoaUGuEY.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CqLw_eqB.js → chunk-XXDRQBXY-_WHFDTp2.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DZFra1GO.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DZFra1GO.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-ku-WqJPx.js → cose-bilkent-JH36ORCC-BxkbTIRd.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-Bq_PYDhl.js → cynefin-VYW2F7L2-Bf2UVnoG.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BiGZV641.js → cynefinDiagram-MW4NZA55-Bo23q1J_.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C4JRlglF.js → dagre-VZM6K2ZE-Dd_UU49i.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B0dNeWYG.js → diagram-7IWD3JNH-ebUa1a9y.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cj35YvYM.js → diagram-B4RE2ZJO-BucthU8r.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-uzQoJ2-8.js → diagram-LBJQPF4R-BgqNpAkm.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D1a-Buoz.js → diagram-Q27KOJAE-DC4q5NGa.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-XjRrLRSs.js → diagram-UB23O5K3-CBqCaMeb.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Da_O24cW.js → ebnfDiagram-BXEA7PRR-CGtnbQ3-.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BLJ8jrYU.js → erDiagram-JOGREHBK-CG3LUao5.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-B9GrLjM0.js → flowDiagram-UKHOOZJN-D2ZVgoFS.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CeJ0TqiK.js → ganttDiagram-PKOTCBZU-DoYFZiKt.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js → gitGraphDiagram-DS77QQ5N-CKoP1s6j.js} +1 -1
- package/dist/worker/console/static/assets/index-CAZ2fC_X.css +1 -0
- package/dist/worker/console/static/assets/index-vbTcFnFs.js +449 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DB26i3d8.js → infoDiagram-6WML65LV-Duofv8p2.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BOGRyeZT.js → ishikawaDiagram-WSZJBQD7-D2nlCkA1.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CZRoBO_6.js → journeyDiagram-NVQOT4AX-Dd4IHdus.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-qlWhJyoB.js → kanban-definition-27J2QSJJ-Bi20AOdt.js} +1 -1
- package/dist/worker/console/static/assets/{linear-CfUiDDB3.js → linear-D59YJ9kB.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-BQe6fpqj.js → mermaid.core-Bl13LpNa.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-zFzWHw64.js → mindmap-definition-FAOFIHXS-C6DLP5eY.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DFGUqjZO.js → pegDiagram-VL7TDLO6-BjO4cy64.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BB5i0l8Z.js → pieDiagram-7S7Q4E2Y-D7i2qZTG.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-obbVZ_X5.js → quadrantDiagram-CIZ2JOQS-Dx8o_h0y.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwdaNzc1.js → railroadDiagram-AXF67PYL-B55orI_t.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CkTJaugh.js → requirementDiagram-LRYGKXZP-zWaehp6f.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BwJ-hIgq.js → sankeyDiagram-W5VNT64P-CcA-pjvD.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BzSgmm7w.js → sequenceDiagram-SI44F4Z6-BOFbzNHI.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-Bn3oj5Q1.js → sizeCapture-X5ZJPWSS-BjAejah1.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-dxhGtL8K.js → stateDiagram-OKZ733FA-DXYwxJwZ.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Clg3V9t1.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BLpEInya.js → swimlanes-SLNWSIFB-OYiOch8n.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DX1dxAAW.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ChJGYcXy.js → timeline-definition-Z64GVDOM-cZfH3nmU.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DK-qjRer.js → vennDiagram-T6HMQDX7-BDE7b3E1.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-3qeaWAg-.js → wardleyDiagram-T6FBY63Y-kyyGy9WJ.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CootlyP9.js → xychartDiagram-ELKLHX3M-CJj6VTog.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/active-run-badge.js +17 -0
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -5
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +19 -8
- package/dist/worker/console/static-src/operator-chat/input-history.js +8 -6
- package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +15 -7
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +10 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +258 -23
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +112 -4
- package/dist/worker/console/static-src/operator-chat/useComposer.js +13 -4
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +29 -6
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +8 -2
- package/dist/worker/console/static-src/shell/console-update-reload.js +36 -9
- package/dist/worker/console/static-src/shell/workspace-route.js +11 -0
- package/dist/worker/console/workspace-context.js +115 -1
- package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
- package/dist/worker/observe/routes.js +4 -0
- package/dist/worker/observe/static/dag-helpers.js +4 -2
- package/dist/worker/observe/static/dag-node-purpose.js +5 -0
- package/dist/worker/observe/static/inspect-workspace.js +23 -0
- package/dist/worker/observe/static/kpi.js +2 -2
- package/dist/worker/observe/static/operator-chrome.d.ts +10 -2
- package/dist/worker/observe/static/operator-chrome.js +37 -23
- package/dist/worker/observe/static/relations.js +2 -2
- package/dist/worker/observe/static/router.d.ts +12 -1
- package/dist/worker/observe/static/router.js +48 -6
- package/dist/worker/observe/static/run-processing.js +4 -2
- package/dist/worker/observe/static/shell-chrome.js +2 -2
- package/dist/worker/observe/static/state.js +5 -0
- package/dist/worker/observe/static/styles.css +95 -0
- package/dist/worker/observe/static/views/dag-inspector.js +14 -7
- package/dist/worker/observe/static/views/dag.js +10 -2
- package/dist/worker/observe/static/views/dags.js +3 -1
- package/dist/worker/observe/static/views/dashboard.js +14 -6
- package/dist/worker/observe/static/views/pool.js +7 -2
- package/dist/worker/observe/static/views/run.js +12 -3
- package/dist/worker/observe/static/views/session-timeline.js +69 -10
- package/dist/worker/observe/static/views/task.js +7 -2
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +215 -28
- package/dist/workflows/dag/backend-test-markdown-workflow.js +13 -1
- package/dist/workflows/dag/backend-test-plan-protocol.js +106 -0
- package/dist/workflows/dag/backend-test-scenario-param.js +172 -25
- package/dist/workflows/dag/backend-test-scenario-partitions.js +62 -1
- package/dist/workflows/dag/backend-test-writer-completeness.js +104 -12
- package/dist/workflows/dag/contract-validator-registrations.js +1 -2
- package/dist/workflows/dag/dag-retry-schema.js +138 -0
- package/dist/workflows/dag/frontend-closeout.js +221 -0
- package/dist/workflows/dag/frontend-design-policy.js +400 -0
- package/dist/workflows/dag/frontend-human-decision.js +182 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +1237 -192
- package/dist/workflows/dag/frontend-plan-render.js +2 -1
- package/dist/workflows/dag/frontend-prewrite-gate.js +256 -350
- package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
- package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
- package/dist/workflows/dag/frontend-recovery-controller.js +226 -0
- package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
- package/dist/workflows/dag/frontend-recovery-plan.js +21 -10
- package/dist/workflows/dag/frontend-recovery-run.js +166 -34
- package/dist/workflows/dag/frontend-repair.js +1 -432
- package/dist/workflows/dag/frontend-review-context.js +261 -15
- package/dist/workflows/dag/frontend-review-findings.js +270 -0
- package/dist/workflows/dag/frontend-shadow-dual-write.js +941 -0
- package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
- package/dist/workflows/dag/frontend-shape-facts.js +419 -0
- package/dist/workflows/dag/frontend-shape.js +435 -0
- package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
- package/dist/workflows/dag/frontend-split-application-service.js +203 -0
- package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
- package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
- package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
- package/dist/workflows/dag/frontend-verification-trace.js +252 -25
- package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
- package/dist/workflows/dag/frontend-writer-admission.js +319 -0
- package/dist/workflows/dag/frontend-writer-status.js +256 -0
- package/dist/workflows/dag/init-hybrid.js +1028 -562
- package/dist/workflows/dag/interrupt-request.js +7 -0
- package/dist/workflows/dag/node-execution.js +828 -4
- package/dist/workflows/dag/prompt.js +70 -3
- package/dist/workflows/dag/recovery-lease.js +170 -0
- package/dist/workflows/dag/report.js +37 -1
- package/dist/workflows/dag/rerun-feedback.js +315 -1
- package/dist/workflows/dag/rerun-plan.js +17 -6
- package/dist/workflows/dag/rerun-run.js +44 -6
- package/dist/workflows/dag/rerun-task.js +251 -13
- package/dist/workflows/dag/retry-policy.js +219 -104
- package/dist/workflows/dag/runner.js +580 -124
- package/dist/workflows/dag/scheduler.js +133 -20
- package/dist/workflows/dag/types.js +244 -16
- package/dist/workflows/dag/validate.js +28 -12
- package/docs/examples/README.md +5 -0
- package/docs/init-surface.manifest.json +30 -12
- package/docs/skills/vetted-skill-registry.md +4 -2
- package/docs/templates/README.md +2 -0
- package/docs/templates/agent-dag-report.schema.json +8 -2
- package/docs/templates/agent-dag.schema.json +1 -1
- package/docs/templates/backend-test-dag.json +23 -20
- package/docs/templates/frontend-implementation-contract.schema.json +4 -1
- package/docs/templates/frontend-implementation-dag.json +89 -0
- package/docs/templates/spec-registry.schema.json +45 -0
- package/harness.json +1 -1
- package/package.json +4 -3
- package/skills/frontend-bounded-implement/SKILL.md +15 -14
- package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
- package/skills/frontend-contract/SKILL.md +23 -0
- package/skills/frontend-contract/references/contract-protocol.md +34 -0
- package/skills/frontend-design-review/SKILL.md +22 -41
- package/skills/frontend-plan/SKILL.md +26 -0
- package/skills/frontend-plan/references/decision-contract.md +37 -0
- package/skills/frontend-plan/references/design-decisions.md +17 -0
- package/skills/frontend-review/SKILL.md +20 -15
- package/skills/frontend-review/references/review-findings.md +6 -7
- package/skills/frontend-scout/SKILL.md +25 -0
- package/skills/frontend-scout/references/design-evidence.md +16 -0
- package/skills/frontend-scout/references/scout-evidence.md +23 -0
- package/skills/frontend-verification/SKILL.md +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -0
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- package/dist/worker/console/static/assets/channel-BU5gOilw.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +0 -1
- package/dist/worker/console/static/assets/index-H9rFJiGL.css +0 -1
- package/dist/worker/console/static/assets/index-xwu9GxEc.js +0 -451
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +0 -8
- package/skills/frontend-implementation/SKILL.md +0 -52
- package/skills/frontend-implementation/references/code-standards.md +0 -33
- package/skills/frontend-implementation/references/design-spec.md +0 -56
- package/skills/frontend-implementation/references/node-contracts.md +0 -31
|
@@ -3,9 +3,98 @@ import { runtimeSelectionFromRecord } from "../../chat/runtime-selection.js";
|
|
|
3
3
|
import { hydrateSessionThread } from "../../chat/turn-process.js";
|
|
4
4
|
import { forgetActiveChatSession, readActiveChatSessionId, rememberActiveChatSession, } from "../../chat/workspace-landing.js";
|
|
5
5
|
import { confirmationToken } from "./format.js";
|
|
6
|
+
import { isLostChatSessionError } from "./runtime-snapshot-store.js";
|
|
6
7
|
import { upsertCompactionMessage } from "./compaction-message.js";
|
|
7
8
|
import { describeMutationGateFailure, isMutationGateFailureCode, isStaleTokenFailure, } from "./mutation-gate.js";
|
|
8
9
|
import { SessionTitleWatcher } from "./session-title-watcher.js";
|
|
10
|
+
/** Pure list mutations keep optimistic UI deterministic and easy to verify. */
|
|
11
|
+
export function insertOptimisticSession(list, row) {
|
|
12
|
+
return [row, ...list.filter((item) => item.sessionId !== row.sessionId)];
|
|
13
|
+
}
|
|
14
|
+
export function replaceOptimisticSession(list, temporaryId, durable) {
|
|
15
|
+
return [
|
|
16
|
+
durable,
|
|
17
|
+
...list.filter((item) => item.sessionId !== durable.sessionId &&
|
|
18
|
+
(!temporaryId || item.sessionId !== temporaryId)),
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
export function removeSessionById(list, sessionId) {
|
|
22
|
+
const index = list.findIndex((item) => item.sessionId === sessionId);
|
|
23
|
+
if (index < 0)
|
|
24
|
+
return { next: list, index: -1 };
|
|
25
|
+
return {
|
|
26
|
+
next: list.filter((item) => item.sessionId !== sessionId),
|
|
27
|
+
removed: list[index],
|
|
28
|
+
index,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function restoreSessionAt(list, row, index) {
|
|
32
|
+
if (list.some((item) => item.sessionId === row.sessionId))
|
|
33
|
+
return list;
|
|
34
|
+
const next = [...list];
|
|
35
|
+
next.splice(Math.max(0, Math.min(index, next.length)), 0, row);
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
function sameQueueSummary(a, b) {
|
|
39
|
+
const left = a.deferredTurns ?? (a.deferredTurn ? [a.deferredTurn] : []);
|
|
40
|
+
const right = b.deferredTurns ?? (b.deferredTurn ? [b.deferredTurn] : []);
|
|
41
|
+
if (left.length !== right.length)
|
|
42
|
+
return false;
|
|
43
|
+
return left.every((item, index) => {
|
|
44
|
+
const other = right[index];
|
|
45
|
+
return Boolean(other &&
|
|
46
|
+
item.clientRequestId === other.clientRequestId &&
|
|
47
|
+
item.revision === other.revision &&
|
|
48
|
+
item.state === other.state &&
|
|
49
|
+
item.updatedAt === other.updatedAt &&
|
|
50
|
+
item.textPreview === other.textPreview &&
|
|
51
|
+
item.imageCount === other.imageCount &&
|
|
52
|
+
item.acceptedTurnId === other.acceptedTurnId &&
|
|
53
|
+
item.lastError?.code === other.lastError?.code &&
|
|
54
|
+
item.lastError?.message === other.lastError?.message);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function sameSessionSummary(a, b) {
|
|
58
|
+
return (a.sessionId === b.sessionId &&
|
|
59
|
+
a.workspaceId === b.workspaceId &&
|
|
60
|
+
a.state === b.state &&
|
|
61
|
+
a.title === b.title &&
|
|
62
|
+
a.displayTitle === b.displayTitle &&
|
|
63
|
+
a.updatedAt === b.updatedAt &&
|
|
64
|
+
a.activeTurnId === b.activeTurnId &&
|
|
65
|
+
a.turnPhase === b.turnPhase &&
|
|
66
|
+
a.model?.provider === b.model?.provider &&
|
|
67
|
+
a.model?.modelId === b.model?.modelId &&
|
|
68
|
+
a.thinkingLevel === b.thinkingLevel &&
|
|
69
|
+
a.isBlank === b.isBlank &&
|
|
70
|
+
a.optimisticState === b.optimisticState &&
|
|
71
|
+
sameQueueSummary(a, b));
|
|
72
|
+
}
|
|
73
|
+
/** Reuse unchanged row and list references across catalog refreshes. */
|
|
74
|
+
export function stabilizeSessionList(previous, next) {
|
|
75
|
+
const previousById = new Map(previous.map((item) => [item.sessionId, item]));
|
|
76
|
+
let changed = previous.length !== next.length;
|
|
77
|
+
const stabilized = next.map((item, index) => {
|
|
78
|
+
const existing = previousById.get(item.sessionId);
|
|
79
|
+
const value = existing && sameSessionSummary(existing, item) ? existing : item;
|
|
80
|
+
if (value !== previous[index])
|
|
81
|
+
changed = true;
|
|
82
|
+
return value;
|
|
83
|
+
});
|
|
84
|
+
return changed ? stabilized : previous;
|
|
85
|
+
}
|
|
86
|
+
/** Select a server-confirmed blank session owned by the requested workspace. */
|
|
87
|
+
export function findReusableBlankSession(list, workspaceId) {
|
|
88
|
+
const scope = workspaceId.trim();
|
|
89
|
+
if (!scope)
|
|
90
|
+
return undefined;
|
|
91
|
+
return list.find((item) => item.workspaceId === scope &&
|
|
92
|
+
item.state === "active" &&
|
|
93
|
+
item.isBlank === true &&
|
|
94
|
+
item.turnPhase === "idle" &&
|
|
95
|
+
!item.activeTurnId &&
|
|
96
|
+
!item.optimisticState);
|
|
97
|
+
}
|
|
9
98
|
export async function createSessionAndPrimeTitleWatcher(input) {
|
|
10
99
|
if (input.creatingRef?.current) {
|
|
11
100
|
throw new Error("CREATE_SESSION_IN_FLIGHT");
|
|
@@ -23,7 +112,10 @@ export async function createSessionAndPrimeTitleWatcher(input) {
|
|
|
23
112
|
"x-loop-console-confirmation": confirmationToken(),
|
|
24
113
|
},
|
|
25
114
|
credentials: "include",
|
|
26
|
-
body: JSON.stringify(
|
|
115
|
+
body: JSON.stringify((() => {
|
|
116
|
+
const { optimisticSessionId: _optimisticSessionId, ...body } = input.options ?? {};
|
|
117
|
+
return body;
|
|
118
|
+
})()),
|
|
27
119
|
});
|
|
28
120
|
if (!res.ok) {
|
|
29
121
|
const body = (await res.json().catch(() => ({})));
|
|
@@ -35,7 +127,7 @@ export async function createSessionAndPrimeTitleWatcher(input) {
|
|
|
35
127
|
const body = (await res.json());
|
|
36
128
|
if (!body.session)
|
|
37
129
|
throw new Error("创建会话响应缺少 durable session");
|
|
38
|
-
const durable = body.session;
|
|
130
|
+
const durable = { ...body.session, isBlank: true };
|
|
39
131
|
const created = {
|
|
40
132
|
sessionId: durable.sessionId,
|
|
41
133
|
...(durable.workspaceId || input.options?.workspaceId
|
|
@@ -45,16 +137,12 @@ export async function createSessionAndPrimeTitleWatcher(input) {
|
|
|
45
137
|
: {}),
|
|
46
138
|
...runtimeSelectionFromRecord(durable),
|
|
47
139
|
};
|
|
48
|
-
const nextList =
|
|
49
|
-
durable,
|
|
50
|
-
...input.sessionListRef.current.filter((item) => item.sessionId !== durable.sessionId),
|
|
51
|
-
];
|
|
140
|
+
const nextList = replaceOptimisticSession(input.sessionListRef.current, input.options?.optimisticSessionId, durable);
|
|
52
141
|
// Commit ref → state → eligibility → deferred refresh. The ref assignment
|
|
53
142
|
// makes the durable record observable before React's state commit.
|
|
54
143
|
input.sessionListRef.current = nextList;
|
|
55
144
|
input.setSessionList(nextList);
|
|
56
145
|
input.titleWatcher?.markEligible(durable.sessionId);
|
|
57
|
-
void input.refreshSessionList();
|
|
58
146
|
return created;
|
|
59
147
|
}
|
|
60
148
|
finally {
|
|
@@ -70,7 +158,7 @@ function mergeCompactionMessages(messages, compactions) {
|
|
|
70
158
|
* compact, plus browser-refresh recovery from the durable session store. */
|
|
71
159
|
export function useChatSessions(params) {
|
|
72
160
|
const { origin, refs, setSession, thread, composer, browser, setError, setNotice, onCompactionChange, setProcessOpen, setAutoScroll, } = params;
|
|
73
|
-
const { applyActivatedSession, resetForNewSession
|
|
161
|
+
const { applyActivatedSession, resetForNewSession } = thread;
|
|
74
162
|
const { setInput, setPendingImages } = composer;
|
|
75
163
|
const { setPreviews, setActivePreviewPath } = browser;
|
|
76
164
|
const { workspaceId, blocked } = params;
|
|
@@ -90,6 +178,11 @@ export function useChatSessions(params) {
|
|
|
90
178
|
}, [refs]);
|
|
91
179
|
const [sessionList, setSessionList] = useState([]);
|
|
92
180
|
const sessionListRef = useRef([]);
|
|
181
|
+
const pendingDeletedIdsRef = useRef(new Set());
|
|
182
|
+
const pendingCreatedRowsRef = useRef(new Map());
|
|
183
|
+
const sessionMutationVersionRef = useRef(0);
|
|
184
|
+
const deleteRequestVersionsRef = useRef(new Map());
|
|
185
|
+
const activateSessionRef = useRef(null);
|
|
93
186
|
sessionListRef.current = sessionList;
|
|
94
187
|
const titleWatcherRef = useRef(null);
|
|
95
188
|
// Shared busy gate for sidebar + Landing create entries (one render feedback).
|
|
@@ -159,9 +252,13 @@ export function useChatSessions(params) {
|
|
|
159
252
|
return;
|
|
160
253
|
}
|
|
161
254
|
const sessions = body.sessions ?? [];
|
|
255
|
+
const durable = sessions.filter((item) => !pendingDeletedIdsRef.current.has(item.sessionId));
|
|
256
|
+
const optimistic = [...pendingCreatedRowsRef.current.values()].filter((item) => !durable.some((session) => session.sessionId === item.sessionId));
|
|
162
257
|
// Preserve every workspace's summaries. ChatWorkspaceSidebar applies the
|
|
163
258
|
// visible recent-item limit independently inside each workspace group.
|
|
164
|
-
|
|
259
|
+
const next = stabilizeSessionList(sessionListRef.current, [...optimistic, ...durable]);
|
|
260
|
+
sessionListRef.current = next;
|
|
261
|
+
setSessionList(next);
|
|
165
262
|
if (body.workspaceErrors?.length) {
|
|
166
263
|
setError(`部分工作区的会话加载失败(${body.workspaceErrors.length} 个)`);
|
|
167
264
|
}
|
|
@@ -171,6 +268,60 @@ export function useChatSessions(params) {
|
|
|
171
268
|
}
|
|
172
269
|
}, [origin, blocked, setError]);
|
|
173
270
|
const deleteSession = useCallback(async (sessionId) => {
|
|
271
|
+
if (pendingDeletedIdsRef.current.has(sessionId))
|
|
272
|
+
return false;
|
|
273
|
+
const requestId = ++sessionMutationVersionRef.current;
|
|
274
|
+
const currentList = sessionListRef.current;
|
|
275
|
+
const { next: removedList, removed, index: removedIndex } = removeSessionById(currentList, sessionId);
|
|
276
|
+
if (!removed || removedIndex < 0)
|
|
277
|
+
return false;
|
|
278
|
+
pendingDeletedIdsRef.current.add(sessionId);
|
|
279
|
+
deleteRequestVersionsRef.current.set(sessionId, requestId);
|
|
280
|
+
const activeSession = refs.sessionRef.current?.sessionId === sessionId
|
|
281
|
+
? refs.sessionRef.current
|
|
282
|
+
: null;
|
|
283
|
+
const activeWorkspaceId = activeSession?.workspaceId ?? workspaceId;
|
|
284
|
+
const deletingRow = {
|
|
285
|
+
...removed,
|
|
286
|
+
optimisticState: "deleting",
|
|
287
|
+
};
|
|
288
|
+
const deletingList = restoreSessionAt(removedList, deletingRow, removedIndex);
|
|
289
|
+
sessionListRef.current = deletingList;
|
|
290
|
+
setSessionList(deletingList);
|
|
291
|
+
window.setTimeout(() => {
|
|
292
|
+
if (!pendingDeletedIdsRef.current.has(sessionId) ||
|
|
293
|
+
deleteRequestVersionsRef.current.get(sessionId) !== requestId)
|
|
294
|
+
return;
|
|
295
|
+
const next = sessionListRef.current.filter((item) => item.sessionId !== sessionId);
|
|
296
|
+
sessionListRef.current = next;
|
|
297
|
+
setSessionList(next);
|
|
298
|
+
}, 150);
|
|
299
|
+
const restore = () => {
|
|
300
|
+
if (deleteRequestVersionsRef.current.get(sessionId) !== requestId)
|
|
301
|
+
return;
|
|
302
|
+
pendingDeletedIdsRef.current.delete(sessionId);
|
|
303
|
+
deleteRequestVersionsRef.current.delete(sessionId);
|
|
304
|
+
const restored = restoreSessionAt(sessionListRef.current, removed, removedIndex);
|
|
305
|
+
if (restored === sessionListRef.current)
|
|
306
|
+
return;
|
|
307
|
+
sessionListRef.current = restored;
|
|
308
|
+
setSessionList(restored);
|
|
309
|
+
};
|
|
310
|
+
let deletionSelectionVersion = null;
|
|
311
|
+
if (activeSession) {
|
|
312
|
+
deletionSelectionVersion = ++sessionSelectionVersionRef.current;
|
|
313
|
+
refs.sessionRef.current = null;
|
|
314
|
+
resetActiveProjection();
|
|
315
|
+
setSession(null);
|
|
316
|
+
forgetActiveChatSession(window.localStorage, activeWorkspaceId, sessionId);
|
|
317
|
+
}
|
|
318
|
+
const restoreActiveSession = async () => {
|
|
319
|
+
if (activeSession &&
|
|
320
|
+
deletionSelectionVersion === sessionSelectionVersionRef.current &&
|
|
321
|
+
!refs.sessionRef.current) {
|
|
322
|
+
await activateSessionRef.current?.(activeSession.sessionId, activeSession.workspaceId);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
174
325
|
try {
|
|
175
326
|
// AC-002: delete follows the session's durable owner, not the
|
|
176
327
|
// global active workspace.
|
|
@@ -184,22 +335,21 @@ export function useChatSessions(params) {
|
|
|
184
335
|
},
|
|
185
336
|
});
|
|
186
337
|
if (!res.ok) {
|
|
338
|
+
restore();
|
|
339
|
+
await restoreActiveSession();
|
|
187
340
|
setError("删除会话失败");
|
|
188
341
|
return false;
|
|
189
342
|
}
|
|
190
343
|
titleWatcherRef.current?.cancel(sessionId);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
resetActiveProjection();
|
|
196
|
-
setSession(null);
|
|
197
|
-
forgetActiveChatSession(window.localStorage, activeWorkspaceId, sessionId);
|
|
198
|
-
}
|
|
199
|
-
await refreshSessionList();
|
|
344
|
+
void refreshSessionList().finally(() => {
|
|
345
|
+
pendingDeletedIdsRef.current.delete(sessionId);
|
|
346
|
+
deleteRequestVersionsRef.current.delete(sessionId);
|
|
347
|
+
});
|
|
200
348
|
return true;
|
|
201
349
|
}
|
|
202
350
|
catch (error) {
|
|
351
|
+
restore();
|
|
352
|
+
await restoreActiveSession();
|
|
203
353
|
setError(error instanceof Error ? error.message : String(error));
|
|
204
354
|
return false;
|
|
205
355
|
}
|
|
@@ -213,6 +363,23 @@ export function useChatSessions(params) {
|
|
|
213
363
|
sessionOwnerQuery,
|
|
214
364
|
workspaceId,
|
|
215
365
|
]);
|
|
366
|
+
const forgetLostSession = useCallback((sessionId, ownerWorkspaceId) => {
|
|
367
|
+
const active = refs.sessionRef.current;
|
|
368
|
+
const listed = sessionListRef.current.find((item) => item.sessionId === sessionId);
|
|
369
|
+
const scope = ownerWorkspaceId ?? listed?.workspaceId ?? active?.workspaceId ?? workspaceId;
|
|
370
|
+
forgetActiveChatSession(window.localStorage, scope, sessionId);
|
|
371
|
+
pendingDeletedIdsRef.current.add(sessionId);
|
|
372
|
+
const { next } = removeSessionById(sessionListRef.current, sessionId);
|
|
373
|
+
sessionListRef.current = next;
|
|
374
|
+
setSessionList(next);
|
|
375
|
+
if (active?.sessionId === sessionId) {
|
|
376
|
+
sessionSelectionVersionRef.current += 1;
|
|
377
|
+
refs.sessionRef.current = null;
|
|
378
|
+
resetActiveProjection();
|
|
379
|
+
setSession(null);
|
|
380
|
+
}
|
|
381
|
+
void refreshSessionList();
|
|
382
|
+
}, [refs, resetActiveProjection, setSession, workspaceId, refreshSessionList]);
|
|
216
383
|
const activateSession = useCallback(async (sessionId, ownerWorkspaceId) => {
|
|
217
384
|
const selectionVersion = ++sessionSelectionVersionRef.current;
|
|
218
385
|
// AC-002: activation targets the session's durable owning workspace —
|
|
@@ -222,10 +389,19 @@ export function useChatSessions(params) {
|
|
|
222
389
|
try {
|
|
223
390
|
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}${ownerQuery}`, { credentials: "include" });
|
|
224
391
|
const body = (await res.json());
|
|
225
|
-
if (!res.ok || !body.session)
|
|
392
|
+
if (!res.ok || !body.session) {
|
|
393
|
+
if (isLostChatSessionError({
|
|
394
|
+
status: res.status,
|
|
395
|
+
code: body.error?.code,
|
|
396
|
+
message: body.error?.message,
|
|
397
|
+
})) {
|
|
398
|
+
forgetLostSession(sessionId, ownerWorkspaceId);
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
226
401
|
throw new Error(body.error?.message ?? "会话不可用");
|
|
402
|
+
}
|
|
227
403
|
if (selectionVersion !== sessionSelectionVersionRef.current)
|
|
228
|
-
return;
|
|
404
|
+
return false;
|
|
229
405
|
const next = {
|
|
230
406
|
sessionId: body.session.sessionId,
|
|
231
407
|
...(body.session.workspaceId ? { workspaceId: body.session.workspaceId } : {}),
|
|
@@ -273,14 +449,17 @@ export function useChatSessions(params) {
|
|
|
273
449
|
pendingQuestion: body.session.pendingQuestion ?? null,
|
|
274
450
|
todos: body.session.todos ?? [],
|
|
275
451
|
goal: body.session.goal ?? null,
|
|
452
|
+
history: body.history,
|
|
276
453
|
});
|
|
277
454
|
rememberActiveChatSession(window.localStorage, body.session.workspaceId ?? ownerWorkspaceId ?? workspaceId, sessionId);
|
|
278
455
|
setActivePreviewPath(null);
|
|
456
|
+
return true;
|
|
279
457
|
}
|
|
280
458
|
catch (error) {
|
|
281
459
|
if (selectionVersion !== sessionSelectionVersionRef.current)
|
|
282
|
-
return;
|
|
460
|
+
return false;
|
|
283
461
|
setError(error instanceof Error ? error.message : String(error));
|
|
462
|
+
return false;
|
|
284
463
|
}
|
|
285
464
|
}, [
|
|
286
465
|
origin,
|
|
@@ -295,7 +474,9 @@ export function useChatSessions(params) {
|
|
|
295
474
|
setError,
|
|
296
475
|
sessionOwnerQuery,
|
|
297
476
|
workspaceId,
|
|
477
|
+
forgetLostSession,
|
|
298
478
|
]);
|
|
479
|
+
activateSessionRef.current = activateSession;
|
|
299
480
|
useEffect(() => {
|
|
300
481
|
void refreshSessionList();
|
|
301
482
|
}, [refreshSessionList]);
|
|
@@ -405,6 +586,7 @@ export function useChatSessions(params) {
|
|
|
405
586
|
pendingQuestion: body.session.pendingQuestion ?? null,
|
|
406
587
|
todos: body.session.todos ?? [],
|
|
407
588
|
goal: body.session.goal ?? null,
|
|
589
|
+
history: body.history,
|
|
408
590
|
});
|
|
409
591
|
}
|
|
410
592
|
catch {
|
|
@@ -437,15 +619,38 @@ export function useChatSessions(params) {
|
|
|
437
619
|
setError("创建会话需要选择一个有效的工作区(不会回退默认工作区)");
|
|
438
620
|
return Promise.reject(new Error("WORKSPACE_REQUIRED"));
|
|
439
621
|
}
|
|
622
|
+
const optimisticSessionId = `optimistic-chat-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
623
|
+
const now = new Date().toISOString();
|
|
624
|
+
const optimisticRow = {
|
|
625
|
+
sessionId: optimisticSessionId,
|
|
626
|
+
workspaceId: scope,
|
|
627
|
+
state: "active",
|
|
628
|
+
updatedAt: now,
|
|
629
|
+
turnPhase: "idle",
|
|
630
|
+
optimisticState: "creating",
|
|
631
|
+
};
|
|
632
|
+
pendingCreatedRowsRef.current.set(optimisticSessionId, optimisticRow);
|
|
633
|
+
const optimisticList = insertOptimisticSession(sessionListRef.current, optimisticRow);
|
|
634
|
+
sessionListRef.current = optimisticList;
|
|
635
|
+
setSessionList(optimisticList);
|
|
440
636
|
return createSessionAndPrimeTitleWatcher({
|
|
441
637
|
origin,
|
|
442
638
|
sessionListRef,
|
|
443
639
|
setSessionList,
|
|
444
640
|
titleWatcher: titleWatcherRef.current,
|
|
445
641
|
refreshSessionList,
|
|
446
|
-
options: { ...options, workspaceId: scope },
|
|
642
|
+
options: { ...options, workspaceId: scope, optimisticSessionId },
|
|
447
643
|
setCreatingSession,
|
|
448
644
|
creatingRef: creatingSessionRef,
|
|
645
|
+
}).then((created) => {
|
|
646
|
+
pendingCreatedRowsRef.current.delete(optimisticSessionId);
|
|
647
|
+
return created;
|
|
648
|
+
}).catch((error) => {
|
|
649
|
+
pendingCreatedRowsRef.current.delete(optimisticSessionId);
|
|
650
|
+
const cleaned = sessionListRef.current.filter((item) => item.sessionId !== optimisticSessionId);
|
|
651
|
+
sessionListRef.current = cleaned;
|
|
652
|
+
setSessionList(cleaned);
|
|
653
|
+
throw error;
|
|
449
654
|
});
|
|
450
655
|
}, [origin, refreshSessionList, workspaceId, setError]);
|
|
451
656
|
const createSession = useCallback(async (options) => {
|
|
@@ -456,11 +661,36 @@ export function useChatSessions(params) {
|
|
|
456
661
|
return null; // AC-R5-002: unknown workspace — no POST
|
|
457
662
|
const selectionVersion = ++sessionSelectionVersionRef.current;
|
|
458
663
|
setError(null);
|
|
664
|
+
const previousSession = refs.sessionRef.current;
|
|
665
|
+
const scope = options?.workspaceId?.trim() ?? workspaceId?.trim();
|
|
666
|
+
if (!scope) {
|
|
667
|
+
setError("创建会话需要选择一个有效的工作区(不会回退默认工作区)");
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
// A caller-provided snapshot is an explicit new-session boundary; reusing
|
|
671
|
+
// an existing shell would silently drop that context suffix.
|
|
672
|
+
const reusable = options?.snapshot
|
|
673
|
+
? undefined
|
|
674
|
+
: findReusableBlankSession(sessionListRef.current, scope);
|
|
675
|
+
if (reusable) {
|
|
676
|
+
if (previousSession?.sessionId === reusable.sessionId)
|
|
677
|
+
return previousSession;
|
|
678
|
+
const activated = await activateSession(reusable.sessionId, reusable.workspaceId);
|
|
679
|
+
if (!activated)
|
|
680
|
+
return null;
|
|
681
|
+
return {
|
|
682
|
+
sessionId: reusable.sessionId,
|
|
683
|
+
...(reusable.workspaceId ? { workspaceId: reusable.workspaceId } : {}),
|
|
684
|
+
...runtimeSelectionFromRecord(reusable),
|
|
685
|
+
};
|
|
686
|
+
}
|
|
459
687
|
try {
|
|
688
|
+
resetActiveProjection({ keepStreams: true });
|
|
689
|
+
refs.sessionRef.current = null;
|
|
690
|
+
setSession(null);
|
|
460
691
|
const created = await createSessionAndPrimeTitleWatcherForHook(options);
|
|
461
692
|
if (selectionVersion !== sessionSelectionVersionRef.current)
|
|
462
693
|
return null;
|
|
463
|
-
resetActiveProjection({ keepStreams: true });
|
|
464
694
|
// Set ref synchronously BEFORE setState so a chained
|
|
465
695
|
// createSession().then(sendPrompt) reads the new session even
|
|
466
696
|
// though sendPrompt's closure captured the stale (null) value.
|
|
@@ -472,6 +702,9 @@ export function useChatSessions(params) {
|
|
|
472
702
|
catch (e) {
|
|
473
703
|
if (selectionVersion !== sessionSelectionVersionRef.current)
|
|
474
704
|
return null;
|
|
705
|
+
if (previousSession) {
|
|
706
|
+
await activateSession(previousSession.sessionId, previousSession.workspaceId);
|
|
707
|
+
}
|
|
475
708
|
const message = e instanceof Error ? e.message : String(e);
|
|
476
709
|
if (message !== "CREATE_SESSION_IN_FLIGHT") {
|
|
477
710
|
setError(message);
|
|
@@ -484,6 +717,7 @@ export function useChatSessions(params) {
|
|
|
484
717
|
setError,
|
|
485
718
|
resetActiveProjection,
|
|
486
719
|
createSessionAndPrimeTitleWatcherForHook,
|
|
720
|
+
activateSession,
|
|
487
721
|
blocked,
|
|
488
722
|
workspaceId,
|
|
489
723
|
]);
|
|
@@ -595,5 +829,6 @@ export function useChatSessions(params) {
|
|
|
595
829
|
updateSession,
|
|
596
830
|
watchSessionTitle,
|
|
597
831
|
compactSession,
|
|
832
|
+
forgetLostSession,
|
|
598
833
|
};
|
|
599
834
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { buildTurnProcessGroups, hydrateSessionThread, selectRecentTurnGroups, } from "../../chat/turn-process.js";
|
|
3
3
|
import { mergeOperationCardState, } from "../../chat/operation-card.js";
|
|
4
4
|
import { createChatSseEventApplier, } from "./chat-sse-events.js";
|
|
@@ -118,6 +118,10 @@ export function useChatThread(params) {
|
|
|
118
118
|
const [operationCards, setOperationCards] = useState([]);
|
|
119
119
|
const [artifactCards, setArtifactCards] = useState([]);
|
|
120
120
|
const [historyLimit, setHistoryLimit] = useState(50);
|
|
121
|
+
const [historyHasMore, setHistoryHasMore] = useState(false);
|
|
122
|
+
const [historyNextCursor, setHistoryNextCursor] = useState(null);
|
|
123
|
+
const [historyLoading, setHistoryLoading] = useState(false);
|
|
124
|
+
const historyLoadingRef = useRef(false);
|
|
121
125
|
const [unread, setUnread] = useState(0);
|
|
122
126
|
const [humanGateCards, setHumanGateCards] = useState([]);
|
|
123
127
|
const [interview, setInterview] = useState(null);
|
|
@@ -136,6 +140,8 @@ export function useChatThread(params) {
|
|
|
136
140
|
/** Assistant bubble id for the in-flight turn; only this row shows the streaming cursor. */
|
|
137
141
|
const [streamingAssistantId, setStreamingAssistantId] = useState(null);
|
|
138
142
|
const [showSystemInMessages] = useState(false);
|
|
143
|
+
const completedGroupsRef = useRef([]);
|
|
144
|
+
const previousTimelineRef = useRef(null);
|
|
139
145
|
const refreshSessionTelemetry = useCallback(async () => {
|
|
140
146
|
const targetSessionId = refs.sessionRef.current?.sessionId;
|
|
141
147
|
if (!targetSessionId)
|
|
@@ -301,12 +307,55 @@ export function useChatThread(params) {
|
|
|
301
307
|
};
|
|
302
308
|
}, [toolCalls]);
|
|
303
309
|
const turnGroups = useMemo(() => {
|
|
304
|
-
const
|
|
310
|
+
const previousTimeline = previousTimelineRef.current;
|
|
311
|
+
let built;
|
|
312
|
+
const activeIndex = streamingAssistantId
|
|
313
|
+
? timelineMessages.findIndex((message) => message.id === streamingAssistantId)
|
|
314
|
+
: -1;
|
|
315
|
+
const canBuildActiveTail = Boolean(previousTimeline && streaming && streamingAssistantId && activeIndex >= 0) &&
|
|
316
|
+
previousTimeline?.activeAssistantId === streamingAssistantId &&
|
|
317
|
+
(previousTimeline?.messages.every((message, index) => message.id === streamingAssistantId || timelineMessages[index]?.id === message.id) ?? false);
|
|
318
|
+
if (canBuildActiveTail && previousTimeline) {
|
|
319
|
+
let tailStart = activeIndex;
|
|
320
|
+
while (tailStart > 0 && timelineMessages[tailStart - 1]?.role !== "user")
|
|
321
|
+
tailStart -= 1;
|
|
322
|
+
const tailMessages = timelineMessages.slice(tailStart);
|
|
323
|
+
const tailIds = new Set(tailMessages.map((message) => message.id));
|
|
324
|
+
const tailTurnIds = new Set(tailMessages.map((message) => message.turnId).filter(Boolean));
|
|
325
|
+
const tailTools = toolCalls.filter((tool) => tailIds.has(tool.assistantMessageId ?? "") ||
|
|
326
|
+
(Boolean(tool.turnId) && tailTurnIds.has(tool.turnId)) ||
|
|
327
|
+
(tool.createdAt >= (tailMessages[0]?.createdAt ?? Number.POSITIVE_INFINITY)));
|
|
328
|
+
const activeTail = buildTurnProcessGroups({
|
|
329
|
+
messages: tailMessages,
|
|
330
|
+
toolCalls: tailTools,
|
|
331
|
+
streaming,
|
|
332
|
+
activeAssistantId: streamingAssistantId,
|
|
333
|
+
});
|
|
334
|
+
built = [...previousTimeline.groups.filter((group) => !group.live), ...activeTail];
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
built = buildTurnProcessGroups({
|
|
338
|
+
messages: timelineMessages,
|
|
339
|
+
toolCalls,
|
|
340
|
+
streaming,
|
|
341
|
+
activeAssistantId: streamingAssistantId,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// Preserve completed group objects across streaming tokens. The active
|
|
345
|
+
// tail is rebuilt, while stable historical groups keep their references so
|
|
346
|
+
// memoized rows do not re-render or re-parse the full conversation.
|
|
347
|
+
const previous = new Map(completedGroupsRef.current.map((group) => [group.key, group]));
|
|
348
|
+
const all = built.map((group) => {
|
|
349
|
+
const cached = previous.get(group.key);
|
|
350
|
+
return cached && !group.live ? cached : group;
|
|
351
|
+
});
|
|
352
|
+
completedGroupsRef.current = all.filter((group) => !group.live);
|
|
353
|
+
previousTimelineRef.current = {
|
|
305
354
|
messages: timelineMessages,
|
|
306
355
|
toolCalls,
|
|
307
|
-
streaming,
|
|
308
356
|
activeAssistantId: streamingAssistantId,
|
|
309
|
-
|
|
357
|
+
groups: all,
|
|
358
|
+
};
|
|
310
359
|
return selectRecentTurnGroups(all, historyLimit);
|
|
311
360
|
}, [
|
|
312
361
|
timelineMessages,
|
|
@@ -315,6 +364,50 @@ export function useChatThread(params) {
|
|
|
315
364
|
streamingAssistantId,
|
|
316
365
|
historyLimit,
|
|
317
366
|
]);
|
|
367
|
+
const loadEarlierMessages = useCallback(async () => {
|
|
368
|
+
const targetSessionId = refs.sessionRef.current?.sessionId;
|
|
369
|
+
const beforeId = historyNextCursor;
|
|
370
|
+
if (!targetSessionId || !historyHasMore || !beforeId || historyLoadingRef.current)
|
|
371
|
+
return false;
|
|
372
|
+
historyLoadingRef.current = true;
|
|
373
|
+
setHistoryLoading(true);
|
|
374
|
+
try {
|
|
375
|
+
const query = new URLSearchParams({ limit: "50", beforeId });
|
|
376
|
+
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(targetSessionId)}/messages?${query.toString()}`, { credentials: "include" });
|
|
377
|
+
if (!res.ok || refs.sessionRef.current?.sessionId !== targetSessionId)
|
|
378
|
+
return false;
|
|
379
|
+
const body = (await res.json());
|
|
380
|
+
const page = hydrateSessionThread(body.messages ?? []);
|
|
381
|
+
setMessages((current) => {
|
|
382
|
+
const seen = new Set();
|
|
383
|
+
const merged = [...page.messages, ...current].filter((message) => {
|
|
384
|
+
if (seen.has(message.id))
|
|
385
|
+
return false;
|
|
386
|
+
seen.add(message.id);
|
|
387
|
+
return true;
|
|
388
|
+
});
|
|
389
|
+
return merged.sort((a, b) => {
|
|
390
|
+
if (a.eventSeq !== undefined && b.eventSeq !== undefined && a.eventSeq !== b.eventSeq)
|
|
391
|
+
return a.eventSeq - b.eventSeq;
|
|
392
|
+
return a.createdAt - b.createdAt;
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
setToolCalls((current) => {
|
|
396
|
+
const byId = new Map(current.map((tool) => [tool.id, tool]));
|
|
397
|
+
for (const tool of page.toolCalls)
|
|
398
|
+
byId.set(tool.id, tool);
|
|
399
|
+
return [...byId.values()].sort((a, b) => a.createdAt - b.createdAt);
|
|
400
|
+
});
|
|
401
|
+
setHistoryHasMore(body.hasMore === true);
|
|
402
|
+
setHistoryNextCursor(body.nextCursor ?? null);
|
|
403
|
+
setHistoryLimit((value) => value + 50);
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
historyLoadingRef.current = false;
|
|
408
|
+
setHistoryLoading(false);
|
|
409
|
+
}
|
|
410
|
+
}, [historyHasMore, historyNextCursor, origin, refs]);
|
|
318
411
|
const visibleMessages = useMemo(() => {
|
|
319
412
|
const next = [];
|
|
320
413
|
for (const group of turnGroups) {
|
|
@@ -346,6 +439,11 @@ export function useChatThread(params) {
|
|
|
346
439
|
setStreamingAssistantId(null);
|
|
347
440
|
setUnread(0);
|
|
348
441
|
setHistoryLimit(50);
|
|
442
|
+
setHistoryHasMore(false);
|
|
443
|
+
setHistoryNextCursor(null);
|
|
444
|
+
historyLoadingRef.current = false;
|
|
445
|
+
completedGroupsRef.current = [];
|
|
446
|
+
previousTimelineRef.current = null;
|
|
349
447
|
refs.usageResponseKeysRef.current = new Set();
|
|
350
448
|
}, [refs]);
|
|
351
449
|
/** Adopt a freshly activated session's hydrated thread + streaming flags. */
|
|
@@ -374,6 +472,10 @@ export function useChatThread(params) {
|
|
|
374
472
|
setStreamingAssistantId(null);
|
|
375
473
|
setUnread(0);
|
|
376
474
|
setHistoryLimit(50);
|
|
475
|
+
setHistoryHasMore(next.history?.hasMore === true);
|
|
476
|
+
setHistoryNextCursor(next.history?.nextCursor ?? null);
|
|
477
|
+
completedGroupsRef.current = [];
|
|
478
|
+
previousTimelineRef.current = null;
|
|
377
479
|
}, [refs]);
|
|
378
480
|
/** Reconcile recovery (AC-001/AC-002/AC-003): reload the canonical session
|
|
379
481
|
* facts (session-store messages + event-ring tail cursor) and REPLACE the
|
|
@@ -417,6 +519,8 @@ export function useChatThread(params) {
|
|
|
417
519
|
const hydrated = hydrateSessionThread(body.session.messages ?? []);
|
|
418
520
|
setMessages(mergeCompactionMessages(hydrated.messages, body.compactions));
|
|
419
521
|
setToolCalls(hydrated.toolCalls);
|
|
522
|
+
setHistoryHasMore(body.history?.hasMore === true);
|
|
523
|
+
setHistoryNextCursor(body.history?.nextCursor ?? null);
|
|
420
524
|
setStreamingAssistantId(null);
|
|
421
525
|
return true;
|
|
422
526
|
}
|
|
@@ -446,6 +550,10 @@ export function useChatThread(params) {
|
|
|
446
550
|
artifactCards,
|
|
447
551
|
historyLimit,
|
|
448
552
|
setHistoryLimit,
|
|
553
|
+
historyHasMore,
|
|
554
|
+
historyNextCursor,
|
|
555
|
+
historyLoading,
|
|
556
|
+
loadEarlierMessages,
|
|
449
557
|
unread,
|
|
450
558
|
humanGateCards,
|
|
451
559
|
interview,
|