@hienlh/ppm 0.13.51 → 0.13.53

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 (41) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{audio-preview-BQWWkcm0.js → audio-preview-DzlMrjXC.js} +1 -1
  5. package/dist/web/assets/chat-tab-BpP-9jSF.js +16 -0
  6. package/dist/web/assets/{code-editor-DChspYxh.js → code-editor-DUrnjDXe.js} +2 -2
  7. package/dist/web/assets/{conflict-editor-C1dZ8tsU.js → conflict-editor-DsL9J6Ao.js} +1 -1
  8. package/dist/web/assets/{database-viewer-2KW73oEG.js → database-viewer-BzZ_B08X.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-BMla30lw.js → diff-viewer-BTtOJWuk.js} +1 -1
  10. package/dist/web/assets/{extension-webview-DpaCcCXq.js → extension-webview-SqqLBksj.js} +1 -1
  11. package/dist/web/assets/{glide-data-grid-D08bJMrD.js → glide-data-grid-YCbGSPc8.js} +1 -1
  12. package/dist/web/assets/{image-preview-DVQkHkSo.js → image-preview-CVERB6Hn.js} +1 -1
  13. package/dist/web/assets/{index-U7GK_3ED.js → index-D3gMHLKc.js} +3 -3
  14. package/dist/web/assets/keybindings-store-BY4JJMPB.js +1 -0
  15. package/dist/web/assets/{markdown-renderer-B4_rx1aO.js → markdown-renderer-F5aFyJ-g.js} +1 -1
  16. package/dist/web/assets/notification-store-CmFhp1D7.js +1 -0
  17. package/dist/web/assets/{pdf-preview-D16LLBdq.js → pdf-preview-RgwYR2Lj.js} +1 -1
  18. package/dist/web/assets/{port-forwarding-tab-CoKoquJZ.js → port-forwarding-tab-CRBTvvYM.js} +1 -1
  19. package/dist/web/assets/{postgres-viewer-B45DfArf.js → postgres-viewer-Dq2nI9jE.js} +1 -1
  20. package/dist/web/assets/{settings-tab-Dc8TBxJQ.js → settings-tab-BrtuPA9W.js} +1 -1
  21. package/dist/web/assets/{sql-query-editor-DMk0nmOO.js → sql-query-editor-CL6O_4eW.js} +1 -1
  22. package/dist/web/assets/{sqlite-viewer-G81XimpL.js → sqlite-viewer-CcJz9Bva.js} +1 -1
  23. package/dist/web/assets/{terminal-tab-rslAfxoV.js → terminal-tab-BlyuDIu5.js} +2 -2
  24. package/dist/web/assets/{video-preview-vjw8RpLK.js → video-preview-C8s0VXIf.js} +1 -1
  25. package/dist/web/index.html +1 -1
  26. package/dist/web/sw.js +1 -1
  27. package/docs/project-changelog.md +10 -1
  28. package/docs/system-architecture.md +4 -0
  29. package/package.json +1 -1
  30. package/src/server/routes/chat.ts +49 -0
  31. package/src/services/db.service.ts +16 -1
  32. package/src/services/draft.service.ts +49 -0
  33. package/src/services/file-list-index.service.ts +2 -3
  34. package/src/web/components/chat/chat-tab.tsx +41 -25
  35. package/src/web/components/chat/message-input.tsx +6 -1
  36. package/src/web/components/chat/message-list.tsx +54 -3
  37. package/src/web/hooks/use-draft.ts +93 -0
  38. package/src/web/hooks/use-terminal.ts +6 -3
  39. package/dist/web/assets/chat-tab-B-QMa-uT.js +0 -16
  40. package/dist/web/assets/keybindings-store-B9gpby19.js +0 -1
  41. package/dist/web/assets/notification-store-7WlicqzJ.js +0 -1
@@ -3,6 +3,7 @@ import { resolve, join, basename } from "node:path";
3
3
  import { mkdirSync, existsSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { chatService } from "../../services/chat.service.ts";
6
+ import { draftService } from "../../services/draft.service.ts";
6
7
  import { providerRegistry } from "../../providers/registry.ts";
7
8
  import { renameSession as sdkRenameSession } from "@anthropic-ai/claude-agent-sdk";
8
9
  import { listSlashItems, searchSlashItems, invalidateCache } from "../../services/slash-items.service.ts";
@@ -221,9 +222,12 @@ chatRoutes.delete("/sessions", async (c) => {
221
222
  deleteSessionMetadata(s.id);
222
223
  deleteSessionTitle(s.id);
223
224
  unpinSession(s.id);
225
+ try { draftService.delete(projectPath, s.id); } catch { /* ignore */ }
224
226
  deleted++;
225
227
  } catch { /* skip individual failures */ }
226
228
  }
229
+ // Clean up any orphaned drafts left behind
230
+ try { draftService.deleteOrphaned(); } catch { /* ignore */ }
227
231
 
228
232
  return c.json(ok({ deleted, total: toDelete.length }));
229
233
  } catch (e) {
@@ -244,6 +248,8 @@ chatRoutes.delete("/sessions/:id", async (c) => {
244
248
  deleteSessionMetadata(id);
245
249
  deleteSessionTitle(id);
246
250
  unpinSession(id);
251
+ // Fire-and-forget draft cleanup
252
+ try { draftService.delete(c.get("projectPath"), id); } catch { /* ignore */ }
247
253
  return c.json(ok({ deleted: id }));
248
254
  } catch (e) {
249
255
  return c.json(err((e as Error).message), 404);
@@ -516,3 +522,46 @@ chatRoutes.get("/uploads/:filename", async (c) => {
516
522
  return c.json(err((e as Error).message), 500);
517
523
  }
518
524
  });
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // Draft endpoints — auto-save / restore chat input per session
528
+ // ---------------------------------------------------------------------------
529
+
530
+ /** GET /chat/drafts/:sessionId — load draft for a session (or null) */
531
+ chatRoutes.get("/drafts/:sessionId", (c) => {
532
+ try {
533
+ const projectPath = c.get("projectPath");
534
+ const sessionId = c.req.param("sessionId");
535
+ const draft = draftService.get(projectPath, sessionId);
536
+ return c.json(ok(draft));
537
+ } catch (e) {
538
+ return c.json(err((e as Error).message), 500);
539
+ }
540
+ });
541
+
542
+ /** PUT /chat/drafts/:sessionId — upsert draft content + attachments */
543
+ chatRoutes.put("/drafts/:sessionId", async (c) => {
544
+ try {
545
+ const projectPath = c.get("projectPath");
546
+ const sessionId = c.req.param("sessionId");
547
+ const body = await c.req.json<{ content?: string; attachments?: string }>();
548
+ const content = typeof body.content === "string" ? body.content : "";
549
+ const attachments = typeof body.attachments === "string" ? body.attachments : undefined;
550
+ draftService.upsert(projectPath, sessionId, content, attachments);
551
+ return c.json(ok({ saved: true }));
552
+ } catch (e) {
553
+ return c.json(err((e as Error).message), 500);
554
+ }
555
+ });
556
+
557
+ /** DELETE /chat/drafts/:sessionId — remove draft */
558
+ chatRoutes.delete("/drafts/:sessionId", (c) => {
559
+ try {
560
+ const projectPath = c.get("projectPath");
561
+ const sessionId = c.req.param("sessionId");
562
+ draftService.delete(projectPath, sessionId);
563
+ return c.json(ok({ deleted: true }));
564
+ } catch (e) {
565
+ return c.json(err((e as Error).message), 500);
566
+ }
567
+ });
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
3
3
  import { mkdirSync, existsSync } from "node:fs";
4
4
  import { encrypt, decrypt } from "../lib/account-crypto.ts";
5
5
  import { getPpmDir } from "./ppm-dir.ts";
6
- const CURRENT_SCHEMA_VERSION = 21;
6
+ const CURRENT_SCHEMA_VERSION = 26;
7
7
 
8
8
  let db: Database | null = null;
9
9
  let dbProfile: string | null = null;
@@ -638,6 +638,21 @@ function runMigrations(database: Database): void {
638
638
  PRAGMA user_version = 25;
639
639
  `);
640
640
  }
641
+
642
+ if (current < 26) {
643
+ database.exec(`
644
+ CREATE TABLE IF NOT EXISTS chat_drafts (
645
+ project_path TEXT NOT NULL,
646
+ session_id TEXT NOT NULL DEFAULT '__new__',
647
+ content TEXT NOT NULL DEFAULT '',
648
+ attachments TEXT DEFAULT '[]',
649
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
650
+ PRIMARY KEY (project_path, session_id)
651
+ );
652
+
653
+ PRAGMA user_version = 26;
654
+ `);
655
+ }
641
656
  }
642
657
 
643
658
  // ---------------------------------------------------------------------------
@@ -0,0 +1,49 @@
1
+ import { getDb } from "./db.service.ts";
2
+
3
+ const MAX_CONTENT_LENGTH = 50 * 1024; // ~50K characters cap
4
+
5
+ export interface DraftData {
6
+ content: string;
7
+ attachments: string; // JSON string
8
+ updatedAt: string;
9
+ }
10
+
11
+ class DraftService {
12
+ get(projectPath: string, sessionId: string): DraftData | null {
13
+ const row = getDb()
14
+ .query("SELECT content, attachments, updated_at FROM chat_drafts WHERE project_path = ? AND session_id = ?")
15
+ .get(projectPath, sessionId) as { content: string; attachments: string; updated_at: string } | null;
16
+ if (!row) return null;
17
+ return { content: row.content, attachments: row.attachments, updatedAt: row.updated_at };
18
+ }
19
+
20
+ upsert(projectPath: string, sessionId: string, content: string, attachments?: string): void {
21
+ // Silent truncation at 50KB
22
+ const safeContent = content.length > MAX_CONTENT_LENGTH ? content.slice(0, MAX_CONTENT_LENGTH) : content;
23
+ getDb()
24
+ .query(
25
+ "INSERT INTO chat_drafts (project_path, session_id, content, attachments, updated_at) VALUES (?, ?, ?, ?, datetime('now')) ON CONFLICT(project_path, session_id) DO UPDATE SET content = excluded.content, attachments = excluded.attachments, updated_at = excluded.updated_at",
26
+ )
27
+ .run(projectPath, sessionId, safeContent, attachments ?? "[]");
28
+ }
29
+
30
+ delete(projectPath: string, sessionId: string): void {
31
+ getDb()
32
+ .query("DELETE FROM chat_drafts WHERE project_path = ? AND session_id = ?")
33
+ .run(projectPath, sessionId);
34
+ }
35
+
36
+ /** Delete orphaned drafts whose session_id is not in session_metadata */
37
+ deleteOrphaned(): number {
38
+ const result = getDb()
39
+ .query(
40
+ `DELETE FROM chat_drafts
41
+ WHERE session_id != '__new__'
42
+ AND session_id NOT IN (SELECT session_id FROM session_metadata)`,
43
+ )
44
+ .run();
45
+ return (result as { changes: number }).changes;
46
+ }
47
+ }
48
+
49
+ export const draftService = new DraftService();
@@ -155,13 +155,12 @@ function walkForIndex(
155
155
  if (matchesGlob(relPosix, allExclude)) continue;
156
156
  if (matchesGlob(entry.name, allExclude)) continue;
157
157
 
158
- // Apply gitignore rules — SOFT exclude for files (include with isIgnored flag),
159
- // HARD exclude for directories (skip recursion to avoid walking huge gitignored dirs).
158
+ // Apply gitignore rules — SOFT exclude only (mark with isIgnored flag).
159
+ // Huge dirs like node_modules/dist/build are already hard-excluded by glob above.
160
160
  let isIgnored = false;
161
161
  if (ig) {
162
162
  const checkPath = entry.isDirectory() ? `${relPosix}/` : relPosix;
163
163
  isIgnored = ig.ignores(checkPath) || ig.ignores(relPosix);
164
- if (isIgnored && entry.isDirectory()) continue;
165
164
  }
166
165
 
167
166
  if (entry.isDirectory()) {
@@ -14,6 +14,7 @@ import { MessageInput, type ChatAttachment, type MessagePriority } from "./messa
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";
17
+ import { useDraft, type DraftAttachment } from "@/hooks/use-draft";
17
18
 
18
19
  import type { DragEvent } from "react";
19
20
  import type { FileNode } from "../../../types/project";
@@ -69,6 +70,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
69
70
  const { usageInfo, usageLoading, lastFetchedAt, refreshUsage } =
70
71
  useUsage(projectName, providerId);
71
72
 
73
+ // Draft auto-save/restore
74
+ const { draft, draftLoading, saveDraft, clearDraft } = useDraft(projectName, sessionId);
75
+
72
76
  // Load global default permission mode on mount (if no per-session override)
73
77
  useEffect(() => {
74
78
  if (permissionMode) return;
@@ -258,13 +262,22 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
258
262
  [sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode],
259
263
  );
260
264
 
261
- /** Stable wrapper for MessageInput onSend — clears forkDraft and delegates to handleSend */
265
+ /** Stable wrapper for MessageInput onSend — clears forkDraft + draft and delegates to handleSend */
262
266
  const handleInputSend = useCallback(
263
267
  (content: string, attachments: ChatAttachment[], priority?: MessagePriority) => {
264
268
  setForkDraft(undefined);
269
+ clearDraft();
265
270
  handleSend(content, attachments, priority);
266
271
  },
267
- [handleSend],
272
+ [handleSend, clearDraft],
273
+ );
274
+
275
+ /** Draft auto-save callback — called by MessageInput on content change */
276
+ const handleContentChange = useCallback(
277
+ (content: string, attachments?: DraftAttachment[]) => {
278
+ saveDraft(content, attachments);
279
+ },
280
+ [saveDraft],
268
281
  );
269
282
 
270
283
  /** Stable callback for slash items loaded — prevents MessageInput memo break */
@@ -469,29 +482,32 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
469
482
  />
470
483
  )}
471
484
 
472
- {/* Input */}
473
- <MessageInput
474
- onSend={handleInputSend}
475
- isStreaming={isStreaming}
476
- onCancel={cancelStreaming}
477
- autoFocus={!(metadata?.sessionId) || !!forkDraft}
478
- initialValue={forkDraft}
479
- projectName={projectName}
480
- onSlashStateChange={handleSlashStateChange}
481
- onSlashItemsLoaded={handleSlashItemsLoaded}
482
- slashSelected={slashSelected}
483
- onFileStateChange={handleFileStateChange}
484
- onFileItemsLoaded={setFileItems}
485
- fileSelected={fileSelected}
486
- externalFiles={externalFiles}
487
- externalPaths={externalPaths}
488
- onExternalPathsConsumed={handleExternalPathsConsumed}
489
- onDisambiguate={handleDisambiguate}
490
- permissionMode={permissionMode}
491
- onModeChange={setPermissionMode}
492
- providerId={providerId}
493
- onProviderChange={!sessionId ? setProviderId : undefined}
494
- />
485
+ {/* Input — gate on draftLoading to avoid empty→filled flash */}
486
+ {!draftLoading && (
487
+ <MessageInput
488
+ onSend={handleInputSend}
489
+ isStreaming={isStreaming}
490
+ onCancel={cancelStreaming}
491
+ autoFocus={!(metadata?.sessionId) || !!forkDraft}
492
+ initialValue={forkDraft ?? draft?.content}
493
+ projectName={projectName}
494
+ onSlashStateChange={handleSlashStateChange}
495
+ onSlashItemsLoaded={handleSlashItemsLoaded}
496
+ slashSelected={slashSelected}
497
+ onFileStateChange={handleFileStateChange}
498
+ onFileItemsLoaded={setFileItems}
499
+ fileSelected={fileSelected}
500
+ externalFiles={externalFiles}
501
+ externalPaths={externalPaths}
502
+ onExternalPathsConsumed={handleExternalPathsConsumed}
503
+ onDisambiguate={handleDisambiguate}
504
+ onContentChange={handleContentChange}
505
+ permissionMode={permissionMode}
506
+ onModeChange={setPermissionMode}
507
+ providerId={providerId}
508
+ onProviderChange={!sessionId ? setProviderId : undefined}
509
+ />
510
+ )}
495
511
  </div>
496
512
 
497
513
  {/* Bug report popup is now global — see BugReportPopup in app.tsx */}
@@ -50,6 +50,8 @@ interface MessageInputProps {
50
50
  onDisambiguate?: (matches: FileNode[]) => void;
51
51
  /** Pre-fill input value (e.g. from command palette "Ask AI") */
52
52
  initialValue?: string;
53
+ /** Called on content change for draft auto-save */
54
+ onContentChange?: (content: string, attachments?: Array<{ name: string; path: string }>) => void;
53
55
  /** Auto-focus textarea on mount */
54
56
  autoFocus?: boolean;
55
57
  /** Current permission mode */
@@ -79,6 +81,7 @@ export const MessageInput = memo(function MessageInput({
79
81
  onExternalPathsConsumed,
80
82
  onDisambiguate,
81
83
  initialValue,
84
+ onContentChange,
82
85
  autoFocus,
83
86
  permissionMode,
84
87
  onModeChange,
@@ -561,6 +564,8 @@ export const MessageInput = memo(function MessageInput({
561
564
  setHasText(text.trim().length > 0);
562
565
  // Update picker state (slash/file autocomplete)
563
566
  updatePickerState(text, el.selectionStart);
567
+ // Notify parent for draft auto-save (debounced in hook)
568
+ onContentChange?.(text, attachments.filter((a) => a.status === "ready" && a.serverPath).map((a) => ({ name: a.name, path: a.serverPath! })));
564
569
  // JS auto-resize fallback — only when CSS field-sizing: content is unsupported
565
570
  if (needsJsResize.current) {
566
571
  if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
@@ -571,7 +576,7 @@ export const MessageInput = memo(function MessageInput({
571
576
  });
572
577
  }
573
578
  },
574
- [updatePickerState],
579
+ [updatePickerState, onContentChange, attachments],
575
580
  );
576
581
 
577
582
  /** Handle paste — intercept images from clipboard */
@@ -436,6 +436,19 @@ function isPdfPath(path: string): boolean {
436
436
  /** Detect if tags contain system-injected content (not real user input) */
437
437
  const SYSTEM_TAG_NAMES = new Set(["task-notification", "environment_details"]);
438
438
 
439
+ /** Extract leading terminal code fences from message text */
440
+ function extractTerminalBlocks(text: string): { blocks: string[]; remainingText: string } {
441
+ const blocks: string[] = [];
442
+ let remaining = text;
443
+ const re = /^```(?:bash|sh|shell|zsh)\n([\s\S]*?)\n```\s*(?:\n\n?)?/;
444
+ let match;
445
+ while ((match = remaining.match(re)) !== null) {
446
+ blocks.push(match[1]!);
447
+ remaining = remaining.slice(match[0].length);
448
+ }
449
+ return { blocks, remainingText: remaining.trim() };
450
+ }
451
+
439
452
  /** User message bubble — full width, collapsible, with system tag badges */
440
453
  function UserBubble({ content, messageId, projectName, onFork }: {
441
454
  content: string;
@@ -443,14 +456,15 @@ function UserBubble({ content, messageId, projectName, onFork }: {
443
456
  projectName?: string;
444
457
  onFork?: () => void;
445
458
  }) {
446
- const { files, text, tags, command } = useMemo(() => {
447
- const parsed = parseUserAttachments(content);
459
+ const { files, text, tags, command, terminalBlocks } = useMemo(() => {
460
+ const { blocks, remainingText: afterBlocks } = extractTerminalBlocks(content);
461
+ const parsed = parseUserAttachments(afterBlocks);
448
462
  const { cleanText: noSysTags, tags } = extractSystemTags(parsed.text);
449
463
  const { command, cleanText } = parseCommandTags(noSysTags);
450
464
  const bodyText = command?.args
451
465
  ? (cleanText ? `${command.args}\n\n${cleanText}` : command.args)
452
466
  : cleanText;
453
- return { files: parsed.files, text: bodyText, tags, command };
467
+ return { files: parsed.files, text: bodyText, tags, command, terminalBlocks: blocks };
454
468
  }, [content]);
455
469
 
456
470
  const isSystemContext = tags.some((t) => SYSTEM_TAG_NAMES.has(t.name));
@@ -508,6 +522,15 @@ function UserBubble({ content, messageId, projectName, onFork }: {
508
522
  </div>
509
523
  )}
510
524
 
525
+ {/* Terminal output previews */}
526
+ {terminalBlocks.length > 0 && (
527
+ <div className="space-y-1.5">
528
+ {terminalBlocks.map((block, i) => (
529
+ <TerminalBlockPreview key={i} content={block} />
530
+ ))}
531
+ </div>
532
+ )}
533
+
511
534
  {/* Text content — 2-line clamp by default, expandable */}
512
535
  {text && (
513
536
  <div
@@ -546,6 +569,34 @@ function UserBubble({ content, messageId, projectName, onFork }: {
546
569
  );
547
570
  }
548
571
 
572
+ /** Collapsible terminal output preview in user messages */
573
+ function TerminalBlockPreview({ content }: { content: string }) {
574
+ const [expanded, setExpanded] = useState(false);
575
+ return (
576
+ <div>
577
+ <button
578
+ type="button"
579
+ onClick={() => setExpanded(!expanded)}
580
+ className={cn(
581
+ "flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
582
+ expanded
583
+ ? "border-primary/50 bg-surface-elevated text-text-primary"
584
+ : "border-border/60 bg-background/40 text-text-secondary hover:bg-surface",
585
+ )}
586
+ >
587
+ <TerminalSquare className="size-3.5 shrink-0" />
588
+ <span>Terminal output</span>
589
+ <ChevronDown className={cn("size-3 shrink-0 transition-transform", expanded && "rotate-180")} />
590
+ </button>
591
+ {expanded && (
592
+ <pre className="mt-1 max-h-40 overflow-auto rounded-md border border-border bg-background p-2 text-xs text-text-primary font-mono whitespace-pre-wrap break-words">
593
+ {content}
594
+ </pre>
595
+ )}
596
+ </div>
597
+ );
598
+ }
599
+
549
600
  /** Render system tags as collapsible badges */
550
601
  function SystemTagBadges({ tags }: { tags: SystemTag[] }) {
551
602
  return (
@@ -0,0 +1,93 @@
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
+ import { api, projectUrl } from "@/lib/api-client";
3
+
4
+ export interface DraftAttachment {
5
+ name: string;
6
+ path: string;
7
+ }
8
+
9
+ interface DraftState {
10
+ content: string;
11
+ attachments: DraftAttachment[];
12
+ }
13
+
14
+ interface DraftResult {
15
+ content: string;
16
+ attachments: string; // JSON string
17
+ updatedAt: string;
18
+ }
19
+
20
+ export function useDraft(projectName: string, sessionId: string | null) {
21
+ const [draft, setDraft] = useState<DraftState | null>(null);
22
+ const [loading, setLoading] = useState(true);
23
+ const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
24
+ const sessionRef = useRef(sessionId);
25
+ sessionRef.current = sessionId;
26
+
27
+ const effectiveId = sessionId ?? "__new__";
28
+
29
+ // Load draft on mount / session change
30
+ useEffect(() => {
31
+ if (!projectName) {
32
+ setLoading(false);
33
+ return;
34
+ }
35
+ let cancelled = false;
36
+ setLoading(true);
37
+ api
38
+ .get<DraftResult | null>(
39
+ `${projectUrl(projectName)}/chat/drafts/${encodeURIComponent(effectiveId)}`,
40
+ )
41
+ .then((data) => {
42
+ if (cancelled) return;
43
+ if (data) {
44
+ let attachments: DraftAttachment[] = [];
45
+ try { attachments = JSON.parse(data.attachments); } catch { /* ignore */ }
46
+ setDraft({ content: data.content, attachments });
47
+ } else {
48
+ setDraft(null);
49
+ }
50
+ })
51
+ .catch(() => { if (!cancelled) setDraft(null); })
52
+ .finally(() => { if (!cancelled) setLoading(false); });
53
+ return () => { cancelled = true; };
54
+ }, [projectName, effectiveId]);
55
+
56
+ // Debounced save (1s)
57
+ const save = useCallback(
58
+ (content: string, attachments?: DraftAttachment[]) => {
59
+ if (!projectName) return;
60
+ if (timerRef.current) clearTimeout(timerRef.current);
61
+ timerRef.current = setTimeout(() => {
62
+ const id = sessionRef.current ?? "__new__";
63
+ api
64
+ .put(
65
+ `${projectUrl(projectName)}/chat/drafts/${encodeURIComponent(id)}`,
66
+ { content, attachments: JSON.stringify(attachments ?? []) },
67
+ )
68
+ .catch(() => {});
69
+ }, 1000);
70
+ },
71
+ [projectName],
72
+ );
73
+
74
+ // Clear draft (on send)
75
+ const clear = useCallback(() => {
76
+ if (!projectName) return;
77
+ if (timerRef.current) clearTimeout(timerRef.current);
78
+ const id = sessionRef.current ?? "__new__";
79
+ api
80
+ .del(`${projectUrl(projectName)}/chat/drafts/${encodeURIComponent(id)}`)
81
+ .catch(() => {});
82
+ setDraft(null);
83
+ }, [projectName]);
84
+
85
+ // Cleanup timer on unmount
86
+ useEffect(() => {
87
+ return () => {
88
+ if (timerRef.current) clearTimeout(timerRef.current);
89
+ };
90
+ }, []);
91
+
92
+ return { draft, draftLoading: loading, saveDraft: save, clearDraft: clear };
93
+ }
@@ -195,10 +195,13 @@ export function useTerminal(
195
195
  if (event.data.startsWith("{")) {
196
196
  try {
197
197
  const msg = JSON.parse(event.data);
198
- if (msg.type === "session" || msg.type === "error" || msg.type === "exited" || msg.type === "ping") {
198
+ // Any valid JSON with a "type" field is a control/system message
199
+ // real PTY output is raw text/escape sequences, never typed JSON.
200
+ // Handle known terminal control types, silently drop everything else
201
+ // (e.g. chat events that may leak via WS under race conditions).
202
+ if (msg.type) {
199
203
  if (msg.type === "session" && msg.id) {
200
204
  actualSessionId.current = msg.id;
201
- // Persist to localStorage so reload reconnects to same PTY
202
205
  if (storageKey) {
203
206
  try { localStorage.setItem(storageKey, msg.id); } catch { /* */ }
204
207
  }
@@ -209,7 +212,7 @@ export function useTerminal(
209
212
  if (msg.type === "exited") {
210
213
  setExited(true);
211
214
  }
212
- return; // Don't write raw JSON to terminal
215
+ return; // Never write typed JSON to terminal
213
216
  }
214
217
  } catch {
215
218
  // Not JSON, write as terminal output