@hienlh/ppm 0.17.36 → 0.17.38

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 (77) hide show
  1. package/CHANGELOG.md +16 -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/{ai-resource-editor-aT2nC1lK.js → ai-resource-editor-ChT9FzxT.js} +1 -1
  5. package/dist/web/assets/{api-client-DhXpjLdi.js → api-client-BgM59GxL.js} +1 -1
  6. package/dist/web/assets/api-client-BwMdPtp6.js +1 -0
  7. package/dist/web/assets/{api-settings-BUYvGBdi.js → api-settings-D108koFG.js} +1 -1
  8. package/dist/web/assets/api-settings-D2fY0GUc.js +1 -0
  9. package/dist/web/assets/{api-themes-BLUwA0Gh.js → api-themes-cPGCYGB_.js} +1 -1
  10. package/dist/web/assets/{audio-preview-DPS0VpgU.js → audio-preview-CSK6nxNy.js} +1 -1
  11. package/dist/web/assets/chat-tab-DGM7js5J.js +13 -0
  12. package/dist/web/assets/code-editor-BvLxm1bf.js +10 -0
  13. package/dist/web/assets/{conflict-editor-Dag7buRT.js → conflict-editor-Bc063EfD.js} +3 -3
  14. package/dist/web/assets/context-menu-DIiZdfbE.js +1 -0
  15. package/dist/web/assets/{csv-preview-6JQDYpOU.js → csv-preview-ft27dubA.js} +1 -1
  16. package/dist/web/assets/{database-viewer-BFzNauDX.js → database-viewer-Bdj1ferk.js} +1 -1
  17. package/dist/web/assets/diff-viewer-C46_ZovZ.js +4 -0
  18. package/dist/web/assets/docx-preview-CLDLTD8N.js +12 -0
  19. package/dist/web/assets/{extension-webview-DUqZPzfp.js → extension-webview-BSvKnN1E.js} +1 -1
  20. package/dist/web/assets/git-log-panel-GP8XaTlA.js +1 -0
  21. package/dist/web/assets/{glide-data-grid-BSSyHBRx.js → glide-data-grid-On_pS9Y7.js} +4 -4
  22. package/dist/web/assets/group-chat-tab-DmYt9GfT.js +1 -0
  23. package/dist/web/assets/{image-preview-ChhxwmZU.js → image-preview-BWNTrufr.js} +1 -1
  24. package/dist/web/assets/index-Crnizc26.js +50 -0
  25. package/dist/web/assets/index-opWDpE6A.css +2 -0
  26. package/dist/web/assets/keybindings-store-B4EWnplE.js +1 -0
  27. package/dist/web/assets/markdown-renderer-Bhv5w9Op.js +1 -0
  28. package/dist/web/assets/{markdown-renderer-BaPAXsZW.js → markdown-renderer-suZuQvya.js} +2 -2
  29. package/dist/web/assets/notification-store-B3zEJT3q.js +1 -0
  30. package/dist/web/assets/pdf-preview-DcP6Tj7N.js +1 -0
  31. package/dist/web/assets/{postgres-viewer-uMVBwY8R.js → postgres-viewer-BOlXoo4R.js} +2 -2
  32. package/dist/web/assets/project-store-8_AdCduJ.js +1 -0
  33. package/dist/web/assets/{settings-store-CBLPPIts.js → settings-store-BhZ7VYJD.js} +2 -2
  34. package/dist/web/assets/settings-tab-BqJJqL1l.js +1 -0
  35. package/dist/web/assets/{sql-query-editor-R1WsE3n7.js → sql-query-editor-h4zuID_d.js} +1 -1
  36. package/dist/web/assets/sqlite-viewer-D5X8iWUw.js +1 -0
  37. package/dist/web/assets/{system-monitor-tab-BzK46Q3D.js → system-monitor-tab-DL-viZmD.js} +1 -1
  38. package/dist/web/assets/terminal-tab-BVW9MfC3.js +3 -0
  39. package/dist/web/assets/tool-cards-zK7OP3DS.js +7 -0
  40. package/dist/web/assets/use-blob-url-BYDGMRMU.js +1 -0
  41. package/dist/web/assets/{use-monaco-theme-CEAY5h6Y.js → use-monaco-theme-Bk-dETGV.js} +1 -1
  42. package/dist/web/assets/{video-preview-C7RTSYae.js → video-preview-C6bKCBvz.js} +1 -1
  43. package/dist/web/index.html +7 -7
  44. package/dist/web/sw.js +1 -1
  45. package/package.json +2 -2
  46. package/src/web/app.tsx +6 -2
  47. package/src/web/components/chat/message-input.tsx +4 -2
  48. package/src/web/components/layout/mobile-nav.tsx +6 -3
  49. package/src/web/components/terminal/terminal-links-sheet.tsx +66 -0
  50. package/src/web/components/terminal/terminal-mobile-toolbar.tsx +107 -0
  51. package/src/web/components/terminal/terminal-tab.tsx +57 -86
  52. package/src/web/components/ui/mobile-bottom-sheet.tsx +20 -3
  53. package/src/web/hooks/use-draft.ts +17 -2
  54. package/src/web/hooks/use-terminal-touch-selection.ts +87 -0
  55. package/src/web/hooks/use-terminal.ts +53 -1
  56. package/src/web/lib/api-client.ts +50 -7
  57. package/dist/web/assets/api-client-DNTkryX1.js +0 -1
  58. package/dist/web/assets/api-settings-Bp5HXQCW.js +0 -1
  59. package/dist/web/assets/chat-tab-k7e8hE9y.js +0 -13
  60. package/dist/web/assets/code-editor-C8ClI1kO.js +0 -10
  61. package/dist/web/assets/diff-viewer-Dub9yryk.js +0 -4
  62. package/dist/web/assets/docx-preview-Bj2Xzdef.js +0 -12
  63. package/dist/web/assets/git-log-panel-90_p5Qdn.js +0 -1
  64. package/dist/web/assets/group-chat-tab-j1CoraSo.js +0 -1
  65. package/dist/web/assets/index-BA8NcuLe.js +0 -50
  66. package/dist/web/assets/index-eW0VEaRF.css +0 -2
  67. package/dist/web/assets/keybindings-store-Bp-oUCTT.js +0 -1
  68. package/dist/web/assets/markdown-renderer-CUHI3gc6.js +0 -1
  69. package/dist/web/assets/mobile-bottom-sheet-BwPBKZON.js +0 -1
  70. package/dist/web/assets/notification-store-COr3urzj.js +0 -1
  71. package/dist/web/assets/pdf-preview-cJC8Cm9R.js +0 -1
  72. package/dist/web/assets/project-store-C1jInVih.js +0 -1
  73. package/dist/web/assets/settings-tab-BdEa4MLs.js +0 -1
  74. package/dist/web/assets/sqlite-viewer-CppHnW_J.js +0 -1
  75. package/dist/web/assets/terminal-tab-2CWIYa05.js +0 -3
  76. package/dist/web/assets/tool-cards-DS4vQ3qy.js +0 -7
  77. package/dist/web/assets/use-blob-url-jSRmyXVg.js +0 -1
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Touch key row for the terminal: clipboard actions plus the keys a soft
3
+ * keyboard does not offer (Tab, Esc, Ctrl, arrows, ^C).
4
+ *
5
+ * The row scrolls horizontally — 44px targets for eleven controls cannot fit a
6
+ * phone's width — so it carries an edge fade to advertise that there is more
7
+ * past the right edge. Without it the last keys look absent rather than
8
+ * off-screen.
9
+ */
10
+ import { Copy, ClipboardPaste, MessageSquare, Link, TextSelect } from "lucide-react";
11
+ import { cn } from "@/lib/utils";
12
+
13
+ export interface MobileKey {
14
+ label: string;
15
+ value: string | null;
16
+ }
17
+
18
+ export const MOBILE_KEYS: readonly MobileKey[] = [
19
+ { label: "Tab", value: "\t" },
20
+ { label: "Esc", value: "\x1b" },
21
+ { label: "Ctrl", value: null },
22
+ { label: "↑", value: "\x1b[A" },
23
+ { label: "↓", value: "\x1b[B" },
24
+ { label: "←", value: "\x1b[D" },
25
+ { label: "→", value: "\x1b[C" },
26
+ { label: "^C", value: "\x03" },
27
+ ];
28
+
29
+ const BUTTON_BASE =
30
+ "flex items-center justify-center shrink-0 min-w-11 min-h-11 rounded text-xs " +
31
+ "bg-surface-elevated text-text-primary active:bg-primary active:text-primary-foreground " +
32
+ "transition-colors select-none";
33
+
34
+ interface TerminalMobileToolbarProps {
35
+ ctrlMode: boolean;
36
+ onToggleCtrl: () => void;
37
+ selectMode: boolean;
38
+ onToggleSelect: () => void;
39
+ onKey: (value: string) => void;
40
+ onCopy: () => void;
41
+ onPaste: () => void;
42
+ onSendToChat: () => void;
43
+ onOpenLinks: () => void;
44
+ }
45
+
46
+ export function TerminalMobileToolbar({
47
+ ctrlMode,
48
+ onToggleCtrl,
49
+ selectMode,
50
+ onToggleSelect,
51
+ onKey,
52
+ onCopy,
53
+ onPaste,
54
+ onSendToChat,
55
+ onOpenLinks,
56
+ }: TerminalMobileToolbarProps) {
57
+ return (
58
+ <div className="relative shrink-0 bg-surface border-t border-border">
59
+ <div className="flex items-center gap-1 px-2 py-1.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
60
+ <button
61
+ onClick={onToggleSelect}
62
+ aria-label="Select text"
63
+ aria-pressed={selectMode}
64
+ className={cn(BUTTON_BASE, selectMode && "bg-primary text-primary-foreground")}
65
+ >
66
+ <TextSelect size={16} />
67
+ </button>
68
+ <button onClick={onCopy} aria-label="Copy" className={BUTTON_BASE}>
69
+ <Copy size={16} />
70
+ </button>
71
+ <button onClick={onPaste} aria-label="Paste" className={BUTTON_BASE}>
72
+ <ClipboardPaste size={16} />
73
+ </button>
74
+ <button onClick={onOpenLinks} aria-label="Links" className={BUTTON_BASE}>
75
+ <Link size={16} />
76
+ </button>
77
+ <button onClick={onSendToChat} aria-label="Send to Chat" className={BUTTON_BASE}>
78
+ <MessageSquare size={16} />
79
+ </button>
80
+
81
+ <div className="w-px h-6 shrink-0 bg-border mx-0.5" />
82
+
83
+ {MOBILE_KEYS.map((key) => (
84
+ <button
85
+ key={key.label}
86
+ onClick={() => (key.value === null ? onToggleCtrl() : onKey(key.value))}
87
+ className={cn(
88
+ BUTTON_BASE,
89
+ "font-mono",
90
+ key.value === null && ctrlMode && "bg-primary text-primary-foreground",
91
+ )}
92
+ >
93
+ {key.label}
94
+ </button>
95
+ ))}
96
+
97
+ {/* Trailing spacer so the last key clears the fade instead of hiding under it. */}
98
+ <div className="w-6 shrink-0" aria-hidden />
99
+ </div>
100
+
101
+ <div
102
+ className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-surface to-transparent"
103
+ aria-hidden
104
+ />
105
+ </div>
106
+ );
107
+ }
@@ -1,51 +1,32 @@
1
1
  import { useRef, useEffect, useState, useCallback, memo } from "react";
2
2
  import { useTerminal } from "@/hooks/use-terminal";
3
+ import { useTerminalTouchSelection } from "@/hooks/use-terminal-touch-selection";
3
4
  import { cn } from "@/lib/utils";
4
5
  import { copyToClipboard } from "@/lib/clipboard";
5
- import { Copy, ClipboardPaste, RotateCcw, MessageSquare } from "lucide-react";
6
+ import { RotateCcw, MessageSquare } from "lucide-react";
6
7
  import "@xterm/xterm/css/xterm.css";
7
8
  import { toast } from "sonner";
8
9
 
9
10
  import { usePanelStore } from "@/stores/panel-store";
11
+ import { TerminalMobileToolbar } from "./terminal-mobile-toolbar";
12
+ import { TerminalLinksSheet } from "./terminal-links-sheet";
10
13
 
11
14
  interface TerminalTabProps {
12
15
  metadata?: Record<string, unknown>;
13
16
  tabId?: string;
14
17
  }
15
18
 
16
- const MOBILE_KEYS = [
17
- { label: "Tab", value: "\t" },
18
- { label: "Esc", value: "\x1b" },
19
- { label: "Ctrl", value: null, isModifier: true },
20
- { label: "\u2191", value: "\x1b[A" },
21
- { label: "\u2193", value: "\x1b[B" },
22
- { label: "\u2190", value: "\x1b[D" },
23
- { label: "\u2192", value: "\x1b[C" },
24
- { label: "^C", value: "\x03" },
25
- ] as const;
26
-
27
19
  export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: TerminalTabProps) {
28
20
  const sessionId = (metadata?.sessionId as string) ?? "new";
29
21
  const projectName = metadata?.projectName as string | undefined;
30
22
  const containerRef = useRef<HTMLDivElement>(null);
31
23
 
32
- const { connected, reconnecting, exited, sendData, getSelection, getLastCommandOutput, restart } = useTerminal({ sessionId, projectName, containerRef, tabId });
24
+ const { connected, reconnecting, exited, sendData, getSelection, getLastCommandOutput, getBufferUrls, restart } = useTerminal({ sessionId, projectName, containerRef, tabId });
33
25
  const [ctrlMode, setCtrlMode] = useState(false);
34
- const [viewportHeight, setViewportHeight] = useState<number | null>(null);
35
-
36
- // Adjust height when mobile keyboard opens
37
- useEffect(() => {
38
- const vv = window.visualViewport;
39
- if (!vv) return;
40
-
41
- function handleResize() {
42
- if (!vv) return;
43
- setViewportHeight(vv.height);
44
- }
26
+ const [selectMode, setSelectMode] = useState(false);
27
+ const [linksOpen, setLinksOpen] = useState(false);
45
28
 
46
- vv.addEventListener("resize", handleResize);
47
- return () => vv.removeEventListener("resize", handleResize);
48
- }, []);
29
+ useTerminalTouchSelection(containerRef, selectMode);
49
30
 
50
31
  const focusTerminal = useCallback(() => {
51
32
  const termElement = containerRef.current?.querySelector(
@@ -54,18 +35,19 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
54
35
  termElement?.focus();
55
36
  }, []);
56
37
 
57
- // On touch devices xterm swallows the synthesized `click`, so an onClick
58
- // handler never fires and the soft keyboard never opens. Bind `touchend` in
59
- // the capture phase (runs before xterm's own handlers) and focus the hidden
60
- // textarea there this happens inside a real user gesture, so mobile
61
- // browsers reliably raise the on-screen keyboard.
38
+ // Raising the soft keyboard needs focus() to run inside a gesture the browser
39
+ // recognises, and xterm's own mousedown handling does not reliably qualify.
40
+ // Capture-phase `touchend` runs before xterm's handlers and is unambiguously a
41
+ // gesture, so the keyboard opens even when the click path is unreliable.
42
+ // Skipped in select mode: a drag there is a selection gesture, and raising the
43
+ // keyboard would both shrink the viewport mid-drag and clear the selection.
62
44
  useEffect(() => {
63
45
  const container = containerRef.current;
64
- if (!container) return;
46
+ if (!container || selectMode) return;
65
47
  const onTouchEnd = () => focusTerminal();
66
48
  container.addEventListener("touchend", onTouchEnd, { capture: true });
67
49
  return () => container.removeEventListener("touchend", onTouchEnd, { capture: true });
68
- }, [focusTerminal]);
50
+ }, [focusTerminal, selectMode]);
69
51
 
70
52
  const sendKey = useCallback(
71
53
  (value: string) => {
@@ -86,12 +68,22 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
86
68
  [ctrlMode, sendData, focusTerminal],
87
69
  );
88
70
 
71
+ // xterm starts a selection from mousedown and has no touch equivalent, so on a
72
+ // phone getSelection() is always empty. Falling back to the last command's
73
+ // output keeps the button useful instead of silently doing nothing.
89
74
  const handleCopy = useCallback(async () => {
90
75
  const selection = getSelection();
91
- if (selection) {
92
- await copyToClipboard(selection);
76
+ const text = selection || getLastCommandOutput();
77
+ if (!text.trim()) {
78
+ toast("Nothing to copy", { duration: 1500 });
79
+ return;
93
80
  }
94
- }, [getSelection]);
81
+ const ok = await copyToClipboard(text);
82
+ toast[ok ? "success" : "error"](
83
+ ok ? (selection ? "Selection copied" : "Last output copied") : "Could not copy",
84
+ { duration: 1500 },
85
+ );
86
+ }, [getSelection, getLastCommandOutput]);
95
87
 
96
88
  const handlePaste = useCallback(async () => {
97
89
  try {
@@ -101,7 +93,10 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
101
93
  focusTerminal();
102
94
  }
103
95
  } catch {
104
- // Clipboard permission denied
96
+ // readText() needs a secure context and an explicit permission grant, and
97
+ // Safari rejects it outright when the tap is not treated as a gesture.
98
+ // Say so — a silent catch reads as a dead button.
99
+ toast.error("Clipboard blocked — paste with the keyboard instead", { duration: 2500 });
105
100
  }
106
101
  }, [sendData, focusTerminal]);
107
102
 
@@ -136,10 +131,7 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
136
131
  const isMobile = typeof window !== "undefined" && "ontouchstart" in window;
137
132
 
138
133
  return (
139
- <div
140
- className="flex flex-col h-full"
141
- style={viewportHeight ? { maxHeight: `${viewportHeight}px` } : undefined}
142
- >
134
+ <div className="flex flex-col h-full min-h-0">
143
135
  {/* Status bar */}
144
136
  <div className="flex items-center gap-2 px-3 py-1 bg-surface border-b border-border text-xs">
145
137
  <span
@@ -180,52 +172,31 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
180
172
  {/* Terminal container. onClick focuses xterm's hidden textarea — on mobile
181
173
  the soft keyboard only opens when focus() runs inside a recognized tap
182
174
  gesture, which xterm's internal mousedown handling doesn't reliably do. */}
183
- <div ref={containerRef} onClick={focusTerminal} className="flex-1 min-h-0 bg-background p-1" />
175
+ <div
176
+ ref={containerRef}
177
+ onClick={selectMode ? undefined : focusTerminal}
178
+ className={cn("flex-1 min-h-0 bg-background p-1", selectMode && "touch-none")}
179
+ />
184
180
 
185
- {/* Mobile toolbar */}
186
181
  {isMobile && (
187
- <div className="flex items-center gap-1 px-2 py-1.5 bg-surface border-t border-border overflow-x-auto">
188
- <button
189
- onClick={handleCopy}
190
- className="px-2 py-1.5 rounded text-xs min-w-[36px] min-h-[32px] bg-surface-elevated text-text-primary active:bg-primary active:text-primary-foreground transition-colors select-none"
191
- >
192
- <Copy size={14} />
193
- </button>
194
- <button
195
- onClick={handlePaste}
196
- className="px-2 py-1.5 rounded text-xs min-w-[36px] min-h-[32px] bg-surface-elevated text-text-primary active:bg-primary active:text-primary-foreground transition-colors select-none"
197
- >
198
- <ClipboardPaste size={14} />
199
- </button>
200
- <button
201
- onClick={handleSendToChat}
202
- className="px-2 py-1.5 rounded text-xs min-w-[36px] min-h-[32px] bg-surface-elevated text-text-primary active:bg-primary active:text-primary-foreground transition-colors select-none"
203
- aria-label="Send to Chat"
204
- >
205
- <MessageSquare size={14} />
206
- </button>
207
- <div className="w-px h-5 bg-border mx-0.5" />
208
- {MOBILE_KEYS.map((key) => (
209
- <button
210
- key={key.label}
211
- onClick={() => {
212
- if (key.label === "Ctrl") {
213
- setCtrlMode(!ctrlMode);
214
- } else if (key.value) {
215
- sendKey(key.value);
216
- }
217
- }}
218
- className={cn(
219
- "px-3 py-1.5 rounded text-xs font-mono min-w-[36px] min-h-[32px]",
220
- "bg-surface-elevated text-text-primary active:bg-primary active:text-primary-foreground",
221
- "transition-colors select-none",
222
- key.label === "Ctrl" && ctrlMode && "bg-primary text-primary-foreground",
223
- )}
224
- >
225
- {key.label}
226
- </button>
227
- ))}
228
- </div>
182
+ <>
183
+ <TerminalMobileToolbar
184
+ ctrlMode={ctrlMode}
185
+ onToggleCtrl={() => setCtrlMode(!ctrlMode)}
186
+ selectMode={selectMode}
187
+ onToggleSelect={() => setSelectMode(!selectMode)}
188
+ onKey={sendKey}
189
+ onCopy={() => void handleCopy()}
190
+ onPaste={() => void handlePaste()}
191
+ onSendToChat={handleSendToChat}
192
+ onOpenLinks={() => setLinksOpen(true)}
193
+ />
194
+ <TerminalLinksSheet
195
+ open={linksOpen}
196
+ onClose={() => setLinksOpen(false)}
197
+ urls={linksOpen ? getBufferUrls() : []}
198
+ />
199
+ </>
229
200
  )}
230
201
  </div>
231
202
  );
@@ -78,17 +78,34 @@ export function BottomSheet({ open, onClose, children, className, zIndex = 50 }:
78
78
  onClick={(e) => e.stopPropagation()}
79
79
  {...swipeHandlers}
80
80
  >
81
- {/* Drag handle */}
82
- <div className="flex justify-center pt-3 pb-1">
81
+ {/* Drag handle. shrink-0 so a flex-column sheet gives it its own height
82
+ instead of letting `h-full`/`flex-1` content overflow past it. */}
83
+ <div className="flex justify-center pt-3 pb-1 shrink-0">
83
84
  <div className="w-10 h-1 rounded-full bg-border" />
84
85
  </div>
85
86
  {children}
86
87
  </div>
87
88
  </div>,
88
- document.body,
89
+ sheetPortalTarget(),
89
90
  );
90
91
  }
91
92
 
93
+ /**
94
+ * Portal target for every sheet: the React root, never `document.body`.
95
+ *
96
+ * TabPool reparents live tab DOM into panel slots with `appendChild`, and on
97
+ * mobile the dock's slot lives inside a sheet. A body-level portal would put
98
+ * that node outside the root container React binds its delegated listeners to,
99
+ * so React resolves the node's root container (`#root`) against the container
100
+ * the event fired on (`body`), finds no match, and drops the event — every
101
+ * button inside a dock tab goes dead while native listeners still fire.
102
+ * `#root` carries no styles, so keeping the portal inside it leaves stacking
103
+ * order untouched.
104
+ */
105
+ function sheetPortalTarget(): HTMLElement {
106
+ return document.getElementById("root") ?? document.body;
107
+ }
108
+
92
109
  /* ------------------------------------------------------------------ */
93
110
  /* Context-menu-specific helpers (used by adaptive-context-menu) */
94
111
  /* ------------------------------------------------------------------ */
@@ -11,6 +11,13 @@ interface DraftState {
11
11
  attachments: DraftAttachment[];
12
12
  }
13
13
 
14
+ /**
15
+ * How long the chat composer may stay hidden waiting for a draft. The composer
16
+ * is gated on `draftLoading`, so a slow or stalled load must not leave the user
17
+ * with no way to type.
18
+ */
19
+ const GATE_RELEASE_MS = 3_000;
20
+
14
21
  interface DraftResult {
15
22
  content: string;
16
23
  attachments: string; // JSON string
@@ -34,6 +41,11 @@ export function useDraft(projectName: string, sessionId: string | null) {
34
41
  }
35
42
  let cancelled = false;
36
43
  setLoading(true);
44
+ // Releasing the gate only reveals the composer; a draft arriving afterwards
45
+ // is still applied, and MessageInput refuses to overwrite typed text.
46
+ const releaseTimer = setTimeout(() => {
47
+ if (!cancelled) setLoading(false);
48
+ }, GATE_RELEASE_MS);
37
49
  api
38
50
  .get<DraftResult | null>(
39
51
  `${projectUrl(projectName)}/chat/drafts/${encodeURIComponent(effectiveId)}`,
@@ -49,8 +61,11 @@ export function useDraft(projectName: string, sessionId: string | null) {
49
61
  }
50
62
  })
51
63
  .catch(() => { if (!cancelled) setDraft(null); })
52
- .finally(() => { if (!cancelled) setLoading(false); });
53
- return () => { cancelled = true; };
64
+ .finally(() => {
65
+ clearTimeout(releaseTimer);
66
+ if (!cancelled) setLoading(false);
67
+ });
68
+ return () => { cancelled = true; clearTimeout(releaseTimer); };
54
69
  }, [projectName, effectiveId]);
55
70
 
56
71
  // Debounced save (1s)
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Drag-to-select for xterm on touch devices.
3
+ *
4
+ * xterm has no touch selection at all: `SelectionService` is driven by a
5
+ * `mousedown` on the `.xterm` element followed by `mousemove`/`mouseup` on the
6
+ * owning document, and nothing translates touch into those. This hook feeds it
7
+ * synthetic mouse events from a one-finger drag, so selection, its highlight and
8
+ * `term.getSelection()` all behave exactly as they do with a mouse.
9
+ *
10
+ * Only active while the caller has select mode switched on — the same gesture
11
+ * otherwise scrolls the viewport, and silently stealing it would strand the user
12
+ * with no way to scroll back.
13
+ */
14
+ import { useEffect, type RefObject } from "react";
15
+
16
+ function mouseEventFrom(type: string, touch: Touch, buttons: number): MouseEvent {
17
+ return new MouseEvent(type, {
18
+ bubbles: true,
19
+ cancelable: true,
20
+ view: window,
21
+ clientX: touch.clientX,
22
+ clientY: touch.clientY,
23
+ screenX: touch.screenX,
24
+ screenY: touch.screenY,
25
+ button: 0,
26
+ buttons,
27
+ // Click count. xterm branches on it to pick single/word/line selection and
28
+ // ignores the event entirely at the default of 0, so a drag would start no
29
+ // selection at all without this.
30
+ detail: 1,
31
+ });
32
+ }
33
+
34
+ export function useTerminalTouchSelection(
35
+ containerRef: RefObject<HTMLDivElement | null>,
36
+ enabled: boolean,
37
+ ) {
38
+ useEffect(() => {
39
+ const container = containerRef.current;
40
+ if (!enabled || !container) return;
41
+
42
+ // `.xterm` is where SelectionService listens for mousedown; dispatching on
43
+ // the screen element would work too but this keeps the target explicit.
44
+ const xterm = container.querySelector(".xterm");
45
+ if (!xterm) return;
46
+
47
+ // Capture phase so these run before xterm's own document-level touch
48
+ // gesture handler, and stopPropagation keeps that handler from scrolling
49
+ // the viewport out from under the drag.
50
+ const onTouchStart = (e: TouchEvent) => {
51
+ const touch = e.touches[0];
52
+ if (!touch || e.touches.length > 1) return;
53
+ e.preventDefault();
54
+ e.stopPropagation();
55
+ xterm.dispatchEvent(mouseEventFrom("mousedown", touch, 1));
56
+ };
57
+
58
+ const onTouchMove = (e: TouchEvent) => {
59
+ const touch = e.touches[0];
60
+ if (!touch) return;
61
+ e.preventDefault();
62
+ e.stopPropagation();
63
+ document.dispatchEvent(mouseEventFrom("mousemove", touch, 1));
64
+ };
65
+
66
+ const onTouchEnd = (e: TouchEvent) => {
67
+ const touch = e.changedTouches[0];
68
+ if (!touch) return;
69
+ e.preventDefault();
70
+ e.stopPropagation();
71
+ document.dispatchEvent(mouseEventFrom("mouseup", touch, 0));
72
+ };
73
+
74
+ const opts = { capture: true, passive: false } as const;
75
+ container.addEventListener("touchstart", onTouchStart, opts);
76
+ container.addEventListener("touchmove", onTouchMove, opts);
77
+ container.addEventListener("touchend", onTouchEnd, opts);
78
+ container.addEventListener("touchcancel", onTouchEnd, opts);
79
+
80
+ return () => {
81
+ container.removeEventListener("touchstart", onTouchStart, opts);
82
+ container.removeEventListener("touchmove", onTouchMove, opts);
83
+ container.removeEventListener("touchend", onTouchEnd, opts);
84
+ container.removeEventListener("touchcancel", onTouchEnd, opts);
85
+ };
86
+ }, [containerRef, enabled]);
87
+ }
@@ -34,9 +34,17 @@ interface UseTerminalReturn {
34
34
  getSelection: () => string;
35
35
  /** Read buffer from last command start to current cursor (for "Send to Chat"). */
36
36
  getLastCommandOutput: () => string;
37
+ /** URLs present in the scrollback, most recent first. */
38
+ getBufferUrls: () => string[];
37
39
  restart: () => void;
38
40
  }
39
41
 
42
+ /** Trailing punctuation that ends a sentence rather than the URL itself. */
43
+ const URL_TRAILING_PUNCT = /[).,;:'"\]}>]+$/;
44
+ const URL_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/g;
45
+ /** Cap the scan so a 50k-line scrollback never blocks the tap that triggered it. */
46
+ const URL_SCAN_MAX_ROWS = 5000;
47
+
40
48
  const RESIZE_PREFIX = "\x01RESIZE:";
41
49
  const PING_MSG = "\x01PING";
42
50
  const PONG_MSG = "\x01PONG";
@@ -101,6 +109,50 @@ export function useTerminal(
101
109
  return lines.join("\n");
102
110
  }, []);
103
111
 
112
+ /**
113
+ * Collect URLs from the scrollback.
114
+ *
115
+ * xterm's link addon activates a link from a mouse hover followed by mouseup,
116
+ * neither of which a touch device produces, so on mobile this list is the only
117
+ * way to reach a URL the shell printed (an `aws sso login` code, for example).
118
+ * Wrapped lines are joined first — a URL long enough to matter is usually the
119
+ * one that got split across rows.
120
+ */
121
+ const getBufferUrls = useCallback(() => {
122
+ const term = termRef.current;
123
+ if (!term) return [];
124
+ const buf = term.buffer.active;
125
+ const endRow = buf.baseY + buf.cursorY;
126
+ const startRow = Math.max(0, endRow - URL_SCAN_MAX_ROWS);
127
+
128
+ const logical: string[] = [];
129
+ let current = "";
130
+ for (let i = startRow; i <= endRow; i++) {
131
+ const line = buf.getLine(i);
132
+ if (!line) continue;
133
+ current += line.translateToString(true);
134
+ // isWrapped marks a row as the continuation of the previous one, so the
135
+ // break is a display artefact and must not split the URL.
136
+ const next = buf.getLine(i + 1);
137
+ if (next?.isWrapped) continue;
138
+ logical.push(current);
139
+ current = "";
140
+ }
141
+ if (current) logical.push(current);
142
+
143
+ const seen = new Set<string>();
144
+ const urls: string[] = [];
145
+ for (const text of logical) {
146
+ for (const match of text.match(URL_PATTERN) ?? []) {
147
+ const url = match.replace(URL_TRAILING_PUNCT, "");
148
+ if (!url || seen.has(url)) continue;
149
+ seen.add(url);
150
+ urls.push(url);
151
+ }
152
+ }
153
+ return urls.reverse();
154
+ }, []);
155
+
104
156
  const sendResize = useCallback(() => {
105
157
  const term = termRef.current;
106
158
  const ws = wsRef.current;
@@ -344,5 +396,5 @@ export function useTerminal(
344
396
  };
345
397
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
346
398
 
347
- return { connected, reconnecting, exited, sendData, getSelection, getLastCommandOutput, restart };
399
+ return { connected, reconnecting, exited, sendData, getSelection, getLastCommandOutput, getBufferUrls, restart };
348
400
  }
@@ -16,11 +16,30 @@ function warnOnAuditFailure(res: Response): void {
16
16
  /** GETs currently awaiting a response, keyed by absolute URL. See ApiClient.get. */
17
17
  const pendingGets = new Map<string, Promise<unknown>>();
18
18
 
19
- class ApiClient {
19
+ /**
20
+ * How long a GET may wait for response headers. Browsers do not time a stalled
21
+ * fetch out on their own, and a request that never settles keeps its
22
+ * `pendingGets` slot forever — every later caller of that URL then inherits the
23
+ * stall, and UI gated on the response stays hidden until a page reload.
24
+ *
25
+ * The clock stops once headers arrive, so transcripts, file reads and other
26
+ * large bodies may still take as long as the connection needs. Generous enough
27
+ * for endpoints that spawn a CLI or hit a registry before answering.
28
+ */
29
+ const RESPONSE_TIMEOUT_MS = 30_000;
30
+
31
+ /** `reason` is only guaranteed on newer engines; never propagate `undefined`. */
32
+ function abortReason(signal: AbortSignal): unknown {
33
+ return signal.reason ?? new DOMException("Aborted", "AbortError");
34
+ }
35
+
36
+ export class ApiClient {
20
37
  private baseUrl: string;
38
+ private responseTimeoutMs: number;
21
39
 
22
- constructor(baseUrl = "") {
40
+ constructor(baseUrl = "", responseTimeoutMs = RESPONSE_TIMEOUT_MS) {
23
41
  this.baseUrl = baseUrl;
42
+ this.responseTimeoutMs = responseTimeoutMs;
24
43
  }
25
44
 
26
45
  private getToken(): string | null {
@@ -61,11 +80,35 @@ class ApiClient {
61
80
  }
62
81
 
63
82
  private async rawGet<T>(path: string, signal?: AbortSignal): Promise<T> {
64
- const res = await fetch(`${this.baseUrl}${path}`, {
65
- headers: this.headers(),
66
- signal,
67
- });
68
- return this.handleResponse<T>(res);
83
+ if (signal?.aborted) throw abortReason(signal);
84
+
85
+ const controller = new AbortController();
86
+ const timer = setTimeout(
87
+ () =>
88
+ controller.abort(
89
+ new DOMException(
90
+ `No response after ${this.responseTimeoutMs}ms: ${path}`,
91
+ "TimeoutError",
92
+ ),
93
+ ),
94
+ this.responseTimeoutMs,
95
+ );
96
+ const forwardAbort = () => controller.abort(abortReason(signal!));
97
+ signal?.addEventListener("abort", forwardAbort, { once: true });
98
+
99
+ try {
100
+ const res = await fetch(`${this.baseUrl}${path}`, {
101
+ headers: this.headers(),
102
+ signal: controller.signal,
103
+ });
104
+ // Headers are in, so the connection answered — stop the clock before
105
+ // reading the body, which may legitimately be large and slow.
106
+ clearTimeout(timer);
107
+ return await this.handleResponse<T>(res);
108
+ } finally {
109
+ clearTimeout(timer);
110
+ signal?.removeEventListener("abort", forwardAbort);
111
+ }
69
112
  }
70
113
 
71
114
  async post<T>(path: string, body?: unknown): Promise<T> {
@@ -1 +0,0 @@
1
- import"./vendor-markdown-CKTlmXbz.js";import"./vendor-ui-DMRGWRuc.js";import{r as e,t}from"./api-client-DhXpjLdi.js";export{t as api,e as projectUrl};
@@ -1 +0,0 @@
1
- import"./vendor-markdown-CKTlmXbz.js";import"./vendor-ui-DMRGWRuc.js";import"./api-client-DhXpjLdi.js";import{v as e}from"./api-settings-BUYvGBdi.js";export{e as updateDeviceName};