@hienlh/ppm 0.17.30 → 0.17.31

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 (72) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -1
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/http-api.md +3 -1
  5. package/dist/web/assets/{ai-resource-editor-D1efMh5f.js → ai-resource-editor-ByZApv3Y.js} +1 -1
  6. package/dist/web/assets/{audio-preview-BfaIfDsY.js → audio-preview-izB0KGPQ.js} +1 -1
  7. package/dist/web/assets/chat-tab-CwWL5DbD.js +13 -0
  8. package/dist/web/assets/{code-editor-hKFw2M2d.js → code-editor-Bx-Q27Nm.js} +2 -2
  9. package/dist/web/assets/{conflict-editor-BRDEJEq6.js → conflict-editor-B60UimS8.js} +1 -1
  10. package/dist/web/assets/{csv-preview-D32vaKWU.js → csv-preview-BZv2SuCz.js} +1 -1
  11. package/dist/web/assets/{database-viewer-B-ukNIhW.js → database-viewer-B5J_4lvX.js} +1 -1
  12. package/dist/web/assets/{diff-viewer-DfFzvZND.js → diff-viewer-DiytoNvX.js} +1 -1
  13. package/dist/web/assets/{docx-preview-B1kTBbEc.js → docx-preview-CrjmF16z.js} +1 -1
  14. package/dist/web/assets/{edit-diff-preview-DDLG3m68.js → edit-diff-preview-BSjgyGAL.js} +5 -10
  15. package/dist/web/assets/{extension-webview-mQPWwlxb.js → extension-webview-BSiQUxFD.js} +1 -1
  16. package/dist/web/assets/{git-log-panel-CIB6xuoc.js → git-log-panel-Fb5EefXW.js} +1 -1
  17. package/dist/web/assets/{glide-data-grid-DM0v58AP.js → glide-data-grid-Cjve7mDb.js} +1 -1
  18. package/dist/web/assets/{group-chat-tab-pfDZpGO_.js → group-chat-tab-DT0X7Cbz.js} +1 -1
  19. package/dist/web/assets/{image-preview-Dxvk6fiw.js → image-preview-BiVYVqa9.js} +1 -1
  20. package/dist/web/assets/index-BW2cOdzp.css +2 -0
  21. package/dist/web/assets/index-CuAijZGh.js +50 -0
  22. package/dist/web/assets/{keybindings-store-BDQFSrPz.js → keybindings-store-sakq3gsF.js} +1 -1
  23. package/dist/web/assets/libesm-Dd8zbj1v.js +6 -0
  24. package/dist/web/assets/{markdown-renderer-TCeFZDxj.js → markdown-renderer-BsKIIM83.js} +1 -1
  25. package/dist/web/assets/{markdown-renderer-zRn368SN.js → markdown-renderer-Cct9JHnx.js} +1 -1
  26. package/dist/web/assets/{notification-store-BcEM83wQ.js → notification-store-D4TsAnN3.js} +1 -1
  27. package/dist/web/assets/{pdf-preview-Bx_RsWdB.js → pdf-preview-B6G1QpE6.js} +1 -1
  28. package/dist/web/assets/{postgres-viewer-DJXDXZbE.js → postgres-viewer-CdFu2Yj9.js} +1 -1
  29. package/dist/web/assets/{settings-store-UwT_fNhg.js → settings-store-DFMz_Oer.js} +2 -2
  30. package/dist/web/assets/settings-tab-Dh3bD_dg.js +1 -0
  31. package/dist/web/assets/{sql-query-editor-UDQo7Bck.js → sql-query-editor-C8XXOY2R.js} +1 -1
  32. package/dist/web/assets/{sqlite-viewer-BaQq64OX.js → sqlite-viewer-B1eXvaLK.js} +1 -1
  33. package/dist/web/assets/{system-monitor-tab-P1uy84Os.js → system-monitor-tab-D2QcCXl1.js} +1 -1
  34. package/dist/web/assets/{terminal-tab-mI-vAmOV.js → terminal-tab-D2-buBAQ.js} +1 -1
  35. package/dist/web/assets/tool-cards-DDxShmxN.js +7 -0
  36. package/dist/web/assets/{use-is-mobile-CE6AamG6.js → use-is-mobile-BcuCjH19.js} +1 -1
  37. package/dist/web/assets/{use-monaco-theme-CZKIw2ZG.js → use-monaco-theme-DMlVAYKD.js} +1 -1
  38. package/dist/web/assets/{video-preview-TGDSUs0J.js → video-preview-Cg3A7vrt.js} +1 -1
  39. package/dist/web/index.html +4 -4
  40. package/dist/web/sw.js +1 -1
  41. package/package.json +1 -1
  42. package/skills-lock.json +11 -0
  43. package/spike-memory-region-dump.ps1 +69 -0
  44. package/src/server/index.ts +6 -0
  45. package/src/server/routes/cloud.ts +35 -0
  46. package/src/web/components/chat/change-edit-list.tsx +50 -0
  47. package/src/web/components/chat/change-file-row.tsx +99 -0
  48. package/src/web/components/chat/jump-to-edit.ts +89 -0
  49. package/src/web/components/chat/message-list.tsx +50 -3
  50. package/src/web/components/chat/tool-cards.tsx +16 -6
  51. package/src/web/components/chat/turn-change-pill.tsx +57 -0
  52. package/src/web/components/chat/turn-change-rollup.tsx +76 -0
  53. package/src/web/components/chat/turn-change-sheet.tsx +178 -0
  54. package/src/web/components/chat/turn-change-tray.tsx +140 -0
  55. package/src/web/components/layout/cloud-alias-row.tsx +163 -0
  56. package/src/web/components/layout/cloud-share-popover.tsx +32 -20
  57. package/src/web/components/layout/command-palette.tsx +2 -0
  58. package/src/web/components/layout/mobile-drawer.tsx +10 -0
  59. package/src/web/components/layout/nav-section-rail.tsx +9 -0
  60. package/src/web/components/settings/settings-tab.tsx +4 -4
  61. package/src/web/lib/aggregate-turn-file-changes.ts +193 -0
  62. package/src/web/styles/globals.css +16 -0
  63. package/dist/web/assets/chat-tab-CVfcHLiQ.js +0 -13
  64. package/dist/web/assets/index-Cp6hb7uj.js +0 -50
  65. package/dist/web/assets/index-DCJ29LBG.css +0 -2
  66. package/dist/web/assets/settings-tab-CAJ4skP0.js +0 -1
  67. package/dist/web/assets/tool-cards-B6jz0GZE.js +0 -7
  68. /package/dist/web/assets/{api-themes-eWBIYCbh.js → api-themes-BpoKUPkQ.js} +0 -0
  69. /package/dist/web/assets/{csv-parser-BOlh-1bz.js → csv-parser-BSnYgPcx.js} +0 -0
  70. /package/dist/web/assets/{lib-CvYn0SaB.js → lib-DA3QSc5_.js} +0 -0
  71. /package/dist/web/assets/{use-blob-url-BMZ0zo_4.js → use-blob-url-kvtWmJtt.js} +0 -0
  72. /package/dist/web/assets/{vendor-xterm-Dlg984wj.js → vendor-xterm-CsgYwG03.js} +0 -0
@@ -5,6 +5,13 @@ import type { ChatMessage, ChatEvent } from "../../../types/chat";
5
5
  import type { SessionPhase } from "../../../types/api";
6
6
  import type { BashPartialEntry } from "../../hooks/use-chat";
7
7
  import { ToolCard } from "./tool-cards";
8
+ import { jumpToEdit } from "./jump-to-edit";
9
+ import {
10
+ aggregateTurnFileChanges,
11
+ collectTurnMessages,
12
+ type TurnFileChange,
13
+ } from "@/lib/aggregate-turn-file-changes";
14
+ import { TurnChangeRollup } from "./turn-change-rollup";
8
15
  import { TaskTracker } from "./task-tracker";
9
16
  import { extractJsonlPath } from "./pre-compact-button";
10
17
  // Kick off the markdown chunk fetch at module load (not first render): Suspense
@@ -153,6 +160,40 @@ export function MessageList({
153
160
  setScrollEl(el);
154
161
  }, [scrollRef]);
155
162
 
163
+ // File changes per turn, keyed by the index of the turn's last assistant message.
164
+ //
165
+ // `filtered` is a new array on every streamed token, so this must not re-aggregate
166
+ // the whole transcript each time — diffing every edit in a long session at token
167
+ // rate is far too slow. Completed turns are therefore cached by their last
168
+ // message's identity, leaving only the in-flight turn to recompute.
169
+ const turnChangeCache = useRef(new Map<string, TurnFileChange[]>());
170
+ const turnChanges = useMemo(() => {
171
+ const cache = turnChangeCache.current;
172
+ const out = new Map<number, TurnFileChange[]>();
173
+ for (let i = 0; i < filtered.length; i++) {
174
+ const msg = filtered[i]!;
175
+ if (msg.role !== "assistant" || filtered[i + 1]?.role === "assistant") continue;
176
+ const key = `${msg.id}:${msg.events?.length ?? 0}`;
177
+ let changes = cache.get(key);
178
+ if (!changes) {
179
+ changes = aggregateTurnFileChanges(collectTurnMessages(filtered, i));
180
+ cache.set(key, changes);
181
+ }
182
+ if (changes.length > 0) out.set(i, changes);
183
+ }
184
+ return out;
185
+ }, [filtered]);
186
+
187
+ // Jump from a change-tray row to the tool card that made the edit. The returned
188
+ // cleanup clears the pending flash, so a second jump can't leave a stale highlight.
189
+ const flashCleanupRef = useRef<(() => void) | null>(null);
190
+ const handleJumpToEdit = useCallback((editRef: string) => {
191
+ if (!scrollEl) return;
192
+ flashCleanupRef.current?.();
193
+ flashCleanupRef.current = jumpToEdit(scrollEl, editRef);
194
+ }, [scrollEl]);
195
+ useEffect(() => () => flashCleanupRef.current?.(), []);
196
+
156
197
  // Preserve the viewport when older messages are prepended (compact expand): capture
157
198
  // distance-from-bottom before the prepend, restore scrollTop after so the content
158
199
  // being read doesn't jump. Only fires for prepends — streaming appends leave the
@@ -360,6 +401,8 @@ export function MessageList({
360
401
  isStreaming={isStreaming && msg.id.startsWith("streaming-")}
361
402
  isLastAssistantInTurn={isLastAssistantInTurn}
362
403
  turnCopyText={turnCopyText}
404
+ turnChanges={isLastAssistantInTurn ? turnChanges.get(globalIdx) : undefined}
405
+ onJumpToEdit={handleJumpToEdit}
363
406
  projectName={projectName}
364
407
  onFork={msg.role === "user" && onFork ? handleFork : undefined}
365
408
  onEdit={msg.role === "user" && onEdit ? handleEdit : undefined}
@@ -402,8 +445,11 @@ function assistantMessageText(msg: ChatMessage): string {
402
445
  : msg.content;
403
446
  }
404
447
 
405
- const MessageBubble = memo(function MessageBubble({ message, isStreaming, isLastAssistantInTurn, turnCopyText, projectName, onFork, onEdit, isEditing, onDismiss, prevMsgId, sessionId, providerId, versionGroup, onNavigateVersion, versionNavDisabled, bashPartialOutput }: {
448
+ const MessageBubble = memo(function MessageBubble({ message, isStreaming, isLastAssistantInTurn, turnCopyText, turnChanges, onJumpToEdit, projectName, onFork, onEdit, isEditing, onDismiss, prevMsgId, sessionId, providerId, versionGroup, onNavigateVersion, versionNavDisabled, bashPartialOutput }: {
406
449
  message: ChatMessage; isStreaming: boolean; isLastAssistantInTurn?: boolean; turnCopyText?: string; projectName?: string;
450
+ /** Files this turn changed — drives the action-bar change pill. */
451
+ turnChanges?: TurnFileChange[];
452
+ onJumpToEdit?: (editRef: string) => void;
407
453
  onFork?: (content: string, messageId: string | undefined) => void;
408
454
  onEdit?: (content: string, messageId: string | undefined, ownMsgId?: string) => void;
409
455
  isEditing?: boolean;
@@ -469,11 +515,12 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, isLast
469
515
  )}
470
516
  {/* Action bar: only on the last assistant message of the turn, after streaming ends */}
471
517
  {!isStreaming && isLastAssistantInTurn && (
472
- <MessageActionBar
518
+ <TurnChangeRollup
473
519
  timestamp={message.timestamp}
474
520
  content={turnCopyText ?? assistantMessageText(message)}
475
521
  accountLabel={message.accountLabel}
476
- className="-mt-1.5"
522
+ changes={turnChanges}
523
+ onJumpToEdit={onJumpToEdit}
477
524
  />
478
525
  )}
479
526
  </div>
@@ -154,7 +154,10 @@ export function ToolCard({
154
154
  const isInteractive = toolName === "AskUserQuestion" || toolName === "ExitPlanMode" || isSubagent;
155
155
 
156
156
  return (
157
- <div className={`rounded-[11px] border overflow-hidden text-xs bg-panel ${isInteractive ? "border-accent-wash-border" : "border-border"}`}>
157
+ <div
158
+ data-tool-ref={toolUseId}
159
+ className={`rounded-[11px] border overflow-hidden text-xs bg-panel ${isInteractive ? "border-accent-wash-border" : "border-border"}`}
160
+ >
158
161
  <button
159
162
  onClick={() => setExpanded(!expanded)}
160
163
  className="flex items-center gap-2.5 px-2.5 py-2 w-full text-left hover:bg-panel-2/40 transition-colors min-w-0"
@@ -183,7 +186,7 @@ export function ToolCard({
183
186
  {expanded && (
184
187
  <div className="px-2 pb-2 space-y-1.5 select-text">
185
188
  {(tool.type === "tool_use" || tool.type === "approval_request") && (
186
- <ToolDetails name={toolName} input={input} projectName={projectName} />
189
+ <ToolDetails name={toolName} input={input} projectName={projectName} toolUseId={toolUseId} />
187
190
  )}
188
191
  {/* Streaming bash output */}
189
192
  {partial && <StreamingBashOutput content={partial.content} lineCount={partial.lineCount} />}
@@ -256,10 +259,13 @@ function ToolDetails({
256
259
  name,
257
260
  input,
258
261
  projectName,
262
+ toolUseId,
259
263
  }: {
260
264
  name: string;
261
265
  input: Record<string, unknown>;
262
266
  projectName?: string;
267
+ /** Anchors each rendered edit so the change tray can jump to it. */
268
+ toolUseId?: string;
263
269
  }) {
264
270
  const s = (v: unknown) => String(v ?? "");
265
271
  const { openTab } = useTabStore(useShallow((state) => ({ openTab: state.openTab })));
@@ -301,8 +307,10 @@ function ToolDetails({
301
307
  case "Edit":
302
308
  case "MultiEdit":
303
309
  case "NotebookEdit": {
304
- const filePath = s(input.file_path);
310
+ // NotebookEdit carries `notebook_path`; the other three carry `file_path`.
311
+ const filePath = s(input.notebook_path || input.file_path);
305
312
  const hasEditDiff = name === "Edit" && (!!input.old_string || !!input.new_string);
313
+ const editRef = (i: number) => (toolUseId ? `${toolUseId}-${i}` : undefined);
306
314
  return (
307
315
  <div className="space-y-1">
308
316
  <div className="flex items-start gap-3">
@@ -328,12 +336,14 @@ function ToolDetails({
328
336
  )}
329
337
  </div>
330
338
  {hasEditDiff && (
331
- <EditDiffPreview oldStr={s(input.old_string)} newStr={s(input.new_string)} filePath={filePath} />
339
+ <div data-edit-ref={editRef(0)}>
340
+ <EditDiffPreview oldStr={s(input.old_string)} newStr={s(input.new_string)} filePath={filePath} />
341
+ </div>
332
342
  )}
333
343
  {name === "MultiEdit" && Array.isArray(input.edits) && (
334
344
  <div className="space-y-1.5">
335
345
  {(input.edits as Array<{ old_string?: string; new_string?: string }>).map((e, i) => (
336
- <div key={i} className="space-y-0.5">
346
+ <div key={i} data-edit-ref={editRef(i)} className="space-y-0.5">
337
347
  <p className="text-text-subtle text-[10px]">Edit {i + 1}</p>
338
348
  <EditDiffPreview oldStr={s(e.old_string)} newStr={s(e.new_string)} filePath={filePath} />
339
349
  </div>
@@ -341,7 +351,7 @@ function ToolDetails({
341
351
  </div>
342
352
  )}
343
353
  {name === "Write" && !!input.content && (
344
- <pre className="font-mono text-text-subtle overflow-x-auto max-h-32 whitespace-pre-wrap">{truncate(s(input.content), 300)}</pre>
354
+ <pre data-edit-ref={editRef(0)} className="font-mono text-text-subtle overflow-x-auto max-h-32 whitespace-pre-wrap">{truncate(s(input.content), 300)}</pre>
345
355
  )}
346
356
  </div>
347
357
  );
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Resting state of the turn change rollup: a pill appended to the message action bar.
3
+ *
4
+ * Only the button lives here — the caller owns `open` so the tray can render *below*
5
+ * the action bar instead of inside its flex row.
6
+ */
7
+ import { cn } from "@/lib/utils";
8
+ import type { TurnFileChange } from "@/lib/aggregate-turn-file-changes";
9
+
10
+ export function changeTotals(changes: TurnFileChange[]): { added: number; removed: number } {
11
+ let added = 0;
12
+ let removed = 0;
13
+ for (const c of changes) {
14
+ added += c.linesAdded;
15
+ removed += c.linesRemoved;
16
+ }
17
+ return { added, removed };
18
+ }
19
+
20
+ export function TurnChangePill({ count, totals, open, onToggle, ref }: {
21
+ count: number;
22
+ totals: { added: number; removed: number };
23
+ open: boolean;
24
+ onToggle: () => void;
25
+ /** Lets the caller restore focus here when the tray closes. */
26
+ ref?: React.Ref<HTMLButtonElement>;
27
+ }) {
28
+ const label = `${count} file${count !== 1 ? "s" : ""}`;
29
+
30
+ return (
31
+ <>
32
+ <span aria-hidden className="mx-1.5 h-3.5 w-px bg-border-soft" />
33
+ <button
34
+ ref={ref}
35
+ type="button"
36
+ onClick={onToggle}
37
+ aria-expanded={open}
38
+ aria-label={`${label} changed this turn, ${totals.added} added, ${totals.removed} removed`}
39
+ className={cn(
40
+ // Borderless on purpose: the sibling Copy/Fork controls are ghost buttons, and
41
+ // a bordered pill next to them reads as a heavy block, especially on mobile.
42
+ // The touch height stays — without a border it costs nothing visually.
43
+ "inline-flex items-center gap-[7px] rounded-full font-mono text-[11.5px]",
44
+ "px-3 min-h-11 md:min-h-0 md:px-[9px] md:py-[5px]",
45
+ "text-text-secondary transition-colors hover:bg-surface hover:text-text-primary",
46
+ )}
47
+ >
48
+ <span aria-hidden className="size-[5px] shrink-0 rounded-full bg-primary" />
49
+ <span>{label}</span>
50
+ <span className="flex items-center gap-[5px] tabular-nums">
51
+ <span className="text-success">+{totals.added}</span>
52
+ <span className="text-error">{"−"}{totals.removed}</span>
53
+ </span>
54
+ </button>
55
+ </>
56
+ );
57
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Hosts the turn change rollup: the action bar with the pill appended, plus the tray
3
+ * (desktop) or sheet (phone) it opens.
4
+ *
5
+ * Wraps `MessageActionBar` rather than living inside it, because the desktop tray has
6
+ * to render *below* the bar instead of wrapping inside its flex row.
7
+ */
8
+ import { useRef, useState } from "react";
9
+ import { useIsMobile } from "@/hooks/use-is-mobile";
10
+ import type { TurnFileChange } from "@/lib/aggregate-turn-file-changes";
11
+ import { MessageActionBar } from "./message-action-bar";
12
+ import { TurnChangePill, changeTotals } from "./turn-change-pill";
13
+ import { TurnChangeTray } from "./turn-change-tray";
14
+ import { TurnChangeSheet } from "./turn-change-sheet";
15
+
16
+ export function TurnChangeRollup({ timestamp, content, accountLabel, changes, onJumpToEdit }: {
17
+ timestamp: string;
18
+ content: string;
19
+ accountLabel?: string;
20
+ changes?: TurnFileChange[];
21
+ onJumpToEdit?: (editRef: string) => void;
22
+ }) {
23
+ const [open, setOpen] = useState(false);
24
+ const isMobile = useIsMobile();
25
+ const pillRef = useRef<HTMLButtonElement>(null);
26
+
27
+ // Without this, closing drops focus to <body> and the next Tab restarts from the
28
+ // top of a very long transcript.
29
+ const close = () => {
30
+ setOpen(false);
31
+ pillRef.current?.focus();
32
+ };
33
+
34
+ const bar = (
35
+ <MessageActionBar
36
+ timestamp={timestamp}
37
+ content={content}
38
+ accountLabel={accountLabel}
39
+ className="-mt-1.5"
40
+ >
41
+ {changes && changes.length > 0 && (
42
+ <TurnChangePill
43
+ ref={pillRef}
44
+ count={changes.length}
45
+ totals={changeTotals(changes)}
46
+ open={open}
47
+ onToggle={() => (open ? close() : setOpen(true))}
48
+ />
49
+ )}
50
+ </MessageActionBar>
51
+ );
52
+
53
+ if (!changes || changes.length === 0) return bar;
54
+
55
+ const jump = (editRef: string) => {
56
+ setOpen(false);
57
+ onJumpToEdit?.(editRef);
58
+ };
59
+
60
+ return (
61
+ <>
62
+ {bar}
63
+ {isMobile ? (
64
+ <TurnChangeSheet
65
+ changes={changes}
66
+ totals={changeTotals(changes)}
67
+ open={open}
68
+ onClose={close}
69
+ onJump={onJumpToEdit ?? (() => {})}
70
+ />
71
+ ) : (
72
+ open && <TurnChangeTray changes={changes} onJump={jump} onClose={close} />
73
+ )}
74
+ </>
75
+ );
76
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Phone presentation of the change tray: a modal bottom sheet with push navigation.
3
+ * The file list and a file's edits are separate levels — `‹` pops back to the list
4
+ * rather than closing.
5
+ *
6
+ * The panel is capped; the body scrolls inside it so the sheet itself never does.
7
+ */
8
+ import { useEffect, useRef, useState } from "react";
9
+ import { ArrowUp, ChevronLeft, X } from "lucide-react";
10
+ import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
11
+ import { copyToClipboard } from "@/lib/clipboard";
12
+ import type { TurnFileChange } from "@/lib/aggregate-turn-file-changes";
13
+ import { ChangeCounts, ChangeFileRow } from "./change-file-row";
14
+ import { ChangeEditList } from "./change-edit-list";
15
+
16
+ function basenameOf(path: string): string {
17
+ const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
18
+ return i < 0 ? path : path.slice(i + 1);
19
+ }
20
+
21
+ /** 44×44 icon control — the minimum touch target on a coarse pointer. */
22
+ function IconButton({ label, onClick, children }: {
23
+ label: string;
24
+ onClick: () => void;
25
+ children: React.ReactNode;
26
+ }) {
27
+ return (
28
+ <button
29
+ type="button"
30
+ onClick={onClick}
31
+ aria-label={label}
32
+ className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-text-subtle transition-colors hover:bg-surface hover:text-text-primary"
33
+ >
34
+ {children}
35
+ </button>
36
+ );
37
+ }
38
+
39
+ export function TurnChangeSheet({ changes, totals, open, onClose, onJump }: {
40
+ changes: TurnFileChange[];
41
+ totals: { added: number; removed: number };
42
+ open: boolean;
43
+ onClose: () => void;
44
+ onJump: (editRef: string) => void;
45
+ }) {
46
+ const [selected, setSelected] = useState<number | null>(null);
47
+ const active = selected == null ? null : changes[selected];
48
+ const panelRef = useRef<HTMLDivElement>(null);
49
+
50
+ const close = () => {
51
+ setSelected(null);
52
+ onClose();
53
+ };
54
+
55
+ const jump = (editRef: string) => {
56
+ close();
57
+ onJump(editRef);
58
+ };
59
+
60
+ // Modal on phone: focus moves in and stays in. `BottomSheet` handles the scrim and
61
+ // swipe-to-dismiss but not the keyboard, so Escape and the tab cycle live here.
62
+ useEffect(() => {
63
+ if (!open) return;
64
+ const panel = panelRef.current;
65
+ const focusables = () =>
66
+ Array.from(panel?.querySelectorAll<HTMLElement>("button:not([disabled])") ?? []);
67
+ focusables()[0]?.focus();
68
+
69
+ const onKeyDown = (e: KeyboardEvent) => {
70
+ if (e.key === "Escape") {
71
+ e.preventDefault();
72
+ close();
73
+ return;
74
+ }
75
+ if (e.key !== "Tab") return;
76
+ const items = focusables();
77
+ if (items.length === 0) return;
78
+ const first = items[0]!;
79
+ const last = items[items.length - 1]!;
80
+ if (e.shiftKey && document.activeElement === first) {
81
+ e.preventDefault();
82
+ last.focus();
83
+ } else if (!e.shiftKey && document.activeElement === last) {
84
+ e.preventDefault();
85
+ first.focus();
86
+ }
87
+ };
88
+ window.addEventListener("keydown", onKeyDown);
89
+ return () => window.removeEventListener("keydown", onKeyDown);
90
+ }, [open, selected]); // eslint-disable-line react-hooks/exhaustive-deps
91
+
92
+ return (
93
+ <BottomSheet
94
+ open={open}
95
+ onClose={close}
96
+ className="flex max-h-[85%] flex-col rounded-t-[14px] motion-reduce:animate-none"
97
+ >
98
+ <div ref={panelRef} className="flex min-h-0 flex-1 flex-col">
99
+ {active ? (
100
+ <>
101
+ <div className="flex items-center gap-1 border-b border-border-soft px-1 pb-1">
102
+ <IconButton label="Back to file list" onClick={() => setSelected(null)}>
103
+ <ChevronLeft className="size-4" />
104
+ </IconButton>
105
+ <span className="min-w-0 flex-1 truncate font-mono text-xs font-medium text-text-primary">
106
+ {basenameOf(active.filePath)}
107
+ </span>
108
+ <ChangeCounts
109
+ added={active.linesAdded}
110
+ removed={active.linesRemoved}
111
+ className="shrink-0 font-mono text-[11px]"
112
+ />
113
+ <IconButton label="Close" onClick={close}>
114
+ <X className="size-4" />
115
+ </IconButton>
116
+ </div>
117
+
118
+ <div className="flex items-center gap-1 border-b border-border-soft py-1.5 pl-3 pr-2">
119
+ <span
120
+ title={active.filePath}
121
+ className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[10.5px] text-text-subtle [direction:rtl] text-left"
122
+ >
123
+ {active.filePath}
124
+ </span>
125
+ <button
126
+ type="button"
127
+ onClick={() => void copyToClipboard(active.filePath)}
128
+ className="inline-flex min-h-11 shrink-0 items-center rounded-md px-2.5 font-mono text-[10px] text-text-subtle transition-colors hover:bg-surface hover:text-text-primary"
129
+ >
130
+ copy
131
+ </button>
132
+ {active.edits[0]?.editRef && (
133
+ <button
134
+ type="button"
135
+ onClick={() => jump(active.edits[0]!.editRef!)}
136
+ className="inline-flex min-h-11 shrink-0 items-center gap-1 rounded-md px-2.5 font-mono text-[10px] text-text-subtle transition-colors hover:bg-surface hover:text-primary"
137
+ >
138
+ <ArrowUp className="size-3" />
139
+ card
140
+ </button>
141
+ )}
142
+ </div>
143
+
144
+ <div className="min-h-0 flex-1 overflow-y-auto">
145
+ <ChangeEditList change={active} onJump={jump} />
146
+ </div>
147
+ </>
148
+ ) : (
149
+ <>
150
+ <div className="flex items-center gap-2 border-b border-border-soft px-3 pb-2">
151
+ <span className="min-w-0 flex-1 truncate font-mono text-xs font-medium text-text-primary">
152
+ Changed this turn · {changes.length} file{changes.length !== 1 ? "s" : ""}
153
+ </span>
154
+ <ChangeCounts
155
+ added={totals.added}
156
+ removed={totals.removed}
157
+ className="shrink-0 font-mono text-[11px]"
158
+ />
159
+ <IconButton label="Close" onClick={close}>
160
+ <X className="size-4" />
161
+ </IconButton>
162
+ </div>
163
+
164
+ <div className="min-h-0 flex-1 overflow-y-auto">
165
+ {changes.map((change, i) => (
166
+ <ChangeFileRow
167
+ key={change.filePath}
168
+ change={change}
169
+ onClick={() => setSelected(i)}
170
+ />
171
+ ))}
172
+ </div>
173
+ </>
174
+ )}
175
+ </div>
176
+ </BottomSheet>
177
+ );
178
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Desktop presentation of the change tray: an inline, non-modal two-pane panel
3
+ * directly below the message's action bar. Left pane lists the turn's files, right
4
+ * pane shows the selected file's edits.
5
+ *
6
+ * Both panes need `min-h-0` or the grid rows refuse to shrink and neither scrolls.
7
+ */
8
+ import { useEffect, useId, useRef, useState } from "react";
9
+ import { X } from "lucide-react";
10
+ import { copyToClipboard } from "@/lib/clipboard";
11
+ import { ownsGlobalShortcut } from "@/lib/owns-global-shortcut";
12
+ import type { TurnFileChange } from "@/lib/aggregate-turn-file-changes";
13
+ import { ChangeCounts, ChangeFileRow } from "./change-file-row";
14
+ import { ChangeEditList } from "./change-edit-list";
15
+
16
+ function splitPath(path: string): { base: string; dir: string } {
17
+ const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
18
+ return i < 0 ? { base: path, dir: "" } : { base: path.slice(i + 1), dir: path.slice(0, i) };
19
+ }
20
+
21
+ /**
22
+ * Several messages in one chat can have a tray open at once, and `ownsGlobalShortcut`
23
+ * only narrows to the focused *tab*. The most recently opened tray claims the keys.
24
+ */
25
+ let activeTrayId: string | null = null;
26
+
27
+ /** `ownsGlobalShortcut` resolves the owning tab; it says nothing about text entry. */
28
+ function isTextEntry(el: Element | null): boolean {
29
+ if (!(el instanceof HTMLElement)) return false;
30
+ return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable;
31
+ }
32
+
33
+ export function TurnChangeTray({ changes, onJump, onClose }: {
34
+ changes: TurnFileChange[];
35
+ onJump: (editRef: string) => void;
36
+ onClose: () => void;
37
+ }) {
38
+ // The desktop tray never shows a bare list — a file is always selected.
39
+ const [selected, setSelected] = useState(0);
40
+ const containerRef = useRef<HTMLDivElement>(null);
41
+ const listRef = useRef<HTMLDivElement>(null);
42
+ const trayId = useId();
43
+
44
+ // Inline and non-modal: focus moves in, but is deliberately not trapped.
45
+ useEffect(() => {
46
+ activeTrayId = trayId;
47
+ listRef.current?.querySelector<HTMLElement>("button")?.focus();
48
+ return () => {
49
+ if (activeTrayId === trayId) activeTrayId = null;
50
+ };
51
+ }, [trayId]);
52
+
53
+ useEffect(() => {
54
+ const onKeyDown = (e: KeyboardEvent) => {
55
+ if (activeTrayId !== trayId) return;
56
+ if (!ownsGlobalShortcut(containerRef.current)) return;
57
+ if (isTextEntry(document.activeElement)) return;
58
+
59
+ if (e.key === "Escape") {
60
+ e.preventDefault();
61
+ onClose();
62
+ } else if (e.key === "j") {
63
+ e.preventDefault();
64
+ setSelected((i) => Math.min(i + 1, changes.length - 1));
65
+ } else if (e.key === "k") {
66
+ e.preventDefault();
67
+ setSelected((i) => Math.max(i - 1, 0));
68
+ } else if (e.key === "Enter") {
69
+ const editRef = changes[selected]?.edits[0]?.editRef;
70
+ if (editRef) {
71
+ e.preventDefault();
72
+ onJump(editRef);
73
+ }
74
+ }
75
+ };
76
+ window.addEventListener("keydown", onKeyDown);
77
+ return () => window.removeEventListener("keydown", onKeyDown);
78
+ }, [trayId, changes, selected, onJump, onClose]);
79
+
80
+ const active = changes[Math.min(selected, changes.length - 1)];
81
+ if (!active) return null;
82
+ const { base, dir } = splitPath(active.filePath);
83
+
84
+ return (
85
+ <div ref={containerRef} className="mt-0.5 overflow-hidden rounded-[10px] border border-border-soft bg-bg">
86
+ <div className="grid h-[356px] grid-cols-[294px_1fr] grid-rows-[minmax(0,1fr)] bg-surface-elevated">
87
+ <div ref={listRef} className="min-h-0 overflow-y-auto border-r border-border-soft">
88
+ {changes.map((change, i) => (
89
+ <ChangeFileRow
90
+ key={change.filePath}
91
+ change={change}
92
+ dense
93
+ selected={i === selected}
94
+ onClick={() => setSelected(i)}
95
+ />
96
+ ))}
97
+ <p className="px-2.5 pt-2 pb-2.5 font-mono text-[10px] text-text-subtle">
98
+ j / k to move · enter to jump
99
+ </p>
100
+ </div>
101
+
102
+ <div className="min-h-0 overflow-y-auto">
103
+ <div className="sticky top-0 z-10 flex items-center gap-2 border-b border-border-soft bg-surface-elevated py-[5px] pl-2.5 pr-2">
104
+ <span className="shrink-0 font-mono text-xs font-medium text-text-primary">{base}</span>
105
+ {dir && (
106
+ <span
107
+ title={active.filePath}
108
+ className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[10.5px] text-text-subtle [direction:rtl] text-left"
109
+ >
110
+ {dir}
111
+ </span>
112
+ )}
113
+ <ChangeCounts
114
+ added={active.linesAdded}
115
+ removed={active.linesRemoved}
116
+ className="shrink-0 font-mono text-[11px]"
117
+ />
118
+ <button
119
+ type="button"
120
+ onClick={() => void copyToClipboard(active.filePath)}
121
+ className="shrink-0 rounded-md px-1.5 py-1 font-mono text-[10px] text-text-subtle transition-colors hover:bg-surface hover:text-text-primary"
122
+ >
123
+ copy path
124
+ </button>
125
+ <button
126
+ type="button"
127
+ onClick={onClose}
128
+ aria-label="Close change tray"
129
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-text-subtle transition-colors hover:bg-surface hover:text-text-primary"
130
+ >
131
+ <X className="size-3.5" />
132
+ </button>
133
+ </div>
134
+
135
+ <ChangeEditList change={active} onJump={onJump} />
136
+ </div>
137
+ </div>
138
+ </div>
139
+ );
140
+ }