@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/web/assets/chat-tab-DxkvWelV.js +7 -0
  3. package/dist/web/assets/{code-editor-CQ7gq0Vj.js → code-editor-D3VJc1tY.js} +1 -1
  4. package/dist/web/assets/{database-viewer-B27aRtdQ.js → database-viewer-qlwORhh0.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-BjtTemkK.js → diff-viewer-D5vGZJnH.js} +1 -1
  6. package/dist/web/assets/{git-graph-BGXo0o-J.js → git-graph-B2fHtKEc.js} +1 -1
  7. package/dist/web/assets/{index-CfClIVo2.js → index-Ccq6zi2E.js} +3 -3
  8. package/dist/web/assets/keybindings-store-e3pqlQbf.js +1 -0
  9. package/dist/web/assets/{markdown-renderer-BtPXdzTv.js → markdown-renderer-DcGMlbRm.js} +1 -1
  10. package/dist/web/assets/{postgres-viewer-BMg-qFcO.js → postgres-viewer-CZzbMFtb.js} +1 -1
  11. package/dist/web/assets/{settings-tab-NPuwQHzs.js → settings-tab-BOmLAhkD.js} +1 -1
  12. package/dist/web/assets/{sqlite-viewer-CAsUczio.js → sqlite-viewer-CrrzHXqq.js} +1 -1
  13. package/dist/web/assets/{terminal-tab--Gw14HP3.js → terminal-tab-DHMITI3S.js} +2 -2
  14. package/dist/web/index.html +1 -1
  15. package/dist/web/sw.js +1 -1
  16. package/package.json +1 -1
  17. package/src/providers/claude-agent-sdk.ts +212 -76
  18. package/src/server/ws/chat.ts +103 -72
  19. package/src/types/api.ts +1 -1
  20. package/src/types/chat.ts +2 -0
  21. package/src/web/components/chat/chat-tab.tsx +3 -3
  22. package/src/web/components/chat/message-input.tsx +41 -4
  23. package/src/web/hooks/use-chat.ts +21 -9
  24. package/dist/web/assets/chat-tab-DmF14O6G.js +0 -7
  25. package/dist/web/assets/keybindings-store-nDbczFnq.js +0 -1
  26. package/snapshot-state.md +0 -1526
  27. package/test-tokens.mjs +0 -212
@@ -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
- * Standalone streaming loopdecoupled from WS message handler.
136
- * Runs independently so WS close does NOT kill the Claude query.
141
+ * Persistent event consumerruns 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 runStreamLoop(sessionId: string, providerId: string, content: string, permissionMode?: string): Promise<void> {
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} runStreamLoop: no entry — aborting`);
148
+ console.error(`[chat] session=${sessionId} startSessionConsumer: no entry — aborting`);
142
149
  return;
143
150
  }
144
- const streamStartMs = Date.now();
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
- const abortController = new AbortController();
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
- const startTime = Date.now();
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 || abortController.signal.aborted) {
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 (hook_started, init, etc.) → transition connecting → thinking
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; // Don't buffer or broadcast system events
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
- doneEmitted = true;
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
- // Fire-and-forget: fetch updated session title from SDK summary
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", `Stream completed (${eventCount} events)`);
291
- console.log(`[chat] session=${sessionId} stream completed (${eventCount} events)`);
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
- if (!abortController.signal.aborted) {
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
- // 1. Buffer and broadcast done event (skip if SDK already yielded one)
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
- if (provider && "resumeSession" in provider) {
483
- const t0 = Date.now();
484
- await (provider as any).resumeSession(sessionId);
485
- const elapsed = Date.now() - t0;
486
- if (elapsed > 500) {
487
- console.warn(`[chat] session=${sessionId} resumeSession took ${elapsed}ms`);
488
- logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
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
- // Abort-and-replace: if already streaming, abort current query and wait for cleanup
496
- if (entry.phase !== "idle" && entry.abort) {
497
- console.log(`[chat] session=${sessionId} aborting current query for new message`);
498
- entry.abort.abort();
499
- if (entry.streamPromise) {
500
- await entry.streamPromise;
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
- // Re-fetch entry after await may have been mutated during cleanup
503
- entry = activeSessions.get(sessionId)!;
504
- if (!entry) return;
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.phase === "idle") {
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
@@ -1,5 +1,7 @@
1
1
  export interface SendMessageOpts {
2
2
  permissionMode?: import("./config").PermissionMode | string;
3
+ priority?: 'now' | 'next' | 'later';
4
+ images?: Array<{ data: string; mediaType: string }>;
3
5
  }
4
6
 
5
7
  export interface AIProvider {
@@ -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
- // If streaming, cancel current stream first then send immediately
382
- if (phaseRef.current !== "idle") {
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
- setPhase("initializing");
414
- phaseRef.current = "initializing";
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({ type: "message", content, permissionMode: opts?.permissionMode }));
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
  );