@hienlh/ppm 0.9.0-beta.4 → 0.9.0-beta.5
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.
Potentially problematic release.
This version of @hienlh/ppm might be problematic. Click here for more details.
- package/CHANGELOG.md +21 -8
- package/dist/web/assets/browser-tab-CEYAwJUI.js +1 -0
- package/dist/web/assets/chat-tab-C8bt4tdL.js +7 -0
- package/dist/web/assets/{code-editor-BRMOypkX.js → code-editor-BVt8uVtX.js} +1 -1
- package/dist/web/assets/{database-viewer-CEoDpzPz.js → database-viewer-CNi9B6tN.js} +1 -1
- package/dist/web/assets/{diff-viewer-jDU2bcGj.js → diff-viewer-CnjvJhwy.js} +1 -1
- package/dist/web/assets/{git-graph-DMQzw4Sp.js → git-graph-BNbraUOe.js} +1 -1
- package/dist/web/assets/{index-QiSWS6f-.js → index-CKvJPAPG.js} +3 -3
- package/dist/web/assets/keybindings-store-Cmf2viOa.js +1 -0
- package/dist/web/assets/{markdown-renderer-BCjJbGP8.js → markdown-renderer-CFW_zoSR.js} +1 -1
- package/dist/web/assets/{postgres-viewer-s0snZ9CL.js → postgres-viewer-Dg23WW__.js} +1 -1
- package/dist/web/assets/{settings-tab-2YkgmrY0.js → settings-tab-rB971cXs.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-B5GNwXaG.js → sqlite-viewer-D3N938wy.js} +1 -1
- package/dist/web/assets/{terminal-tab-MRg8y1xF.js → terminal-tab-BdHlIi8e.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 -73
- 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/browser-tab-BhTdeeZd.js +0 -1
- package/dist/web/assets/chat-tab-ZiiUVOxM.js +0 -7
- package/dist/web/assets/keybindings-store-BplH-yiN.js +0 -1
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,28 +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
|
|
139
|
-
let sessionId = initialSessionId;
|
|
145
|
+
async function startSessionConsumer(sessionId: string, providerId: string, content: string, permissionMode?: string, images?: Array<{ data: string; mediaType: string }>): Promise<void> {
|
|
140
146
|
const entry = activeSessions.get(sessionId);
|
|
141
147
|
if (!entry) {
|
|
142
|
-
console.error(`[chat] session=${sessionId}
|
|
148
|
+
console.error(`[chat] session=${sessionId} startSessionConsumer: no entry — aborting`);
|
|
143
149
|
return;
|
|
144
150
|
}
|
|
145
|
-
|
|
146
|
-
console.log(`[chat] session=${sessionId} runStreamLoop started (clients=${entry.clients.size})`);
|
|
151
|
+
console.log(`[chat] session=${sessionId} startSessionConsumer started (clients=${entry.clients.size})`);
|
|
147
152
|
|
|
148
|
-
|
|
149
|
-
entry.abort = abortController;
|
|
153
|
+
entry.isStreamingActive = true;
|
|
150
154
|
entry.pendingApprovalEvent = undefined;
|
|
151
155
|
entry.turnEvents = [];
|
|
152
156
|
setPhase(sessionId, "connecting");
|
|
153
157
|
|
|
154
158
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
155
159
|
let lastContextWindowPct: number | undefined;
|
|
156
|
-
let doneEmitted = false;
|
|
157
160
|
|
|
158
161
|
try {
|
|
159
162
|
const userPreview = content.slice(0, 200);
|
|
@@ -162,12 +165,12 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
|
|
|
162
165
|
|
|
163
166
|
let eventCount = 0;
|
|
164
167
|
let firstEventReceived = false;
|
|
165
|
-
|
|
168
|
+
let startTime = Date.now();
|
|
166
169
|
|
|
167
170
|
// Heartbeat: while waiting for first response, send elapsed time every 5s
|
|
168
171
|
const CONNECTION_TIMEOUT_S = 120;
|
|
169
172
|
heartbeat = setInterval(() => {
|
|
170
|
-
if (firstEventReceived
|
|
173
|
+
if (firstEventReceived) {
|
|
171
174
|
clearInterval(heartbeat);
|
|
172
175
|
return;
|
|
173
176
|
}
|
|
@@ -186,15 +189,12 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
|
|
|
186
189
|
type: "error",
|
|
187
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\``,
|
|
188
191
|
});
|
|
189
|
-
abortController.abort();
|
|
190
192
|
return;
|
|
191
193
|
}
|
|
192
|
-
// Heartbeat uses broadcast() directly — NOT setPhase() (same-phase guard would skip elapsed updates)
|
|
193
194
|
broadcast(sessionId, { type: "phase_changed", phase: "connecting", elapsed });
|
|
194
195
|
}, 5_000);
|
|
195
196
|
|
|
196
|
-
for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode })) {
|
|
197
|
-
if (abortController.signal.aborted) break;
|
|
197
|
+
for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode, images })) {
|
|
198
198
|
eventCount++;
|
|
199
199
|
const ev = event as any;
|
|
200
200
|
const evType = ev.type ?? "unknown";
|
|
@@ -216,14 +216,13 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
|
|
|
216
216
|
continue;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
// System events
|
|
220
|
-
// These indicate SDK has connected and is processing, but no content yet.
|
|
219
|
+
// System events → transition connecting → thinking
|
|
221
220
|
if (evType === "system") {
|
|
222
221
|
if (!firstEventReceived) {
|
|
223
222
|
if (heartbeat) clearInterval(heartbeat);
|
|
224
223
|
setPhase(sessionId, "thinking");
|
|
225
224
|
}
|
|
226
|
-
continue;
|
|
225
|
+
continue;
|
|
227
226
|
}
|
|
228
227
|
|
|
229
228
|
// First content event — stop heartbeat, transition phase
|
|
@@ -256,10 +255,11 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
|
|
|
256
255
|
console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
|
|
257
256
|
logSessionEvent(sessionId, "ERROR", errorDetail);
|
|
258
257
|
} else if (evType === "done") {
|
|
259
|
-
|
|
258
|
+
// Turn complete — transition to idle, clear buffer for next turn
|
|
260
259
|
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
|
|
261
260
|
if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
|
|
262
|
-
|
|
261
|
+
|
|
262
|
+
// Fire-and-forget: title + notification
|
|
263
263
|
sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
|
|
264
264
|
const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
|
|
265
265
|
const title = found?.customTitle ?? found?.summary;
|
|
@@ -269,7 +269,6 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
|
|
|
269
269
|
if (session) session.title = title;
|
|
270
270
|
}
|
|
271
271
|
}).catch(() => {});
|
|
272
|
-
// Fire-and-forget notification broadcast (push + telegram)
|
|
273
272
|
import("../../services/notification.service.ts").then(({ notificationService }) => {
|
|
274
273
|
const project = entry.projectName || "Project";
|
|
275
274
|
const session = chatService.getSession(sessionId);
|
|
@@ -284,7 +283,6 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
|
|
|
284
283
|
}).catch(() => {});
|
|
285
284
|
} else if (evType === "approval_request") {
|
|
286
285
|
entry.pendingApprovalEvent = ev;
|
|
287
|
-
// Fire-and-forget notification for approval/question
|
|
288
286
|
import("../../services/notification.service.ts").then(({ notificationService }) => {
|
|
289
287
|
const project = entry.projectName || "Project";
|
|
290
288
|
const session = chatService.getSession(sessionId);
|
|
@@ -303,32 +301,40 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
|
|
|
303
301
|
|
|
304
302
|
// Buffer + broadcast content events
|
|
305
303
|
bufferAndBroadcast(sessionId, event);
|
|
304
|
+
|
|
305
|
+
// After "done", transition to idle + clear turn buffer for next turn
|
|
306
|
+
// Consumer loop continues — query waits for next message in generator
|
|
307
|
+
if (evType === "done") {
|
|
308
|
+
entry.turnEvents = [];
|
|
309
|
+
entry.pendingApprovalEvent = undefined;
|
|
310
|
+
setPhase(sessionId, "idle");
|
|
311
|
+
// Reset heartbeat tracking for next turn
|
|
312
|
+
firstEventReceived = false;
|
|
313
|
+
startTime = Date.now();
|
|
314
|
+
}
|
|
306
315
|
}
|
|
307
316
|
|
|
308
|
-
logSessionEvent(sessionId, "INFO", `
|
|
309
|
-
console.log(`[chat] session=${sessionId}
|
|
317
|
+
logSessionEvent(sessionId, "INFO", `Session consumer completed (${eventCount} events total)`);
|
|
318
|
+
console.log(`[chat] session=${sessionId} session consumer completed (${eventCount} events)`);
|
|
310
319
|
} catch (e) {
|
|
311
320
|
const errMsg = (e as Error).message;
|
|
312
321
|
logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
|
|
313
|
-
|
|
314
|
-
bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
|
|
315
|
-
}
|
|
322
|
+
bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
|
|
316
323
|
} finally {
|
|
317
324
|
if (heartbeat) clearInterval(heartbeat);
|
|
318
|
-
|
|
319
|
-
if (!doneEmitted) {
|
|
320
|
-
bufferAndBroadcast(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
|
|
321
|
-
}
|
|
322
|
-
// 2. Clear buffer BEFORE setting phase to idle
|
|
325
|
+
entry.isStreamingActive = false;
|
|
323
326
|
entry.turnEvents = [];
|
|
324
|
-
// 3. Transition to idle
|
|
325
327
|
setPhase(sessionId, "idle");
|
|
326
|
-
// 4. Cleanup
|
|
327
|
-
entry.abort = undefined;
|
|
328
328
|
entry.pendingApprovalEvent = undefined;
|
|
329
|
+
// Close streaming session in provider
|
|
330
|
+
const provider = providerRegistry.get(entry.providerId);
|
|
331
|
+
if (provider && "closeStreamingSession" in provider) {
|
|
332
|
+
(provider as any).closeStreamingSession(sessionId);
|
|
333
|
+
}
|
|
329
334
|
if (entry.clients.size === 0) {
|
|
330
335
|
startCleanupTimer(sessionId);
|
|
331
336
|
}
|
|
337
|
+
console.log(`[chat] session=${sessionId} consumer loop ended`);
|
|
332
338
|
}
|
|
333
339
|
}
|
|
334
340
|
|
|
@@ -404,6 +410,7 @@ export const chatWebSocket = {
|
|
|
404
410
|
pingIntervals: new Map(),
|
|
405
411
|
phase: "idle",
|
|
406
412
|
turnEvents: [],
|
|
413
|
+
isStreamingActive: false,
|
|
407
414
|
};
|
|
408
415
|
activeSessions.set(sessionId, newEntry);
|
|
409
416
|
setupClientPing(newEntry, ws);
|
|
@@ -453,7 +460,7 @@ export const chatWebSocket = {
|
|
|
453
460
|
if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
|
|
454
461
|
const newEntry: SessionEntry = {
|
|
455
462
|
providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
|
|
456
|
-
pingIntervals: new Map(), phase: "idle", turnEvents: [],
|
|
463
|
+
pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
|
|
457
464
|
};
|
|
458
465
|
activeSessions.set(sessionId, newEntry);
|
|
459
466
|
setupClientPing(newEntry, ws);
|
|
@@ -490,51 +497,74 @@ export const chatWebSocket = {
|
|
|
490
497
|
ws.send(JSON.stringify({ type: "error", message: "Message content is required" }));
|
|
491
498
|
return;
|
|
492
499
|
}
|
|
500
|
+
// Validate image payload
|
|
501
|
+
if (parsed.images?.length) {
|
|
502
|
+
if (parsed.images.length > 5) {
|
|
503
|
+
ws.send(JSON.stringify({ type: "error", message: "Max 5 images per message" }));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const MAX_BASE64_SIZE = 7_000_000; // ~5MB decoded
|
|
507
|
+
const SUPPORTED_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
508
|
+
for (const img of parsed.images) {
|
|
509
|
+
if (img.data.length > MAX_BASE64_SIZE) {
|
|
510
|
+
ws.send(JSON.stringify({ type: "error", message: "Image too large (max 5MB)" }));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (!SUPPORTED_TYPES.has(img.mediaType)) {
|
|
514
|
+
ws.send(JSON.stringify({ type: "error", message: `Unsupported image type: ${img.mediaType}` }));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
493
519
|
// Store permission mode — sticky for this session
|
|
494
520
|
if (parsed.permissionMode) {
|
|
495
521
|
entry.permissionMode = parsed.permissionMode;
|
|
496
522
|
}
|
|
497
523
|
|
|
498
|
-
// Resume session in provider (can be slow on first call — sdkListSessions)
|
|
499
524
|
const provider = providerRegistry.get(providerId);
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
if (
|
|
505
|
-
|
|
506
|
-
|
|
525
|
+
|
|
526
|
+
if (!entry.isStreamingActive) {
|
|
527
|
+
// First message or post-crash recovery: start persistent consumer
|
|
528
|
+
// Resume session in provider (can be slow on first call — sdkListSessions)
|
|
529
|
+
if (provider && "resumeSession" in provider) {
|
|
530
|
+
const t0 = Date.now();
|
|
531
|
+
await (provider as any).resumeSession(sessionId);
|
|
532
|
+
const elapsed = Date.now() - t0;
|
|
533
|
+
if (elapsed > 500) {
|
|
534
|
+
console.warn(`[chat] session=${sessionId} resumeSession took ${elapsed}ms`);
|
|
535
|
+
logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (entry.projectPath && provider && "ensureProjectPath" in provider) {
|
|
539
|
+
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
507
540
|
}
|
|
508
|
-
}
|
|
509
|
-
if (entry.projectPath && provider?.ensureProjectPath) {
|
|
510
|
-
provider.ensureProjectPath(sessionId, entry.projectPath);
|
|
511
|
-
}
|
|
512
541
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
entry.
|
|
517
|
-
|
|
518
|
-
|
|
542
|
+
entry.turnEvents = [];
|
|
543
|
+
setPhase(sessionId, "initializing");
|
|
544
|
+
|
|
545
|
+
const permMode = entry.permissionMode;
|
|
546
|
+
const msgImages = parsed.type === "message" ? parsed.images : undefined;
|
|
547
|
+
entry.streamPromise = new Promise<void>((resolve) => {
|
|
548
|
+
setTimeout(() => {
|
|
549
|
+
startSessionConsumer(sessionId, providerId, parsed.content, permMode, msgImages).then(resolve, resolve);
|
|
550
|
+
}, 0);
|
|
551
|
+
});
|
|
552
|
+
} else {
|
|
553
|
+
// Follow-up: push into existing generator via provider
|
|
554
|
+
if (provider && "pushMessage" in provider && parsed.type === "message") {
|
|
555
|
+
(provider as any).pushMessage(sessionId, parsed.content, {
|
|
556
|
+
priority: parsed.priority ?? 'next',
|
|
557
|
+
images: parsed.images,
|
|
558
|
+
});
|
|
519
559
|
}
|
|
520
|
-
//
|
|
521
|
-
entry =
|
|
522
|
-
|
|
560
|
+
// Clear turn events for new turn display + transition phase
|
|
561
|
+
entry.turnEvents = [];
|
|
562
|
+
entry.pendingApprovalEvent = undefined;
|
|
563
|
+
setPhase(sessionId, "thinking");
|
|
564
|
+
console.log(`[chat] session=${sessionId} follow-up pushed to generator`);
|
|
523
565
|
}
|
|
524
|
-
|
|
525
|
-
// Reset for new query
|
|
526
|
-
entry.turnEvents = [];
|
|
527
|
-
setPhase(sessionId, "initializing");
|
|
528
|
-
|
|
529
|
-
// Store promise reference on entry to prevent GC from collecting the async operation.
|
|
530
|
-
// Use setTimeout(0) to detach from WS handler's async scope.
|
|
531
|
-
const permMode = entry.permissionMode;
|
|
532
|
-
entry.streamPromise = new Promise<void>((resolve) => {
|
|
533
|
-
setTimeout(() => {
|
|
534
|
-
runStreamLoop(sessionId, providerId, parsed.content, permMode).then(resolve, resolve);
|
|
535
|
-
}, 0);
|
|
536
|
-
});
|
|
537
566
|
} else if (parsed.type === "cancel") {
|
|
567
|
+
// Interrupt current turn — session stays alive for next message
|
|
538
568
|
const provider = providerRegistry.get(providerId);
|
|
539
569
|
provider?.abortQuery?.(sessionId);
|
|
540
570
|
} else if (parsed.type === "approval_response") {
|
|
@@ -559,7 +589,7 @@ export const chatWebSocket = {
|
|
|
559
589
|
evictClient(entry, ws);
|
|
560
590
|
console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
|
|
561
591
|
|
|
562
|
-
if (entry.clients.size === 0 && entry.
|
|
592
|
+
if (entry.clients.size === 0 && !entry.isStreamingActive) {
|
|
563
593
|
startCleanupTimer(sessionId);
|
|
564
594
|
}
|
|
565
595
|
},
|
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";
|
|
@@ -205,7 +205,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
205
205
|
);
|
|
206
206
|
|
|
207
207
|
const handleSend = useCallback(
|
|
208
|
-
async (content: string, attachments: ChatAttachment[] = []) => {
|
|
208
|
+
async (content: string, attachments: ChatAttachment[] = [], priority?: MessagePriority) => {
|
|
209
209
|
const fullContent = buildMessageWithAttachments(content, attachments);
|
|
210
210
|
if (!fullContent.trim()) return;
|
|
211
211
|
|
|
@@ -227,7 +227,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
227
227
|
return;
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
-
sendMessage(fullContent, { permissionMode });
|
|
230
|
+
sendMessage(fullContent, { permissionMode, priority });
|
|
231
231
|
},
|
|
232
232
|
[sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode],
|
|
233
233
|
);
|
|
@@ -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, Mic, MicOff } from "lucide-react";
|
|
2
|
+
import { ArrowUp, Square, Paperclip, Loader2, Mic, MicOff, Zap, ListOrdered, Clock } from "lucide-react";
|
|
3
3
|
import { useVoiceInput } from "@/hooks/use-voice-input";
|
|
4
4
|
import { api, projectUrl, getAuthToken } from "@/lib/api-client";
|
|
5
5
|
import { randomId } from "@/lib/utils";
|
|
@@ -22,8 +22,10 @@ export interface ChatAttachment {
|
|
|
22
22
|
status: "uploading" | "ready" | "error";
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export type MessagePriority = 'now' | 'next' | 'later';
|
|
26
|
+
|
|
25
27
|
interface MessageInputProps {
|
|
26
|
-
onSend: (content: string, attachments: ChatAttachment[]) => void;
|
|
28
|
+
onSend: (content: string, attachments: ChatAttachment[], priority?: MessagePriority) => void;
|
|
27
29
|
isStreaming?: boolean;
|
|
28
30
|
onCancel?: () => void;
|
|
29
31
|
disabled?: boolean;
|
|
@@ -76,6 +78,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
76
78
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
|
77
79
|
const [modeSelectorOpen, setModeSelectorOpen] = useState(false);
|
|
78
80
|
const [pendingSend, setPendingSend] = useState(false);
|
|
81
|
+
const [priority, setPriority] = useState<MessagePriority>('next');
|
|
79
82
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
80
83
|
const mobileTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
81
84
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -329,7 +332,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
329
332
|
|
|
330
333
|
onSlashStateChange?.(false, "");
|
|
331
334
|
onFileStateChange?.(false, "");
|
|
332
|
-
onSend(trimmed, readyAttachments);
|
|
335
|
+
onSend(trimmed, readyAttachments, isStreaming ? priority : undefined);
|
|
333
336
|
setValue("");
|
|
334
337
|
// Revoke preview URLs
|
|
335
338
|
for (const att of attachments) {
|
|
@@ -337,9 +340,10 @@ export const MessageInput = memo(function MessageInput({
|
|
|
337
340
|
}
|
|
338
341
|
setAttachments([]);
|
|
339
342
|
setPendingSend(false);
|
|
343
|
+
setPriority('next');
|
|
340
344
|
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
|
341
345
|
if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
|
|
342
|
-
}, [value, attachments, onSend, onSlashStateChange, onFileStateChange]);
|
|
346
|
+
}, [value, attachments, onSend, onSlashStateChange, onFileStateChange, isStreaming, priority]);
|
|
343
347
|
|
|
344
348
|
const handleSend = useCallback(() => {
|
|
345
349
|
if (disabled) return;
|
|
@@ -514,6 +518,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
514
518
|
projectName={projectName}
|
|
515
519
|
/>
|
|
516
520
|
)}
|
|
521
|
+
{isStreaming && <PriorityToggle value={priority} onChange={setPriority} />}
|
|
517
522
|
</div>
|
|
518
523
|
{/* Mobile: single row — attach + mic + textarea + send */}
|
|
519
524
|
<div className="flex items-end gap-1 md:hidden px-2 py-2">
|
|
@@ -636,6 +641,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
636
641
|
projectName={projectName}
|
|
637
642
|
/>
|
|
638
643
|
)}
|
|
644
|
+
{isStreaming && <PriorityToggle value={priority} onChange={setPriority} />}
|
|
639
645
|
</div>
|
|
640
646
|
<div className="flex items-center gap-1">
|
|
641
647
|
{showCancel ? (
|
|
@@ -682,3 +688,34 @@ function ModeChip({ mode, onClick }: { mode: string; onClick: () => void }) {
|
|
|
682
688
|
</button>
|
|
683
689
|
);
|
|
684
690
|
}
|
|
691
|
+
|
|
692
|
+
const PRIORITY_OPTIONS: { value: MessagePriority; label: string; Icon: typeof Zap }[] = [
|
|
693
|
+
{ value: 'now', label: 'Interrupt', Icon: Zap },
|
|
694
|
+
{ value: 'next', label: 'Queue', Icon: ListOrdered },
|
|
695
|
+
{ value: 'later', label: 'Later', Icon: Clock },
|
|
696
|
+
];
|
|
697
|
+
|
|
698
|
+
/** Compact priority toggle — visible only during streaming */
|
|
699
|
+
function PriorityToggle({ value, onChange }: { value: MessagePriority; onChange: (v: MessagePriority) => void }) {
|
|
700
|
+
const cycle = useCallback(() => {
|
|
701
|
+
const order: MessagePriority[] = ['next', 'later', 'now'];
|
|
702
|
+
const idx = order.indexOf(value);
|
|
703
|
+
onChange(order[(idx + 1) % order.length]!);
|
|
704
|
+
}, [value, onChange]);
|
|
705
|
+
|
|
706
|
+
const current = PRIORITY_OPTIONS.find((o) => o.value === value) ?? PRIORITY_OPTIONS[1]!;
|
|
707
|
+
const Icon = current.Icon;
|
|
708
|
+
|
|
709
|
+
return (
|
|
710
|
+
<button
|
|
711
|
+
type="button"
|
|
712
|
+
onClick={(e) => { e.stopPropagation(); cycle(); }}
|
|
713
|
+
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"
|
|
714
|
+
aria-label={`Message priority: ${current.label}`}
|
|
715
|
+
title={`Priority: ${current.label} (click to cycle)`}
|
|
716
|
+
>
|
|
717
|
+
<Icon className="size-3" />
|
|
718
|
+
<span>{current.label}</span>
|
|
719
|
+
</button>
|
|
720
|
+
);
|
|
721
|
+
}
|
|
@@ -25,7 +25,7 @@ interface UseChatReturn {
|
|
|
25
25
|
sessionTitle: string | null;
|
|
26
26
|
/** When CLI provider assigns a different session ID, this holds the new ID */
|
|
27
27
|
migratedSessionId: string | null;
|
|
28
|
-
sendMessage: (content: string, opts?: { permissionMode?: string }) => void;
|
|
28
|
+
sendMessage: (content: string, opts?: { permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> }) => void;
|
|
29
29
|
respondToApproval: (requestId: string, approved: boolean, data?: unknown) => void;
|
|
30
30
|
cancelStreaming: () => void;
|
|
31
31
|
reconnect: () => void;
|
|
@@ -385,11 +385,13 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
385
385
|
}, [sessionId, providerId, projectName]);
|
|
386
386
|
|
|
387
387
|
const sendMessage = useCallback(
|
|
388
|
-
(content: string, opts?: { permissionMode?: string }) => {
|
|
388
|
+
(content: string, opts?: { permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> }) => {
|
|
389
389
|
if (!content.trim()) return;
|
|
390
390
|
|
|
391
|
-
|
|
392
|
-
|
|
391
|
+
const isFollowUp = phaseRef.current !== "idle";
|
|
392
|
+
|
|
393
|
+
if (isFollowUp) {
|
|
394
|
+
// Streaming follow-up: finalize current assistant message, then send
|
|
393
395
|
const finalContent = streamingContentRef.current;
|
|
394
396
|
const finalEvents = [...streamingEventsRef.current];
|
|
395
397
|
setMessages((prev) => {
|
|
@@ -402,7 +404,6 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
402
404
|
}
|
|
403
405
|
return prev;
|
|
404
406
|
});
|
|
405
|
-
send(JSON.stringify({ type: "cancel" }));
|
|
406
407
|
}
|
|
407
408
|
|
|
408
409
|
// Add user message
|
|
@@ -416,15 +417,26 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
416
417
|
},
|
|
417
418
|
]);
|
|
418
419
|
|
|
419
|
-
// Reset streaming state
|
|
420
|
+
// Reset streaming state for new turn
|
|
420
421
|
streamingContentRef.current = "";
|
|
421
422
|
streamingEventsRef.current = [];
|
|
422
423
|
pendingMessageRef.current = null;
|
|
423
|
-
|
|
424
|
-
|
|
424
|
+
if (!isFollowUp) {
|
|
425
|
+
setPhase("initializing");
|
|
426
|
+
phaseRef.current = "initializing";
|
|
427
|
+
} else {
|
|
428
|
+
setPhase("thinking");
|
|
429
|
+
phaseRef.current = "thinking";
|
|
430
|
+
}
|
|
425
431
|
setPendingApproval(null);
|
|
426
432
|
|
|
427
|
-
send(JSON.stringify({
|
|
433
|
+
send(JSON.stringify({
|
|
434
|
+
type: "message",
|
|
435
|
+
content,
|
|
436
|
+
permissionMode: opts?.permissionMode,
|
|
437
|
+
priority: opts?.priority,
|
|
438
|
+
images: opts?.images,
|
|
439
|
+
}));
|
|
428
440
|
},
|
|
429
441
|
[send],
|
|
430
442
|
);
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{o as e}from"./chunk-CFjPhJqf.js";import{t}from"./react-nm2Ru1Pt.js";import{t as n}from"./jsx-runtime-BRW_vwa9.js";import{t as r}from"./arrow-left-C_j9Ki73.js";import{o as i,t as a}from"./tab-store-DSz5PQI0.js";import{$ as o,A as s,L as c}from"./index-QiSWS6f-.js";var l=e(t(),1),u=n();function d(e){let t=e.trim();if(!t)return null;if(/^\d+$/.test(t))return`http://localhost:${t}`;/^localhost(:\d+)?/.test(t)&&(t=`http://${t}`),/^[\w.-]+:\d+/.test(t)&&!t.includes(`://`)&&(t=`http://${t}`),t.includes(`://`)||(t=t.includes(`localhost`)?`http://${t}`:`https://${t}`);try{return new URL(t),t}catch{return null}}function f(e){try{let t=new URL(e);return t.hostname===`localhost`||t.hostname===`127.0.0.1`||t.hostname===`0.0.0.0`||t.hostname===`::1`}catch{return!1}}function p(e){if(!f(e))return e;try{let t=new URL(e);return`/api/preview/${t.port||`80`}${t.pathname+t.search+t.hash}`}catch{return e}}function m({metadata:e,tabId:t}){let n=e?.url||`http://localhost:3000`,[m,h]=(0,l.useState)(n),[g,_]=(0,l.useState)(n),[v,y]=(0,l.useState)(p(n)),[b,x]=(0,l.useState)(!1),[S,C]=(0,l.useState)(!1),[w,T]=(0,l.useState)(!0),[E,D]=(0,l.useState)(null),O=(0,l.useRef)(null),k=a(e=>e.updateTab),A=(0,l.useRef)([n]),j=(0,l.useRef)(0),M=(0,l.useCallback)((e,n=!0)=>{let r=d(e);if(!r){D(`Invalid URL`);return}if(D(null),_(r),h(r),y(p(r)),T(!0),n){let e=A.current,t=j.current;A.current=e.slice(0,t+1),A.current.push(r),j.current=A.current.length-1}if(x(j.current>0),C(j.current<A.current.length-1),t)try{let e=new URL(r);k(t,{title:f(r)?`localhost:${e.port||`80`}`:e.hostname})}catch{}},[t,k]),N=(0,l.useCallback)(()=>{j.current>0&&(j.current--,M(A.current[j.current],!1))},[M]),P=(0,l.useCallback)(()=>{j.current<A.current.length-1&&(j.current++,M(A.current[j.current],!1))},[M]),F=(0,l.useCallback)(()=>{if(T(!0),D(null),O.current){let e=O.current.src;O.current.src=``,requestAnimationFrame(()=>{O.current&&(O.current.src=e)})}},[]),I=(0,l.useCallback)(()=>{window.open(g,`_blank`)},[g]);return(0,l.useEffect)(()=>{let t=e?.url;t&&t!==g&&M(t)},[e?.url]),(0,u.jsxs)(`div`,{className:`flex flex-col h-full w-full bg-background`,children:[(0,u.jsxs)(`div`,{className:`flex items-center gap-1 px-2 py-1.5 border-b border-border bg-surface shrink-0`,children:[(0,u.jsx)(`button`,{onClick:N,disabled:!b,className:`p-1.5 rounded hover:bg-surface-elevated disabled:opacity-30 transition-colors`,title:`Back`,children:(0,u.jsx)(r,{className:`size-4`})}),(0,u.jsx)(`button`,{onClick:P,disabled:!S,className:`p-1.5 rounded hover:bg-surface-elevated disabled:opacity-30 transition-colors`,title:`Forward`,children:(0,u.jsx)(o,{className:`size-4`})}),(0,u.jsx)(`button`,{onClick:F,className:`p-1.5 rounded hover:bg-surface-elevated transition-colors`,title:`Reload`,children:(0,u.jsx)(s,{className:`size-4 ${w?`animate-spin`:``}`})}),(0,u.jsxs)(`div`,{className:`flex-1 flex items-center gap-2 mx-1 px-2.5 py-1.5 rounded-md bg-background border border-border focus-within:border-accent/50 transition-colors`,children:[(0,u.jsx)(c,{className:`size-3.5 text-text-subtle shrink-0`}),(0,u.jsx)(`input`,{type:`text`,value:m,onChange:e=>h(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),M(m))},placeholder:`Enter URL or port (e.g. 3000, localhost:8080)`,className:`flex-1 bg-transparent text-xs text-text-primary outline-none placeholder:text-text-subtle min-w-0`})]}),(0,u.jsx)(`button`,{onClick:I,className:`p-1.5 rounded hover:bg-surface-elevated transition-colors`,title:`Open in browser`,children:(0,u.jsx)(i,{className:`size-4`})})]}),(0,u.jsxs)(`div`,{className:`flex-1 relative min-h-0`,children:[E?(0,u.jsx)(`div`,{className:`flex items-center justify-center h-full text-text-secondary text-sm`,children:(0,u.jsx)(`p`,{children:E})}):(0,u.jsx)(`iframe`,{ref:O,src:v,className:`w-full h-full border-0`,sandbox:`allow-scripts allow-forms allow-same-origin allow-popups allow-modals`,onLoad:()=>T(!1),onError:()=>{T(!1),D(`Failed to load ${g}`)}}),w&&!E&&(0,u.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center bg-background/50`,children:(0,u.jsxs)(`div`,{className:`flex items-center gap-2 text-sm text-text-secondary`,children:[(0,u.jsx)(s,{className:`size-4 animate-spin`}),(0,u.jsx)(`span`,{children:`Loading...`})]})})]})]})}export{m as BrowserTab};
|