@hienlh/ppm 0.9.0-beta.2 → 0.9.0-beta.3
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 +19 -0
- package/dist/web/assets/chat-tab-DxkvWelV.js +7 -0
- package/dist/web/assets/{code-editor-CQ7gq0Vj.js → code-editor-D3VJc1tY.js} +1 -1
- package/dist/web/assets/{database-viewer-B27aRtdQ.js → database-viewer-qlwORhh0.js} +1 -1
- package/dist/web/assets/{diff-viewer-BjtTemkK.js → diff-viewer-D5vGZJnH.js} +1 -1
- package/dist/web/assets/{git-graph-BGXo0o-J.js → git-graph-B2fHtKEc.js} +1 -1
- package/dist/web/assets/{index-CfClIVo2.js → index-Ccq6zi2E.js} +3 -3
- package/dist/web/assets/keybindings-store-e3pqlQbf.js +1 -0
- package/dist/web/assets/{markdown-renderer-BtPXdzTv.js → markdown-renderer-DcGMlbRm.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BMg-qFcO.js → postgres-viewer-CZzbMFtb.js} +1 -1
- package/dist/web/assets/{settings-tab-NPuwQHzs.js → settings-tab-BOmLAhkD.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CAsUczio.js → sqlite-viewer-CrrzHXqq.js} +1 -1
- package/dist/web/assets/{terminal-tab--Gw14HP3.js → terminal-tab-DHMITI3S.js} +2 -2
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +212 -76
- package/src/server/ws/chat.ts +103 -72
- package/src/types/api.ts +1 -1
- package/src/types/chat.ts +2 -0
- package/src/web/components/chat/chat-tab.tsx +3 -3
- package/src/web/components/chat/message-input.tsx +41 -4
- package/src/web/hooks/use-chat.ts +21 -9
- package/dist/web/assets/chat-tab-DmF14O6G.js +0 -7
- package/dist/web/assets/keybindings-store-nDbczFnq.js +0 -1
- package/snapshot-state.md +0 -1526
- package/test-tokens.mjs +0 -212
package/src/server/ws/chat.ts
CHANGED
|
@@ -22,7 +22,6 @@ type ChatWsSocket = {
|
|
|
22
22
|
interface SessionEntry {
|
|
23
23
|
providerId: string;
|
|
24
24
|
clients: Set<ChatWsSocket>;
|
|
25
|
-
abort?: AbortController;
|
|
26
25
|
projectPath?: string;
|
|
27
26
|
projectName?: string;
|
|
28
27
|
pingIntervals: Map<ChatWsSocket, ReturnType<typeof setInterval>>;
|
|
@@ -32,6 +31,8 @@ interface SessionEntry {
|
|
|
32
31
|
turnEvents: unknown[];
|
|
33
32
|
streamPromise?: Promise<void>;
|
|
34
33
|
permissionMode?: string;
|
|
34
|
+
/** Whether the persistent event consumer loop is running */
|
|
35
|
+
isStreamingActive: boolean;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
/** Tracks active sessions — persists even when FE disconnects */
|
|
@@ -125,6 +126,11 @@ function startCleanupTimer(sessionId: string): void {
|
|
|
125
126
|
entry.cleanupTimer = setTimeout(() => {
|
|
126
127
|
console.log(`[chat] session=${sessionId} cleanup: no FE reconnected within timeout`);
|
|
127
128
|
logSessionEvent(sessionId, "INFO", "Session cleaned up (no FE reconnected)");
|
|
129
|
+
// Close streaming session in provider
|
|
130
|
+
const provider = providerRegistry.get(entry.providerId);
|
|
131
|
+
if (provider && "closeStreamingSession" in provider) {
|
|
132
|
+
(provider as any).closeStreamingSession(sessionId);
|
|
133
|
+
}
|
|
128
134
|
for (const interval of entry.pingIntervals.values()) clearInterval(interval);
|
|
129
135
|
entry.pingIntervals.clear();
|
|
130
136
|
activeSessions.delete(sessionId);
|
|
@@ -132,27 +138,25 @@ function startCleanupTimer(sessionId: string): void {
|
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
141
|
+
* Persistent event consumer — runs for the entire session lifetime.
|
|
142
|
+
* First message creates the query; follow-ups push into the provider's
|
|
143
|
+
* message channel. Events from ALL turns flow through this single loop.
|
|
137
144
|
*/
|
|
138
|
-
async function
|
|
145
|
+
async function startSessionConsumer(sessionId: string, providerId: string, content: string, permissionMode?: string, images?: Array<{ data: string; mediaType: string }>): Promise<void> {
|
|
139
146
|
const entry = activeSessions.get(sessionId);
|
|
140
147
|
if (!entry) {
|
|
141
|
-
console.error(`[chat] session=${sessionId}
|
|
148
|
+
console.error(`[chat] session=${sessionId} startSessionConsumer: no entry — aborting`);
|
|
142
149
|
return;
|
|
143
150
|
}
|
|
144
|
-
|
|
145
|
-
console.log(`[chat] session=${sessionId} runStreamLoop started (clients=${entry.clients.size})`);
|
|
151
|
+
console.log(`[chat] session=${sessionId} startSessionConsumer started (clients=${entry.clients.size})`);
|
|
146
152
|
|
|
147
|
-
|
|
148
|
-
entry.abort = abortController;
|
|
153
|
+
entry.isStreamingActive = true;
|
|
149
154
|
entry.pendingApprovalEvent = undefined;
|
|
150
155
|
entry.turnEvents = [];
|
|
151
156
|
setPhase(sessionId, "connecting");
|
|
152
157
|
|
|
153
158
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
154
159
|
let lastContextWindowPct: number | undefined;
|
|
155
|
-
let doneEmitted = false;
|
|
156
160
|
|
|
157
161
|
try {
|
|
158
162
|
const userPreview = content.slice(0, 200);
|
|
@@ -161,12 +165,12 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
161
165
|
|
|
162
166
|
let eventCount = 0;
|
|
163
167
|
let firstEventReceived = false;
|
|
164
|
-
|
|
168
|
+
let startTime = Date.now();
|
|
165
169
|
|
|
166
170
|
// Heartbeat: while waiting for first response, send elapsed time every 5s
|
|
167
171
|
const CONNECTION_TIMEOUT_S = 120;
|
|
168
172
|
heartbeat = setInterval(() => {
|
|
169
|
-
if (firstEventReceived
|
|
173
|
+
if (firstEventReceived) {
|
|
170
174
|
clearInterval(heartbeat);
|
|
171
175
|
return;
|
|
172
176
|
}
|
|
@@ -185,27 +189,23 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
185
189
|
type: "error",
|
|
186
190
|
message: `Claude SDK timed out after ${elapsed}s for project "${projectPath || "(no project)"}".${wslHint}\n\nDebug steps:\n1. Run: \`${debugCmd}\` — if it also hangs, the issue is your Claude CLI environment\n2. Check env vars: \`echo $ANTHROPIC_API_KEY $ANTHROPIC_BASE_URL\` — stale/invalid keys cause silent hang\n3. Try with env cleared: \`ANTHROPIC_API_KEY="" ANTHROPIC_BASE_URL="" ${debugCmd}\`\n4. Check hooks/MCP: \`cat ${projectPath}/.claude/settings.local.json\`\n5. Refresh auth: \`claude login\``,
|
|
187
191
|
});
|
|
188
|
-
abortController.abort();
|
|
189
192
|
return;
|
|
190
193
|
}
|
|
191
|
-
// Heartbeat uses broadcast() directly — NOT setPhase() (same-phase guard would skip elapsed updates)
|
|
192
194
|
broadcast(sessionId, { type: "phase_changed", phase: "connecting", elapsed });
|
|
193
195
|
}, 5_000);
|
|
194
196
|
|
|
195
|
-
for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode })) {
|
|
196
|
-
if (abortController.signal.aborted) break;
|
|
197
|
+
for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode, images })) {
|
|
197
198
|
eventCount++;
|
|
198
199
|
const ev = event as any;
|
|
199
200
|
const evType = ev.type ?? "unknown";
|
|
200
201
|
|
|
201
|
-
// System events
|
|
202
|
-
// These indicate SDK has connected and is processing, but no content yet.
|
|
202
|
+
// System events → transition connecting → thinking
|
|
203
203
|
if (evType === "system") {
|
|
204
204
|
if (!firstEventReceived) {
|
|
205
205
|
if (heartbeat) clearInterval(heartbeat);
|
|
206
206
|
setPhase(sessionId, "thinking");
|
|
207
207
|
}
|
|
208
|
-
continue;
|
|
208
|
+
continue;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
// First content event — stop heartbeat, transition phase
|
|
@@ -238,10 +238,11 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
238
238
|
console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
|
|
239
239
|
logSessionEvent(sessionId, "ERROR", errorDetail);
|
|
240
240
|
} else if (evType === "done") {
|
|
241
|
-
|
|
241
|
+
// Turn complete — transition to idle, clear buffer for next turn
|
|
242
242
|
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
|
|
243
243
|
if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
|
|
244
|
-
|
|
244
|
+
|
|
245
|
+
// Fire-and-forget: title + notification
|
|
245
246
|
sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
|
|
246
247
|
const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
|
|
247
248
|
const title = found?.customTitle ?? found?.summary;
|
|
@@ -251,7 +252,6 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
251
252
|
if (session) session.title = title;
|
|
252
253
|
}
|
|
253
254
|
}).catch(() => {});
|
|
254
|
-
// Fire-and-forget notification broadcast (push + telegram)
|
|
255
255
|
import("../../services/notification.service.ts").then(({ notificationService }) => {
|
|
256
256
|
const project = entry.projectName || "Project";
|
|
257
257
|
const session = chatService.getSession(sessionId);
|
|
@@ -266,7 +266,6 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
266
266
|
}).catch(() => {});
|
|
267
267
|
} else if (evType === "approval_request") {
|
|
268
268
|
entry.pendingApprovalEvent = ev;
|
|
269
|
-
// Fire-and-forget notification for approval/question
|
|
270
269
|
import("../../services/notification.service.ts").then(({ notificationService }) => {
|
|
271
270
|
const project = entry.projectName || "Project";
|
|
272
271
|
const session = chatService.getSession(sessionId);
|
|
@@ -285,32 +284,40 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
285
284
|
|
|
286
285
|
// Buffer + broadcast content events
|
|
287
286
|
bufferAndBroadcast(sessionId, event);
|
|
287
|
+
|
|
288
|
+
// After "done", transition to idle + clear turn buffer for next turn
|
|
289
|
+
// Consumer loop continues — query waits for next message in generator
|
|
290
|
+
if (evType === "done") {
|
|
291
|
+
entry.turnEvents = [];
|
|
292
|
+
entry.pendingApprovalEvent = undefined;
|
|
293
|
+
setPhase(sessionId, "idle");
|
|
294
|
+
// Reset heartbeat tracking for next turn
|
|
295
|
+
firstEventReceived = false;
|
|
296
|
+
startTime = Date.now();
|
|
297
|
+
}
|
|
288
298
|
}
|
|
289
299
|
|
|
290
|
-
logSessionEvent(sessionId, "INFO", `
|
|
291
|
-
console.log(`[chat] session=${sessionId}
|
|
300
|
+
logSessionEvent(sessionId, "INFO", `Session consumer completed (${eventCount} events total)`);
|
|
301
|
+
console.log(`[chat] session=${sessionId} session consumer completed (${eventCount} events)`);
|
|
292
302
|
} catch (e) {
|
|
293
303
|
const errMsg = (e as Error).message;
|
|
294
304
|
logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
|
|
295
|
-
|
|
296
|
-
bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
|
|
297
|
-
}
|
|
305
|
+
bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
|
|
298
306
|
} finally {
|
|
299
307
|
if (heartbeat) clearInterval(heartbeat);
|
|
300
|
-
|
|
301
|
-
if (!doneEmitted) {
|
|
302
|
-
bufferAndBroadcast(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
|
|
303
|
-
}
|
|
304
|
-
// 2. Clear buffer BEFORE setting phase to idle
|
|
308
|
+
entry.isStreamingActive = false;
|
|
305
309
|
entry.turnEvents = [];
|
|
306
|
-
// 3. Transition to idle
|
|
307
310
|
setPhase(sessionId, "idle");
|
|
308
|
-
// 4. Cleanup
|
|
309
|
-
entry.abort = undefined;
|
|
310
311
|
entry.pendingApprovalEvent = undefined;
|
|
312
|
+
// Close streaming session in provider
|
|
313
|
+
const provider = providerRegistry.get(entry.providerId);
|
|
314
|
+
if (provider && "closeStreamingSession" in provider) {
|
|
315
|
+
(provider as any).closeStreamingSession(sessionId);
|
|
316
|
+
}
|
|
311
317
|
if (entry.clients.size === 0) {
|
|
312
318
|
startCleanupTimer(sessionId);
|
|
313
319
|
}
|
|
320
|
+
console.log(`[chat] session=${sessionId} consumer loop ended`);
|
|
314
321
|
}
|
|
315
322
|
}
|
|
316
323
|
|
|
@@ -386,6 +393,7 @@ export const chatWebSocket = {
|
|
|
386
393
|
pingIntervals: new Map(),
|
|
387
394
|
phase: "idle",
|
|
388
395
|
turnEvents: [],
|
|
396
|
+
isStreamingActive: false,
|
|
389
397
|
};
|
|
390
398
|
activeSessions.set(sessionId, newEntry);
|
|
391
399
|
setupClientPing(newEntry, ws);
|
|
@@ -435,7 +443,7 @@ export const chatWebSocket = {
|
|
|
435
443
|
if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
|
|
436
444
|
const newEntry: SessionEntry = {
|
|
437
445
|
providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
|
|
438
|
-
pingIntervals: new Map(), phase: "idle", turnEvents: [],
|
|
446
|
+
pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
|
|
439
447
|
};
|
|
440
448
|
activeSessions.set(sessionId, newEntry);
|
|
441
449
|
setupClientPing(newEntry, ws);
|
|
@@ -472,51 +480,74 @@ export const chatWebSocket = {
|
|
|
472
480
|
ws.send(JSON.stringify({ type: "error", message: "Message content is required" }));
|
|
473
481
|
return;
|
|
474
482
|
}
|
|
483
|
+
// Validate image payload
|
|
484
|
+
if (parsed.images?.length) {
|
|
485
|
+
if (parsed.images.length > 5) {
|
|
486
|
+
ws.send(JSON.stringify({ type: "error", message: "Max 5 images per message" }));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const MAX_BASE64_SIZE = 7_000_000; // ~5MB decoded
|
|
490
|
+
const SUPPORTED_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
491
|
+
for (const img of parsed.images) {
|
|
492
|
+
if (img.data.length > MAX_BASE64_SIZE) {
|
|
493
|
+
ws.send(JSON.stringify({ type: "error", message: "Image too large (max 5MB)" }));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (!SUPPORTED_TYPES.has(img.mediaType)) {
|
|
497
|
+
ws.send(JSON.stringify({ type: "error", message: `Unsupported image type: ${img.mediaType}` }));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
475
502
|
// Store permission mode — sticky for this session
|
|
476
503
|
if (parsed.permissionMode) {
|
|
477
504
|
entry.permissionMode = parsed.permissionMode;
|
|
478
505
|
}
|
|
479
506
|
|
|
480
|
-
// Resume session in provider (can be slow on first call — sdkListSessions)
|
|
481
507
|
const provider = providerRegistry.get(providerId);
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (
|
|
487
|
-
|
|
488
|
-
|
|
508
|
+
|
|
509
|
+
if (!entry.isStreamingActive) {
|
|
510
|
+
// First message or post-crash recovery: start persistent consumer
|
|
511
|
+
// Resume session in provider (can be slow on first call — sdkListSessions)
|
|
512
|
+
if (provider && "resumeSession" in provider) {
|
|
513
|
+
const t0 = Date.now();
|
|
514
|
+
await (provider as any).resumeSession(sessionId);
|
|
515
|
+
const elapsed = Date.now() - t0;
|
|
516
|
+
if (elapsed > 500) {
|
|
517
|
+
console.warn(`[chat] session=${sessionId} resumeSession took ${elapsed}ms`);
|
|
518
|
+
logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (entry.projectPath && provider && "ensureProjectPath" in provider) {
|
|
522
|
+
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
489
523
|
}
|
|
490
|
-
}
|
|
491
|
-
if (entry.projectPath && provider && "ensureProjectPath" in provider) {
|
|
492
|
-
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
493
|
-
}
|
|
494
524
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
entry.
|
|
499
|
-
|
|
500
|
-
|
|
525
|
+
entry.turnEvents = [];
|
|
526
|
+
setPhase(sessionId, "initializing");
|
|
527
|
+
|
|
528
|
+
const permMode = entry.permissionMode;
|
|
529
|
+
const msgImages = parsed.type === "message" ? parsed.images : undefined;
|
|
530
|
+
entry.streamPromise = new Promise<void>((resolve) => {
|
|
531
|
+
setTimeout(() => {
|
|
532
|
+
startSessionConsumer(sessionId, providerId, parsed.content, permMode, msgImages).then(resolve, resolve);
|
|
533
|
+
}, 0);
|
|
534
|
+
});
|
|
535
|
+
} else {
|
|
536
|
+
// Follow-up: push into existing generator via provider
|
|
537
|
+
if (provider && "pushMessage" in provider && parsed.type === "message") {
|
|
538
|
+
(provider as any).pushMessage(sessionId, parsed.content, {
|
|
539
|
+
priority: parsed.priority ?? 'next',
|
|
540
|
+
images: parsed.images,
|
|
541
|
+
});
|
|
501
542
|
}
|
|
502
|
-
//
|
|
503
|
-
entry =
|
|
504
|
-
|
|
543
|
+
// Clear turn events for new turn display + transition phase
|
|
544
|
+
entry.turnEvents = [];
|
|
545
|
+
entry.pendingApprovalEvent = undefined;
|
|
546
|
+
setPhase(sessionId, "thinking");
|
|
547
|
+
console.log(`[chat] session=${sessionId} follow-up pushed to generator`);
|
|
505
548
|
}
|
|
506
|
-
|
|
507
|
-
// Reset for new query
|
|
508
|
-
entry.turnEvents = [];
|
|
509
|
-
setPhase(sessionId, "initializing");
|
|
510
|
-
|
|
511
|
-
// Store promise reference on entry to prevent GC from collecting the async operation.
|
|
512
|
-
// Use setTimeout(0) to detach from WS handler's async scope.
|
|
513
|
-
const permMode = entry.permissionMode;
|
|
514
|
-
entry.streamPromise = new Promise<void>((resolve) => {
|
|
515
|
-
setTimeout(() => {
|
|
516
|
-
runStreamLoop(sessionId, providerId, parsed.content, permMode).then(resolve, resolve);
|
|
517
|
-
}, 0);
|
|
518
|
-
});
|
|
519
549
|
} else if (parsed.type === "cancel") {
|
|
550
|
+
// Interrupt current turn — session stays alive for next message
|
|
520
551
|
const provider = providerRegistry.get(providerId);
|
|
521
552
|
if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
|
|
522
553
|
(provider as any).abortQuery(sessionId);
|
|
@@ -543,7 +574,7 @@ export const chatWebSocket = {
|
|
|
543
574
|
evictClient(entry, ws);
|
|
544
575
|
console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
|
|
545
576
|
|
|
546
|
-
if (entry.clients.size === 0 && entry.
|
|
577
|
+
if (entry.clients.size === 0 && !entry.isStreamingActive) {
|
|
547
578
|
startCleanupTimer(sessionId);
|
|
548
579
|
}
|
|
549
580
|
},
|
package/src/types/api.ts
CHANGED
|
@@ -23,7 +23,7 @@ export type TerminalWsMessage =
|
|
|
23
23
|
|
|
24
24
|
/** WebSocket message types (chat) */
|
|
25
25
|
export type ChatWsClientMessage =
|
|
26
|
-
| { type: "message"; content: string; permissionMode?: string }
|
|
26
|
+
| { type: "message"; content: string; permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> }
|
|
27
27
|
| { type: "cancel" }
|
|
28
28
|
| { type: "approval_response"; requestId: string; approved: boolean; reason?: string; data?: unknown }
|
|
29
29
|
| { type: "ready" };
|
package/src/types/chat.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { useNotificationStore } from "@/stores/notification-store";
|
|
|
10
10
|
import { openBugReportPopup } from "@/lib/report-bug";
|
|
11
11
|
import { getAISettings } from "@/lib/api-settings";
|
|
12
12
|
import { MessageList } from "./message-list";
|
|
13
|
-
import { MessageInput, type ChatAttachment } from "./message-input";
|
|
13
|
+
import { MessageInput, type ChatAttachment, type MessagePriority } from "./message-input";
|
|
14
14
|
import { SlashCommandPicker, type SlashItem } from "./slash-command-picker";
|
|
15
15
|
import { FilePicker } from "./file-picker";
|
|
16
16
|
import { ChatHistoryBar } from "./chat-history-bar";
|
|
@@ -196,7 +196,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
196
196
|
);
|
|
197
197
|
|
|
198
198
|
const handleSend = useCallback(
|
|
199
|
-
async (content: string, attachments: ChatAttachment[] = []) => {
|
|
199
|
+
async (content: string, attachments: ChatAttachment[] = [], priority?: MessagePriority) => {
|
|
200
200
|
const fullContent = buildMessageWithAttachments(content, attachments);
|
|
201
201
|
if (!fullContent.trim()) return;
|
|
202
202
|
|
|
@@ -218,7 +218,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
218
218
|
return;
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
-
sendMessage(fullContent, { permissionMode });
|
|
221
|
+
sendMessage(fullContent, { permissionMode, priority });
|
|
222
222
|
},
|
|
223
223
|
[sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode],
|
|
224
224
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useRef, useCallback, useEffect, memo, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
|
|
2
|
-
import { ArrowUp, Square, Paperclip, Loader2 } from "lucide-react";
|
|
2
|
+
import { ArrowUp, Square, Paperclip, Loader2, Zap, ListOrdered, Clock } from "lucide-react";
|
|
3
3
|
import { api, projectUrl, getAuthToken } from "@/lib/api-client";
|
|
4
4
|
import { randomId } from "@/lib/utils";
|
|
5
5
|
import { isSupportedFile, isImageFile } from "@/lib/file-support";
|
|
@@ -20,8 +20,10 @@ export interface ChatAttachment {
|
|
|
20
20
|
status: "uploading" | "ready" | "error";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export type MessagePriority = 'now' | 'next' | 'later';
|
|
24
|
+
|
|
23
25
|
interface MessageInputProps {
|
|
24
|
-
onSend: (content: string, attachments: ChatAttachment[]) => void;
|
|
26
|
+
onSend: (content: string, attachments: ChatAttachment[], priority?: MessagePriority) => void;
|
|
25
27
|
isStreaming?: boolean;
|
|
26
28
|
onCancel?: () => void;
|
|
27
29
|
disabled?: boolean;
|
|
@@ -68,6 +70,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
68
70
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
|
69
71
|
const [modeSelectorOpen, setModeSelectorOpen] = useState(false);
|
|
70
72
|
const [pendingSend, setPendingSend] = useState(false);
|
|
73
|
+
const [priority, setPriority] = useState<MessagePriority>('next');
|
|
71
74
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
72
75
|
const mobileTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
73
76
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -286,7 +289,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
286
289
|
|
|
287
290
|
onSlashStateChange?.(false, "");
|
|
288
291
|
onFileStateChange?.(false, "");
|
|
289
|
-
onSend(trimmed, readyAttachments);
|
|
292
|
+
onSend(trimmed, readyAttachments, isStreaming ? priority : undefined);
|
|
290
293
|
setValue("");
|
|
291
294
|
// Revoke preview URLs
|
|
292
295
|
for (const att of attachments) {
|
|
@@ -294,9 +297,10 @@ export const MessageInput = memo(function MessageInput({
|
|
|
294
297
|
}
|
|
295
298
|
setAttachments([]);
|
|
296
299
|
setPendingSend(false);
|
|
300
|
+
setPriority('next');
|
|
297
301
|
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
|
298
302
|
if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
|
|
299
|
-
}, [value, attachments, onSend, onSlashStateChange, onFileStateChange]);
|
|
303
|
+
}, [value, attachments, onSend, onSlashStateChange, onFileStateChange, isStreaming, priority]);
|
|
300
304
|
|
|
301
305
|
const handleSend = useCallback(() => {
|
|
302
306
|
if (disabled) return;
|
|
@@ -464,6 +468,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
464
468
|
open={modeSelectorOpen}
|
|
465
469
|
onOpenChange={setModeSelectorOpen}
|
|
466
470
|
/>
|
|
471
|
+
{isStreaming && <PriorityToggle value={priority} onChange={setPriority} />}
|
|
467
472
|
</div>
|
|
468
473
|
{/* Mobile: single row — attach + textarea + send */}
|
|
469
474
|
<div className="flex items-end gap-1 md:hidden px-2 py-2">
|
|
@@ -548,6 +553,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
548
553
|
onOpenChange={setModeSelectorOpen}
|
|
549
554
|
/>
|
|
550
555
|
</div>
|
|
556
|
+
{isStreaming && <PriorityToggle value={priority} onChange={setPriority} />}
|
|
551
557
|
</div>
|
|
552
558
|
<div className="flex items-center gap-1">
|
|
553
559
|
{showCancel ? (
|
|
@@ -594,3 +600,34 @@ function ModeChip({ mode, onClick }: { mode: string; onClick: () => void }) {
|
|
|
594
600
|
</button>
|
|
595
601
|
);
|
|
596
602
|
}
|
|
603
|
+
|
|
604
|
+
const PRIORITY_OPTIONS: { value: MessagePriority; label: string; Icon: typeof Zap }[] = [
|
|
605
|
+
{ value: 'now', label: 'Interrupt', Icon: Zap },
|
|
606
|
+
{ value: 'next', label: 'Queue', Icon: ListOrdered },
|
|
607
|
+
{ value: 'later', label: 'Later', Icon: Clock },
|
|
608
|
+
];
|
|
609
|
+
|
|
610
|
+
/** Compact priority toggle — visible only during streaming */
|
|
611
|
+
function PriorityToggle({ value, onChange }: { value: MessagePriority; onChange: (v: MessagePriority) => void }) {
|
|
612
|
+
const cycle = useCallback(() => {
|
|
613
|
+
const order: MessagePriority[] = ['next', 'later', 'now'];
|
|
614
|
+
const idx = order.indexOf(value);
|
|
615
|
+
onChange(order[(idx + 1) % order.length]!);
|
|
616
|
+
}, [value, onChange]);
|
|
617
|
+
|
|
618
|
+
const current = PRIORITY_OPTIONS.find((o) => o.value === value) ?? PRIORITY_OPTIONS[1]!;
|
|
619
|
+
const Icon = current.Icon;
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<button
|
|
623
|
+
type="button"
|
|
624
|
+
onClick={(e) => { e.stopPropagation(); cycle(); }}
|
|
625
|
+
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] text-text-subtle hover:text-text-primary hover:bg-surface-elevated transition-colors border border-transparent hover:border-border"
|
|
626
|
+
aria-label={`Message priority: ${current.label}`}
|
|
627
|
+
title={`Priority: ${current.label} (click to cycle)`}
|
|
628
|
+
>
|
|
629
|
+
<Icon className="size-3" />
|
|
630
|
+
<span>{current.label}</span>
|
|
631
|
+
</button>
|
|
632
|
+
);
|
|
633
|
+
}
|
|
@@ -23,7 +23,7 @@ interface UseChatReturn {
|
|
|
23
23
|
pendingApproval: ApprovalRequest | null;
|
|
24
24
|
contextWindowPct: number | null;
|
|
25
25
|
sessionTitle: string | null;
|
|
26
|
-
sendMessage: (content: string, opts?: { permissionMode?: string }) => void;
|
|
26
|
+
sendMessage: (content: string, opts?: { permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> }) => void;
|
|
27
27
|
respondToApproval: (requestId: string, approved: boolean, data?: unknown) => void;
|
|
28
28
|
cancelStreaming: () => void;
|
|
29
29
|
reconnect: () => void;
|
|
@@ -375,11 +375,13 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
375
375
|
}, [sessionId, providerId, projectName]);
|
|
376
376
|
|
|
377
377
|
const sendMessage = useCallback(
|
|
378
|
-
(content: string, opts?: { permissionMode?: string }) => {
|
|
378
|
+
(content: string, opts?: { permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> }) => {
|
|
379
379
|
if (!content.trim()) return;
|
|
380
380
|
|
|
381
|
-
|
|
382
|
-
|
|
381
|
+
const isFollowUp = phaseRef.current !== "idle";
|
|
382
|
+
|
|
383
|
+
if (isFollowUp) {
|
|
384
|
+
// Streaming follow-up: finalize current assistant message, then send
|
|
383
385
|
const finalContent = streamingContentRef.current;
|
|
384
386
|
const finalEvents = [...streamingEventsRef.current];
|
|
385
387
|
setMessages((prev) => {
|
|
@@ -392,7 +394,6 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
392
394
|
}
|
|
393
395
|
return prev;
|
|
394
396
|
});
|
|
395
|
-
send(JSON.stringify({ type: "cancel" }));
|
|
396
397
|
}
|
|
397
398
|
|
|
398
399
|
// Add user message
|
|
@@ -406,15 +407,26 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
406
407
|
},
|
|
407
408
|
]);
|
|
408
409
|
|
|
409
|
-
// Reset streaming state
|
|
410
|
+
// Reset streaming state for new turn
|
|
410
411
|
streamingContentRef.current = "";
|
|
411
412
|
streamingEventsRef.current = [];
|
|
412
413
|
pendingMessageRef.current = null;
|
|
413
|
-
|
|
414
|
-
|
|
414
|
+
if (!isFollowUp) {
|
|
415
|
+
setPhase("initializing");
|
|
416
|
+
phaseRef.current = "initializing";
|
|
417
|
+
} else {
|
|
418
|
+
setPhase("thinking");
|
|
419
|
+
phaseRef.current = "thinking";
|
|
420
|
+
}
|
|
415
421
|
setPendingApproval(null);
|
|
416
422
|
|
|
417
|
-
send(JSON.stringify({
|
|
423
|
+
send(JSON.stringify({
|
|
424
|
+
type: "message",
|
|
425
|
+
content,
|
|
426
|
+
permissionMode: opts?.permissionMode,
|
|
427
|
+
priority: opts?.priority,
|
|
428
|
+
images: opts?.images,
|
|
429
|
+
}));
|
|
418
430
|
},
|
|
419
431
|
[send],
|
|
420
432
|
);
|