返回 DeepSeek-Reasonix
readStatus.ts
根目录 / desktop / frontend / src / lib / readStatus.ts
1 // Read progress is host-owned, keyed by read id, and upserted: a hundred pages
2 // of one logical read still render one status line, never a hundred notices.
3
4 /** WireReadStatus is one read's delivery state; zero-based half-open ranges, no text. */
5 export interface WireReadStatus {
6 readId: string;
7 generation?: number;
8 seq?: number;
9 path: string;
10 intent?: string;
11 state: string;
12 verdict?: "partial_read_sufficient" | "full_read_pending" | "read_hard_stop";
13 covered?: [number, number][];
14 missing?: [number, number][];
15 sourceEnd?: number;
16 hasMore?: boolean;
17 reason?: string;
18 recovery?: string;
19 active?: boolean;
20 }
21
22 /** Host recovery metadata, never an executable permission or read receipt. */
23 export interface OperationDiagnostic {
24 code: string;
25 path?: string;
26 operation_id?: string;
27 expected_snapshot?: string;
28 actual_snapshot?: string;
29 required_ranges?: { start: number; end: number }[];
30 recovery: string;
31 /** Host-issued receipt ids the model may cite instead of retyping a command. */
32 available_receipts?: string[];
33 /** Closed set of actions the host accepts, e.g. "use_receipt:r_123". */
34 allowed_recovery?: string[];
35 retryable?: boolean;
36 retry_budget?: number;
37 /** Operation lifecycle state; "needs_user" means the host stopped retrying. */
38 state?: string;
39 }
40
41 export type ReadStatusHost = { readStatuses?: Record<string, WireReadStatus>; readStatusClosed?: boolean };
42
43 export function applyReadStatusEvent<T extends ReadStatusHost & { activeTurnId?: string; turnActive: boolean }>(state: T, event: { turnId?: string; readStatus?: WireReadStatus }): T {
44 if (state.readStatusClosed || (state.activeTurnId && !state.turnActive)) return state;
45 if (event.turnId && state.activeTurnId && event.turnId !== state.activeTurnId) return state;
46 return applyReadStatusFrame(state, event.readStatus);
47 }
48
49 /**
50 * applyReadStatusFrame upserts one frame. A re-ordered frame never moves a read
51 * backwards, and an unnamed frame is ignored.
52 */
53 export function applyReadStatusFrame<T extends ReadStatusHost>(state: T, incoming: WireReadStatus | undefined): T {
54 if (!incoming?.readId) return state;
55 const previous = state.readStatuses?.[incoming.readId];
56 if (previous) {
57 const oldGeneration = previous.generation ?? 0;
58 const generation = incoming.generation ?? 0;
59 if (generation < oldGeneration) return state;
60 if (generation === oldGeneration && (incoming.seq ?? 0) <= (previous.seq ?? 0)) return state;
61 }
62 return { ...state, readStatuses: { ...(state.readStatuses ?? {}), [incoming.readId]: incoming } };
63 }
64
65 /** ReadStatusKey is the closed set of labels one read status can produce. */
66 export type ReadStatusKey =
67 | "composer.readStatusReading"
68 | "composer.readStatusCovered"
69 | "composer.readStatusDone"
70 | "composer.readStatusPaused"
71 | "composer.readStatusBudget"
72 | "composer.readStatusSource"
73 | "composer.readStatusStalled"
74 | "composer.readStatusRecovery";
75
76 /** readStatusLabel renders every active read as one short status line. */
77 export function readStatusLabel(
78 statuses: Record<string, WireReadStatus> | undefined,
79 t: (key: ReadStatusKey, vars?: Record<string, string | number>) => string,
80 ): string {
81 const active = Object.values(statuses ?? {}).filter((status) => status.active);
82 return active.map((status) => readStatusItemLabel(status, t)).join(" · ");
83 }
84
85 function readStatusItemLabel(first: WireReadStatus, t: (key: ReadStatusKey, vars?: Record<string, string | number>) => string): string {
86 const file = first.path.split(/[\\/]/).pop() || first.path;
87 const covered = first.covered?.length
88 ? first.covered.map(([start, end]) => `${start + 1}–${end}`).join(", ")
89 : "";
90 if (first.state === "blocked" || first.state === "needs_scope") {
91 const reason = first.reason === "no_progress" ? "composer.readStatusStalled"
92 : ["page_budget", "time_budget", "no_headroom", "unknown_window"].includes(first.reason ?? "") ? "composer.readStatusBudget"
93 : "composer.readStatusSource";
94 return [t("composer.readStatusPaused", { file }), t(reason), t("composer.readStatusRecovery")].join(" · ");
95 }
96 if (first.hasMore) {
97 return covered ? t("composer.readStatusCovered", { file, range: covered }) : t("composer.readStatusReading", { file });
98 }
99 return covered ? t("composer.readStatusDone", { file, range: covered }) : t("composer.readStatusReading", { file });
100 }
101
102 /** TurnPhaseKey is the closed set of phase labels the composer can show. */
103 export type TurnPhaseKey =
104 | "composer.turnPhaseChecking"
105 | "composer.turnPhaseVerifying"
106 | "composer.turnPhaseReviewing"
107 | "composer.turnPhaseWorking"
108 | "composer.runAnnounceRunning";
109
110 /** turnPhaseStatusLabel renders the host turn phase for the status line. */
111 export function turnPhaseStatusLabel(
112 turnPhase: string | undefined,
113 t: (key: TurnPhaseKey, vars?: Record<string, string | number>) => string,
114 ): string {
115 switch ((turnPhase ?? "").trim()) {
116 case "checking":
117 return t("composer.turnPhaseChecking");
118 case "verifying":
119 return t("composer.turnPhaseVerifying");
120 case "reviewing":
121 return t("composer.turnPhaseReviewing");
122 case "working":
123 return t("composer.turnPhaseWorking");
124 default:
125 return t("composer.runAnnounceRunning");
126 }
127 }
128
128 lines TYPESCRIPT