@hienlh/ppm 0.17.55 → 0.17.57
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/CHANGELOG.md +12 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-2h4gRHil.js → ai-resource-editor-CXWQcx3_.js} +1 -1
- package/dist/web/assets/{audio-preview-Duhsn8R0.js → audio-preview-D7jNc8mi.js} +1 -1
- package/dist/web/assets/{chat-tab-CdS8pzow.js → chat-tab-C5U7a_rP.js} +3 -3
- package/dist/web/assets/{code-editor-Bq5OC1R8.js → code-editor-CqlzLQ7T.js} +2 -2
- package/dist/web/assets/{conflict-editor-DdX0vWbq.js → conflict-editor-CpGcSopK.js} +1 -1
- package/dist/web/assets/{csv-preview-DzCzp1eI.js → csv-preview-DD94T9ff.js} +1 -1
- package/dist/web/assets/{database-viewer-Bc0SxzHp.js → database-viewer-DTMmic31.js} +1 -1
- package/dist/web/assets/{diff-viewer-D1_p6x3B.js → diff-viewer-DyQLYP7J.js} +1 -1
- package/dist/web/assets/{docx-preview-DwMr29B9.js → docx-preview-CR2ZtvNP.js} +1 -1
- package/dist/web/assets/{extension-webview-DekZ27i8.js → extension-webview-BTCFWY2A.js} +1 -1
- package/dist/web/assets/{git-log-panel-BfND10PZ.js → git-log-panel-CrUgsLY5.js} +1 -1
- package/dist/web/assets/{glide-data-grid-CR7uGT3d.js → glide-data-grid-LinTYl0_.js} +1 -1
- package/dist/web/assets/{group-chat-tab-DtnJcJnm.js → group-chat-tab-BqNJW7y2.js} +1 -1
- package/dist/web/assets/{image-preview-IL6Io7vM.js → image-preview-0SGb7_tH.js} +1 -1
- package/dist/web/assets/{index-oAxj66VZ.js → index-I2ik0ZF7.js} +3 -3
- package/dist/web/assets/{keybindings-store-Dq7MhLxP.js → keybindings-store-C46tNxJE.js} +1 -1
- package/dist/web/assets/{markdown-renderer-BWY-49fv.js → markdown-renderer-DSxSupDq.js} +1 -1
- package/dist/web/assets/{markdown-renderer-SKo8Onun.js → markdown-renderer-xzyhJklS.js} +1 -1
- package/dist/web/assets/{notification-store-U1Rbcui7.js → notification-store-Dek7ClI2.js} +1 -1
- package/dist/web/assets/{pdf-preview-Dl5cTIGt.js → pdf-preview-DEn8amrO.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DR_shYSN.js → postgres-viewer-CjrM15gz.js} +1 -1
- package/dist/web/assets/{settings-tab-DI2q_Qj3.js → settings-tab-DIuR1VIc.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BFnHUYW2.js → sql-query-editor-CT5M9PKQ.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DZAF9v9K.js → sqlite-viewer-CEObRQ7n.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-BIn4ZRWr.js → system-monitor-tab-De3oz9zD.js} +1 -1
- package/dist/web/assets/{terminal-tab-BI4jMhtL.js → terminal-tab-CciNqtMc.js} +1 -1
- package/dist/web/assets/{tool-cards-DooXE9m_.js → tool-cards-Bdx04yYD.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-rmEGn72e.js → use-monaco-theme-k-17kQqf.js} +1 -1
- package/dist/web/assets/{video-preview-DDrh4ENk.js → video-preview-D-S_YnG6.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
- package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
- package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
- package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
- package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +6 -0
- package/src/providers/mock-provider.ts +10 -0
- package/src/server/index.ts +41 -15
- package/src/server/ws/chat.ts +8 -1
- package/src/services/autostart-generator.ts +24 -0
- package/src/services/autostart-register.ts +33 -4
- package/src/services/jsonl-transcript-parser.ts +6 -0
- package/src/services/subagent-transcript-merger.ts +144 -0
- package/src/web/app.tsx +1 -16
- package/src/web/hooks/use-chat.ts +25 -6
- package/src/web/hooks/use-global-events.ts +8 -2
- package/src/web/lib/sync-running-sessions.ts +27 -0
- package/src/web/stores/streaming-store.ts +29 -7
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubagentTranscriptMerger — restore Agent/Task card children on reload.
|
|
3
|
+
*
|
|
4
|
+
* Newer Claude Code CLIs no longer write subagent activity into the main
|
|
5
|
+
* session JSONL (no isSidechain lines, no parent_tool_use_id). Instead each
|
|
6
|
+
* agent gets its own transcript at:
|
|
7
|
+
* ~/.claude/projects/<slug>/<sessionId>/subagents/agent-<id>.jsonl
|
|
8
|
+
* with a sibling agent-<id>.meta.json carrying the spawning Agent tool_use id.
|
|
9
|
+
*
|
|
10
|
+
* Without merging these, a reloaded session shows bare Agent cards and the
|
|
11
|
+
* live-streamed children are lost. This module reads the meta files, parses
|
|
12
|
+
* each agent transcript, and attaches its events as the card's children.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import type { ChatEvent } from "../types/api.ts";
|
|
19
|
+
import { parseSessionMessage } from "./jsonl-transcript-parser.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Locate the per-session directory (…/projects/<slug>/<sessionId>) that holds
|
|
23
|
+
* subagents/. The SDK encodes the cwd by replacing separators + drive colon
|
|
24
|
+
* with "-"; when that drifts (drive-letter case), fall back to scanning
|
|
25
|
+
* project dirs for the session's main JSONL.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveSessionDir(sessionId: string, projectPath: string | null | undefined): string | null {
|
|
28
|
+
const home = homedir();
|
|
29
|
+
const projectsRoot = join(home, ".claude", "projects");
|
|
30
|
+
if (projectPath) {
|
|
31
|
+
const encoded = projectPath.replace(/[/\\:]/g, "-");
|
|
32
|
+
const dir = join(projectsRoot, encoded);
|
|
33
|
+
if (existsSync(join(dir, `${sessionId}.jsonl`))) return join(dir, sessionId);
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
for (const entry of readdirSync(projectsRoot)) {
|
|
37
|
+
if (existsSync(join(projectsRoot, entry, `${sessionId}.jsonl`))) {
|
|
38
|
+
return join(projectsRoot, entry, sessionId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
/* projects root unreadable */
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface MessageLike {
|
|
48
|
+
content: string;
|
|
49
|
+
events?: ChatEvent[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Payload guards — a session can hold dozens of agents with multi-MB transcripts. */
|
|
53
|
+
const MAX_CHILDREN_PER_AGENT = 2000;
|
|
54
|
+
const MAX_CHILD_OUTPUT_CHARS = 50_000;
|
|
55
|
+
|
|
56
|
+
/** Map of Agent-card toolUseId → agent transcript path, from subagents/*.meta.json */
|
|
57
|
+
function indexAgentTranscripts(subagentsDir: string): Map<string, string> {
|
|
58
|
+
const map = new Map<string, string>();
|
|
59
|
+
let entries: string[];
|
|
60
|
+
try {
|
|
61
|
+
entries = readdirSync(subagentsDir);
|
|
62
|
+
} catch {
|
|
63
|
+
return map;
|
|
64
|
+
}
|
|
65
|
+
for (const name of entries) {
|
|
66
|
+
if (!name.endsWith(".meta.json")) continue;
|
|
67
|
+
try {
|
|
68
|
+
const meta = JSON.parse(readFileSync(join(subagentsDir, name), "utf8"));
|
|
69
|
+
const toolUseId = meta?.toolUseId as string | undefined;
|
|
70
|
+
if (!toolUseId) continue;
|
|
71
|
+
const transcript = join(subagentsDir, name.replace(/\.meta\.json$/, ".jsonl"));
|
|
72
|
+
if (existsSync(transcript)) map.set(toolUseId, transcript);
|
|
73
|
+
} catch {
|
|
74
|
+
/* unreadable meta — skip this agent */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return map;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parse one agent transcript into a flat children event list (stream order). */
|
|
81
|
+
function parseAgentTranscript(filePath: string): ChatEvent[] {
|
|
82
|
+
const children: ChatEvent[] = [];
|
|
83
|
+
let text: string;
|
|
84
|
+
try {
|
|
85
|
+
text = readFileSync(filePath, "utf8");
|
|
86
|
+
} catch {
|
|
87
|
+
return children;
|
|
88
|
+
}
|
|
89
|
+
let isFirstUser = true;
|
|
90
|
+
for (const line of text.split("\n")) {
|
|
91
|
+
const trimmed = line.trim();
|
|
92
|
+
if (!trimmed) continue;
|
|
93
|
+
let entry: any;
|
|
94
|
+
try {
|
|
95
|
+
entry = JSON.parse(trimmed);
|
|
96
|
+
} catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
100
|
+
if (!entry.message) continue;
|
|
101
|
+
if (children.length >= MAX_CHILDREN_PER_AGENT) break;
|
|
102
|
+
// The first user record is the agent's spawn prompt — the live stream
|
|
103
|
+
// never showed it as a child, so skip it to match the streaming view.
|
|
104
|
+
if (entry.type === "user" && isFirstUser) {
|
|
105
|
+
isFirstUser = false;
|
|
106
|
+
const content = entry.message?.content;
|
|
107
|
+
const hasToolResult = Array.isArray(content) && content.some((b: any) => b?.type === "tool_result");
|
|
108
|
+
if (!hasToolResult) continue;
|
|
109
|
+
}
|
|
110
|
+
const parsed = parseSessionMessage(entry);
|
|
111
|
+
for (const ev of parsed.events ?? []) {
|
|
112
|
+
// Keep single events from ballooning the history payload (agent files
|
|
113
|
+
// can carry multi-MB tool outputs the live stream also showed in full,
|
|
114
|
+
// but 24 agents × full outputs breaks mobile reloads).
|
|
115
|
+
if (ev.type === "tool_result" && typeof ev.output === "string" && ev.output.length > MAX_CHILD_OUTPUT_CHARS) {
|
|
116
|
+
ev.output = ev.output.slice(0, MAX_CHILD_OUTPUT_CHARS) + "\n… [truncated]";
|
|
117
|
+
}
|
|
118
|
+
children.push(ev);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return children;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Attach subagent transcript events as children of their Agent/Task cards.
|
|
126
|
+
* `sessionDir` is the per-session directory next to the main JSONL
|
|
127
|
+
* (…/projects/<slug>/<sessionId>). Existing children are replaced — the disk
|
|
128
|
+
* transcript is the complete record, while live-collected children may be a
|
|
129
|
+
* partial overlap. Mutates messages in-place.
|
|
130
|
+
*/
|
|
131
|
+
export function mergeSubagentChildren(sessionDir: string, messages: MessageLike[]): void {
|
|
132
|
+
const index = indexAgentTranscripts(join(sessionDir, "subagents"));
|
|
133
|
+
if (index.size === 0) return;
|
|
134
|
+
|
|
135
|
+
for (const msg of messages) {
|
|
136
|
+
for (const ev of msg.events ?? []) {
|
|
137
|
+
if (ev.type !== "tool_use" || (ev.tool !== "Agent" && ev.tool !== "Task") || !ev.toolUseId) continue;
|
|
138
|
+
const transcript = index.get(ev.toolUseId);
|
|
139
|
+
if (!transcript) continue;
|
|
140
|
+
const children = parseAgentTranscript(transcript);
|
|
141
|
+
if (children.length > 0) ev.children = children;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/web/app.tsx
CHANGED
|
@@ -16,8 +16,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
|
|
16
16
|
import { useTheme } from "@/theme/use-theme";
|
|
17
17
|
import { initShikiThemeSync, warmShiki } from "@/theme/adapters/shiki-adapter";
|
|
18
18
|
import { initMonacoThemeSync } from "@/theme/adapters/monaco-adapter";
|
|
19
|
-
import { getAuthToken
|
|
20
|
-
import { useStreamingStore } from "@/stores/streaming-store";
|
|
19
|
+
import { getAuthToken } from "@/lib/api-client";
|
|
21
20
|
import { useUrlSync, parseUrlState, autoOpenFromUrl } from "@/hooks/use-url-sync";
|
|
22
21
|
import { useGlobalKeybindings } from "@/hooks/use-global-keybindings";
|
|
23
22
|
import { useNotificationBadge } from "@/hooks/use-notification-badge";
|
|
@@ -227,20 +226,6 @@ export function App() {
|
|
|
227
226
|
});
|
|
228
227
|
}, [authState, activeProject?.name]);
|
|
229
228
|
|
|
230
|
-
// Seed which sessions are mid-turn. Chat tabs mount lazily, and a session's
|
|
231
|
-
// phase otherwise only arrives over that tab's WebSocket — so without this a
|
|
232
|
-
// background turn shows no tab-strip spinner and no title indicator.
|
|
233
|
-
useEffect(() => {
|
|
234
|
-
if (authState !== "authenticated" || !activeProject?.name) return;
|
|
235
|
-
api
|
|
236
|
-
.get<{ sessionId: string }[]>(`${projectUrl(activeProject.name)}/chat/sessions/running`)
|
|
237
|
-
.then((running) => {
|
|
238
|
-
const { setStreaming } = useStreamingStore.getState();
|
|
239
|
-
for (const s of running) setStreaming(s.sessionId, true);
|
|
240
|
-
})
|
|
241
|
-
.catch(() => {}); // never block boot on the indicator
|
|
242
|
-
}, [authState, activeProject?.name]);
|
|
243
|
-
|
|
244
229
|
// Keep-alive: mount workspace on first visit, never unmount
|
|
245
230
|
useEffect(() => {
|
|
246
231
|
const projectName = activeProject?.name ?? "__global__";
|
|
@@ -216,9 +216,10 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
216
216
|
// Sync streaming state to global store (for favicon + tab icon indicators)
|
|
217
217
|
useEffect(() => {
|
|
218
218
|
if (!sessionId) return;
|
|
219
|
-
|
|
219
|
+
// projectName tags the entry so a project-scoped sync can reconcile it.
|
|
220
|
+
useStreamingStore.getState().setStreaming(sessionId, phase !== "idle", projectName);
|
|
220
221
|
return () => { useStreamingStore.getState().setStreaming(sessionId, false); };
|
|
221
|
-
}, [sessionId, phase]);
|
|
222
|
+
}, [sessionId, phase, projectName]);
|
|
222
223
|
|
|
223
224
|
/**
|
|
224
225
|
* Route a child event to its parent Agent/Task tool_use's children array.
|
|
@@ -363,7 +364,13 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
363
364
|
syncMessages();
|
|
364
365
|
break;
|
|
365
366
|
}
|
|
366
|
-
if (pid
|
|
367
|
+
if (pid) {
|
|
368
|
+
// Parent card not found (e.g. replay raced the history fetch) — drop
|
|
369
|
+
// rather than rendering subagent output flat in the main transcript;
|
|
370
|
+
// the history merge restores it from the agent's disk transcript.
|
|
371
|
+
if (routeToFinalizedParent(ev as ChatEvent, pid)) { /* nested */ }
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
367
374
|
streamingContentRef.current += ev.content;
|
|
368
375
|
streamingEventsRef.current.push(ev as ChatEvent);
|
|
369
376
|
syncMessages();
|
|
@@ -376,7 +383,11 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
376
383
|
syncMessages();
|
|
377
384
|
break;
|
|
378
385
|
}
|
|
379
|
-
if (pid
|
|
386
|
+
if (pid) {
|
|
387
|
+
// No parent found → drop (see text case) instead of flat-rendering.
|
|
388
|
+
if (routeToFinalizedParent(ev as ChatEvent, pid)) { /* nested */ }
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
380
391
|
streamingEventsRef.current.push(ev as ChatEvent);
|
|
381
392
|
syncMessages();
|
|
382
393
|
break;
|
|
@@ -394,7 +405,11 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
394
405
|
syncMessages();
|
|
395
406
|
break;
|
|
396
407
|
}
|
|
397
|
-
if (pid
|
|
408
|
+
if (pid) {
|
|
409
|
+
// No parent found → drop (see text case) instead of flat-rendering.
|
|
410
|
+
if (routeToFinalizedParent(ev as ChatEvent, pid)) { /* nested */ }
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
398
413
|
const tuId = ev.toolUseId as string | undefined;
|
|
399
414
|
upsertStreamingEvent((e) => !!tuId && e.type === "tool_use" && (e as any).toolUseId === tuId);
|
|
400
415
|
syncMessages();
|
|
@@ -415,7 +430,11 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
415
430
|
syncMessages();
|
|
416
431
|
break;
|
|
417
432
|
}
|
|
418
|
-
if (pid
|
|
433
|
+
if (pid) {
|
|
434
|
+
// No parent found → drop (see text case) instead of flat-rendering.
|
|
435
|
+
if (routeToFinalizedParent(ev as ChatEvent, pid)) { /* nested */ }
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
419
438
|
upsertStreamingEvent((e) => !!trId && e.type === "tool_result" && (e as any).toolUseId === trId);
|
|
420
439
|
syncMessages();
|
|
421
440
|
break;
|
|
@@ -3,6 +3,7 @@ import { WsClient } from "@/lib/ws-client";
|
|
|
3
3
|
import { getAuthToken } from "@/lib/api-client";
|
|
4
4
|
import { useNotificationStore } from "@/stores/notification-store";
|
|
5
5
|
import { useStreamingStore } from "@/stores/streaming-store";
|
|
6
|
+
import { syncRunningSessions } from "@/lib/sync-running-sessions";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* App-wide event bus client (`/ws/global`).
|
|
@@ -18,6 +19,9 @@ import { useStreamingStore } from "@/stores/streaming-store";
|
|
|
18
19
|
* - `session:phase_changed` → keeps the tab-strip spinner and title indicator
|
|
19
20
|
* correct for sessions whose tab is not mounted, and — critically — clears them
|
|
20
21
|
* when the turn ends. Nothing else can: `useChat` only runs while mounted.
|
|
22
|
+
* On every (re)connect the indicators are also reconciled against the server
|
|
23
|
+
* registry, since a phase change that happened while this socket was down was
|
|
24
|
+
* never delivered and would otherwise stick until a full reload.
|
|
21
25
|
* - `jira:*` → re-dispatched as window events.
|
|
22
26
|
*
|
|
23
27
|
* Also tells the server which project to watch, so file watching follows the
|
|
@@ -53,6 +57,7 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
|
|
|
53
57
|
if (projectRef.current) {
|
|
54
58
|
client.send(JSON.stringify({ type: "watch", projectName: projectRef.current }));
|
|
55
59
|
}
|
|
60
|
+
void syncRunningSessions(projectRef.current);
|
|
56
61
|
return;
|
|
57
62
|
}
|
|
58
63
|
|
|
@@ -73,8 +78,8 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
|
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
if (type === "session:phase_changed") {
|
|
76
|
-
const d = data as unknown as { sessionId: string; phase: string };
|
|
77
|
-
useStreamingStore.getState().setStreaming(d.sessionId, d.phase !== "idle");
|
|
81
|
+
const d = data as unknown as { sessionId: string; phase: string; projectName?: string };
|
|
82
|
+
useStreamingStore.getState().setStreaming(d.sessionId, d.phase !== "idle", d.projectName);
|
|
78
83
|
return;
|
|
79
84
|
}
|
|
80
85
|
|
|
@@ -97,5 +102,6 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
|
|
|
97
102
|
useEffect(() => {
|
|
98
103
|
if (!enabled || !projectName) return;
|
|
99
104
|
clientRef.current?.send(JSON.stringify({ type: "watch", projectName }));
|
|
105
|
+
void syncRunningSessions(projectName);
|
|
100
106
|
}, [enabled, projectName]);
|
|
101
107
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
2
|
+
import { useStreamingStore } from "@/stores/streaming-store";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reconcile a project's streaming indicators (tab-strip spinner, title/favicon)
|
|
6
|
+
* against the server's session registry.
|
|
7
|
+
*
|
|
8
|
+
* Runs on every `/ws/global` (re)connect and on project switch. Two gaps it
|
|
9
|
+
* closes, both of which nothing else can:
|
|
10
|
+
* - a turn running in a tab that is not mounted (tabs mount lazily, so its phase
|
|
11
|
+
* never reaches this client) → indicator missing;
|
|
12
|
+
* - an `idle` broadcast that landed while the global socket was down → indicator
|
|
13
|
+
* stuck on until a full page reload.
|
|
14
|
+
*/
|
|
15
|
+
export async function syncRunningSessions(projectName: string | undefined): Promise<void> {
|
|
16
|
+
if (!projectName) return;
|
|
17
|
+
try {
|
|
18
|
+
const running = await api.get<{ sessionId: string }[]>(
|
|
19
|
+
`${projectUrl(projectName)}/chat/sessions/running`,
|
|
20
|
+
);
|
|
21
|
+
useStreamingStore
|
|
22
|
+
.getState()
|
|
23
|
+
.replaceProjectStreaming(projectName, running.map((s) => s.sessionId));
|
|
24
|
+
} catch {
|
|
25
|
+
// Never block boot or a reconnect on an indicator.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -2,21 +2,43 @@ import { create } from "zustand";
|
|
|
2
2
|
|
|
3
3
|
/** Tracks which chat sessions are currently streaming AI responses */
|
|
4
4
|
interface StreamingStore {
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/** sessionId → projectName ("" when unknown) for sessions actively streaming.
|
|
6
|
+
* The project is recorded so a project-scoped sync can drop stale entries
|
|
7
|
+
* without touching sessions that belong to another project. */
|
|
8
|
+
sessions: Map<string, string>;
|
|
7
9
|
/** Mark a session as streaming or idle */
|
|
8
|
-
setStreaming: (sessionId: string, streaming: boolean) => void;
|
|
10
|
+
setStreaming: (sessionId: string, streaming: boolean, projectName?: string) => void;
|
|
11
|
+
/**
|
|
12
|
+
* Reconcile one project's streaming set against the server's registry, which
|
|
13
|
+
* is authoritative. Needed because an `idle` broadcast missed while
|
|
14
|
+
* `/ws/global` was down would otherwise leave a spinner running forever —
|
|
15
|
+
* nothing else ever clears it. Scoped per project: the running list only
|
|
16
|
+
* covers one project, so entries from other projects must survive.
|
|
17
|
+
*/
|
|
18
|
+
replaceProjectStreaming: (projectName: string, sessionIds: string[]) => void;
|
|
9
19
|
}
|
|
10
20
|
|
|
11
21
|
export const useStreamingStore = create<StreamingStore>((set) => ({
|
|
12
|
-
sessions: new
|
|
13
|
-
setStreaming: (sessionId, streaming) =>
|
|
22
|
+
sessions: new Map(),
|
|
23
|
+
setStreaming: (sessionId, streaming, projectName) =>
|
|
14
24
|
set((state) => {
|
|
15
|
-
const next = new
|
|
16
|
-
|
|
25
|
+
const next = new Map(state.sessions);
|
|
26
|
+
// Keep a previously recorded project when a caller omits it, so the entry
|
|
27
|
+
// stays reconcilable.
|
|
28
|
+
if (streaming) next.set(sessionId, projectName ?? next.get(sessionId) ?? "");
|
|
17
29
|
else next.delete(sessionId);
|
|
18
30
|
return { sessions: next };
|
|
19
31
|
}),
|
|
32
|
+
replaceProjectStreaming: (projectName, sessionIds) =>
|
|
33
|
+
set((state) => {
|
|
34
|
+
const running = new Set(sessionIds);
|
|
35
|
+
const next = new Map(state.sessions);
|
|
36
|
+
for (const [id, project] of state.sessions) {
|
|
37
|
+
if (project === projectName && !running.has(id)) next.delete(id);
|
|
38
|
+
}
|
|
39
|
+
for (const id of sessionIds) next.set(id, projectName);
|
|
40
|
+
return { sessions: next };
|
|
41
|
+
}),
|
|
20
42
|
}));
|
|
21
43
|
|
|
22
44
|
/** Selector: true if any session is streaming */
|