@hienlh/ppm 0.17.29 → 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 (94) hide show
  1. package/CHANGELOG.md +17 -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-BCgyv1KF.js → ai-resource-editor-ByZApv3Y.js} +1 -1
  6. package/dist/web/assets/{audio-preview-BPuiX8ma.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-Bx-Q27Nm.js +10 -0
  9. package/dist/web/assets/{conflict-editor-BuLnwxFE.js → conflict-editor-B60UimS8.js} +1 -1
  10. package/dist/web/assets/{csv-preview-DdA_IpTo.js → csv-preview-BZv2SuCz.js} +1 -1
  11. package/dist/web/assets/{database-viewer-BcF3uuk5.js → database-viewer-B5J_4lvX.js} +1 -1
  12. package/dist/web/assets/{diff-viewer-tyMHX20V.js → diff-viewer-DiytoNvX.js} +1 -1
  13. package/dist/web/assets/{docx-preview-BTclTd2v.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-CBLWXhjt.js → extension-webview-BSiQUxFD.js} +1 -1
  16. package/dist/web/assets/{git-log-panel-tmGgY2kK.js → git-log-panel-Fb5EefXW.js} +1 -1
  17. package/dist/web/assets/glide-data-grid-Cjve7mDb.js +138 -0
  18. package/dist/web/assets/group-chat-tab-DT0X7Cbz.js +1 -0
  19. package/dist/web/assets/{image-preview-CQ57RN6l.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-sakq3gsF.js +1 -0
  23. package/dist/web/assets/libesm-Dd8zbj1v.js +6 -0
  24. package/dist/web/assets/{markdown-renderer-devgS0zw.js → markdown-renderer-BsKIIM83.js} +2 -2
  25. package/dist/web/assets/{markdown-renderer-C7clwuDe.js → markdown-renderer-Cct9JHnx.js} +1 -1
  26. package/dist/web/assets/notification-store-D4TsAnN3.js +1 -0
  27. package/dist/web/assets/{pdf-preview-Cw_GRd7e.js → pdf-preview-B6G1QpE6.js} +1 -1
  28. package/dist/web/assets/{postgres-viewer-DN40Bdc-.js → postgres-viewer-CdFu2Yj9.js} +2 -2
  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-Df1pzXAj.js → sql-query-editor-C8XXOY2R.js} +1 -1
  32. package/dist/web/assets/{sqlite-viewer-Dbh3LluT.js → sqlite-viewer-B1eXvaLK.js} +1 -1
  33. package/dist/web/assets/{system-monitor-tab-bbct9aEk.js → system-monitor-tab-D2QcCXl1.js} +1 -1
  34. package/dist/web/assets/terminal-tab-D2-buBAQ.js +3 -0
  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-Cqo17yKA.js → use-monaco-theme-DMlVAYKD.js} +1 -1
  38. package/dist/web/assets/{video-preview-kZgggFAn.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/src/server/index.ts +6 -0
  43. package/src/server/routes/cloud.ts +35 -0
  44. package/src/web/components/chat/account-dialogs.tsx +3 -3
  45. package/src/web/components/chat/change-edit-list.tsx +50 -0
  46. package/src/web/components/chat/change-file-row.tsx +99 -0
  47. package/src/web/components/chat/chat-history-bar.tsx +4 -2
  48. package/src/web/components/chat/jump-to-edit.ts +89 -0
  49. package/src/web/components/chat/message-action-bar.tsx +2 -1
  50. package/src/web/components/chat/message-list.tsx +50 -3
  51. package/src/web/components/chat/tool-cards.tsx +16 -6
  52. package/src/web/components/chat/turn-change-pill.tsx +57 -0
  53. package/src/web/components/chat/turn-change-rollup.tsx +76 -0
  54. package/src/web/components/chat/turn-change-sheet.tsx +178 -0
  55. package/src/web/components/chat/turn-change-tray.tsx +140 -0
  56. package/src/web/components/database/connection-import-export.tsx +5 -2
  57. package/src/web/components/database/export-button.tsx +4 -3
  58. package/src/web/components/explorer/file-tree.tsx +3 -2
  59. package/src/web/components/layout/cloud-alias-row.tsx +163 -0
  60. package/src/web/components/layout/cloud-share-popover.tsx +34 -21
  61. package/src/web/components/layout/command-palette.tsx +2 -0
  62. package/src/web/components/layout/mobile-drawer.tsx +10 -0
  63. package/src/web/components/layout/mobile-nav.tsx +3 -2
  64. package/src/web/components/layout/nav-section-rail.tsx +9 -0
  65. package/src/web/components/layout/project-bottom-sheet.tsx +2 -1
  66. package/src/web/components/layout/project-switcher.tsx +2 -1
  67. package/src/web/components/layout/tab-bar.tsx +3 -2
  68. package/src/web/components/settings/accounts-settings-section.tsx +3 -3
  69. package/src/web/components/settings/proxy-settings-section.tsx +8 -7
  70. package/src/web/components/settings/settings-tab.tsx +4 -4
  71. package/src/web/components/shared/bug-report-popup.tsx +2 -1
  72. package/src/web/components/shared/markdown-code-block.tsx +3 -2
  73. package/src/web/components/terminal/terminal-tab.tsx +2 -1
  74. package/src/web/components/tunnels/tunnel-manager-tab.tsx +4 -2
  75. package/src/web/lib/aggregate-turn-file-changes.ts +193 -0
  76. package/src/web/lib/clipboard.ts +52 -0
  77. package/src/web/lib/report-bug.ts +0 -10
  78. package/src/web/styles/globals.css +16 -0
  79. package/dist/web/assets/chat-tab-WnnM6GKA.js +0 -13
  80. package/dist/web/assets/code-editor-CnGUyOuU.js +0 -10
  81. package/dist/web/assets/glide-data-grid-CkCW7sFw.js +0 -138
  82. package/dist/web/assets/group-chat-tab-D_x5CRae.js +0 -1
  83. package/dist/web/assets/index-Cp3e9olf.js +0 -50
  84. package/dist/web/assets/index-DCJ29LBG.css +0 -2
  85. package/dist/web/assets/keybindings-store-CpgEwnFA.js +0 -1
  86. package/dist/web/assets/notification-store-BohufOcS.js +0 -1
  87. package/dist/web/assets/settings-tab-DkpOd_kU.js +0 -1
  88. package/dist/web/assets/terminal-tab-Cqs7xZJt.js +0 -3
  89. package/dist/web/assets/tool-cards-BonPvpUI.js +0 -7
  90. /package/dist/web/assets/{api-themes-eWBIYCbh.js → api-themes-BpoKUPkQ.js} +0 -0
  91. /package/dist/web/assets/{csv-parser-BOlh-1bz.js → csv-parser-BSnYgPcx.js} +0 -0
  92. /package/dist/web/assets/{lib-CvYn0SaB.js → lib-DA3QSc5_.js} +0 -0
  93. /package/dist/web/assets/{use-blob-url-BMZ0zo_4.js → use-blob-url-kvtWmJtt.js} +0 -0
  94. /package/dist/web/assets/{vendor-xterm-Dlg984wj.js → vendor-xterm-CsgYwG03.js} +0 -0
@@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
5
5
  import { Label } from "@/components/ui/label";
6
6
  import { Switch } from "@/components/ui/switch";
7
7
  import { getProxySettings, updateProxySettings, type ProxySettings } from "@/lib/api-settings";
8
+ import { copyToClipboard } from "@/lib/clipboard";
8
9
  import { ProxyTestButton } from "./proxy-test-section";
9
10
 
10
11
  export function ProxySettingsSection() {
@@ -37,8 +38,8 @@ export function ProxySettingsSection() {
37
38
  }
38
39
  };
39
40
 
40
- const copyToClipboard = (text: string, label: string) => {
41
- navigator.clipboard.writeText(text);
41
+ const handleCopy = (text: string, label: string) => {
42
+ void copyToClipboard(text);
42
43
  setCopied(label);
43
44
  setTimeout(() => setCopied(null), 2000);
44
45
  };
@@ -96,7 +97,7 @@ export function ProxySettingsSection() {
96
97
  variant="outline"
97
98
  size="sm"
98
99
  className="h-7 px-2 cursor-pointer shrink-0"
99
- onClick={() => copyToClipboard(settings.authKey!, "key")}
100
+ onClick={() => handleCopy(settings.authKey!, "key")}
100
101
  >
101
102
  {copied === "key" ? "Copied!" : <Copy className="size-3" />}
102
103
  </Button>
@@ -145,7 +146,7 @@ export function ProxySettingsSection() {
145
146
  variant="ghost"
146
147
  size="sm"
147
148
  className="h-6 px-1.5 cursor-pointer shrink-0"
148
- onClick={() => copyToClipboard(hasTunnel ? settings.proxyEndpoint! : localEndpoint, "anthropic")}
149
+ onClick={() => handleCopy(hasTunnel ? settings.proxyEndpoint! : localEndpoint, "anthropic")}
149
150
  >
150
151
  {copied === "anthropic" ? "Copied!" : <Copy className="size-3" />}
151
152
  </Button>
@@ -163,7 +164,7 @@ export function ProxySettingsSection() {
163
164
  variant="ghost"
164
165
  size="sm"
165
166
  className="h-6 px-1.5 cursor-pointer shrink-0"
166
- onClick={() => copyToClipboard(
167
+ onClick={() => handleCopy(
167
168
  hasTunnel ? settings.openAiEndpoint! : settings.localOpenAiEndpoint, "openai",
168
169
  )}
169
170
  >
@@ -190,7 +191,7 @@ ANTHROPIC_API_KEY=${settings.authKey}`}
190
191
  variant="ghost"
191
192
  size="sm"
192
193
  className="absolute top-1 right-1 h-5 px-1 cursor-pointer"
193
- onClick={() => copyToClipboard(
194
+ onClick={() => handleCopy(
194
195
  `ANTHROPIC_BASE_URL=${hasTunnel ? settings.tunnelUrl + "/proxy" : localBaseUrl + "/proxy"}\nANTHROPIC_API_KEY=${settings.authKey}`,
195
196
  "anthropic-env",
196
197
  )}
@@ -211,7 +212,7 @@ OPENAI_API_KEY=${settings.authKey}`}
211
212
  variant="ghost"
212
213
  size="sm"
213
214
  className="absolute top-1 right-1 h-5 px-1 cursor-pointer"
214
- onClick={() => copyToClipboard(
215
+ onClick={() => handleCopy(
215
216
  `OPENAI_BASE_URL=${hasTunnel ? settings.tunnelUrl + "/proxy/v1" : localBaseUrl + "/proxy/v1"}\nOPENAI_API_KEY=${settings.authKey}`,
216
217
  "openai-env",
217
218
  )}
@@ -279,16 +279,16 @@ export function SettingsTab() {
279
279
  function NotificationsContent() {
280
280
  return (
281
281
  <div className="space-y-4">
282
- {/* Cloud Push */}
282
+ {/* Browser push */}
283
283
  <section className="space-y-2">
284
284
  <h3 className="text-xs font-medium text-muted-foreground">Push Notifications</h3>
285
285
  <div className="flex items-center gap-1.5">
286
286
  <Bell className="size-3.5 text-muted-foreground" />
287
- <span className="text-xs">Managed via PPM Cloud</span>
287
+ <span className="text-xs">Not available yet</span>
288
288
  </div>
289
289
  <p className="text-[11px] text-muted-foreground">
290
- Push notifications are now dispatched through PPM Cloud.
291
- Visit your Cloud dashboard to manage subscriptions.
290
+ Browser push does not deliver right now.
291
+ Use Telegram below to get alerted when a session finishes or needs approval.
292
292
  </p>
293
293
  </section>
294
294
 
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import { Copy, ExternalLink, X, Check } from "lucide-react";
3
- import { openGithubIssue, copyToClipboard } from "@/lib/report-bug";
3
+ import { openGithubIssue } from "@/lib/report-bug";
4
+ import { copyToClipboard } from "@/lib/clipboard";
4
5
 
5
6
  export function BugReportPopup() {
6
7
  const [text, setText] = useState<string | null>(null);
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, type ReactNode } from "react";
2
2
  import { useMdContext, FILE_EXT_RE, GLOB_CHARS_RE } from "./markdown-context";
3
3
  import { useTabStore } from "@/stores/tab-store";
4
4
  import { highlightToHtml, highlightSync, getActiveShikiTheme } from "@/theme/adapters/shiki-adapter";
5
+ import { copyToClipboard } from "@/lib/clipboard";
5
6
 
6
7
  /**
7
8
  * Shiki-highlighted HTML for a code block. Returns null while streaming or
@@ -88,13 +89,13 @@ export function MdPre({ children, node, ...rest }: any) {
88
89
  : <pre {...rest}>{children}</pre>}
89
90
  {codeActions && (
90
91
  <div className="code-actions absolute top-1 right-1 flex gap-1">
91
- <ActionBtn title="Copy" icon={<CopyIcon />} activeIcon={<CheckIcon />} onClick={() => navigator.clipboard.writeText(text)} />
92
+ <ActionBtn title="Copy" icon={<CopyIcon />} activeIcon={<CheckIcon />} onClick={() => void copyToClipboard(text)} />
92
93
  {isBash && projectName && (
93
94
  <ActionBtn
94
95
  title="Run in terminal"
95
96
  icon={<PlayIcon />}
96
97
  onClick={() => {
97
- navigator.clipboard.writeText(text.replace(/^\$\s*/gm, ""));
98
+ void copyToClipboard(text.replace(/^\$\s*/gm, ""));
98
99
  openTab({ type: "terminal", title: "Terminal", metadata: { projectName }, projectId: projectName, closable: true });
99
100
  }}
100
101
  />
@@ -1,6 +1,7 @@
1
1
  import { useRef, useEffect, useState, useCallback, memo } from "react";
2
2
  import { useTerminal } from "@/hooks/use-terminal";
3
3
  import { cn } from "@/lib/utils";
4
+ import { copyToClipboard } from "@/lib/clipboard";
4
5
  import { Copy, ClipboardPaste, RotateCcw, MessageSquare } from "lucide-react";
5
6
  import "@xterm/xterm/css/xterm.css";
6
7
  import { toast } from "sonner";
@@ -88,7 +89,7 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
88
89
  const handleCopy = useCallback(async () => {
89
90
  const selection = getSelection();
90
91
  if (selection) {
91
- await navigator.clipboard.writeText(selection);
92
+ await copyToClipboard(selection);
92
93
  }
93
94
  }, [getSelection]);
94
95
 
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import { Check, Copy, ExternalLink, Globe, Loader2, Lock, RefreshCw, Square } from "lucide-react";
3
3
  import { tunnelsApi, type TunnelEntry } from "@/lib/api-tunnels";
4
+ import { copyToClipboard } from "@/lib/clipboard";
4
5
  import { toast } from "sonner";
5
6
 
6
7
  /** Badge label + style per tunnel source. */
@@ -73,11 +74,12 @@ export function TunnelManagerTab() {
73
74
  };
74
75
 
75
76
  const copyUrl = (pid: number, url: string) => {
76
- navigator.clipboard.writeText(url).then(() => {
77
+ copyToClipboard(url).then((ok) => {
78
+ if (!ok) { toast.error("Failed to copy URL"); return; }
77
79
  setCopiedPid(pid);
78
80
  toast.success("URL copied");
79
81
  setTimeout(() => setCopiedPid(null), 2000);
80
- }).catch(() => toast.error("Failed to copy URL"));
82
+ });
81
83
  };
82
84
 
83
85
  const handleSubmit = (e: React.FormEvent) => {
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Aggregate the file mutations of a single assistant turn.
3
+ *
4
+ * Feeds the change tray (see turn-change-pill.tsx). Pure — no I/O, no React — so it
5
+ * works identically on live-streamed events and on messages parsed back from a JSONL
6
+ * transcript.
7
+ *
8
+ * Note on diffs: Edit's `old_string`/`new_string` are disjoint *fragments* at unknown
9
+ * offsets, so per-file edits cannot be composed into one whole-file diff. Each
10
+ * fragment is kept separate and rendered on its own.
11
+ */
12
+ import { diffLines } from "diff";
13
+ import type { ChatEvent, ChatMessage } from "../../types/chat";
14
+
15
+ export type FileChangeOp = "create" | "write" | "edit" | "notebook";
16
+
17
+ /** Tools whose input mutates a file. Kept in one place so it stays greppable. */
18
+ export const FILE_MUTATION_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
19
+
20
+ /** Rank for resolving `op` when several tools touch the same file in one turn. */
21
+ const OP_RANK: Record<FileChangeOp, number> = { create: 3, write: 2, notebook: 2, edit: 1 };
22
+
23
+ export interface FileEditFragment {
24
+ /** "" for Write/NotebookEdit — the prior content is not in the tool input. */
25
+ oldStr: string;
26
+ newStr: string;
27
+ toolUseId?: string;
28
+ /** Index within this tool call's edits (always 0 except MultiEdit). */
29
+ editIndex: number;
30
+ /** Anchor key matching `data-edit-ref` on the tool card. */
31
+ editRef?: string;
32
+ viaSubagent: boolean;
33
+ }
34
+
35
+ export interface TurnFileChange {
36
+ filePath: string;
37
+ op: FileChangeOp;
38
+ editCount: number;
39
+ linesAdded: number;
40
+ linesRemoved: number;
41
+ edits: FileEditFragment[];
42
+ /** True when *any* edit to this file came from a spawned sub-agent. */
43
+ viaSubagent: boolean;
44
+ }
45
+
46
+ function str(v: unknown): string {
47
+ return typeof v === "string" ? v : "";
48
+ }
49
+
50
+ function countLines(oldStr: string, newStr: string): { added: number; removed: number } {
51
+ let added = 0;
52
+ let removed = 0;
53
+ for (const part of diffLines(oldStr, newStr)) {
54
+ if (part.added) added += part.count ?? 0;
55
+ else if (part.removed) removed += part.count ?? 0;
56
+ }
57
+ return { added, removed };
58
+ }
59
+
60
+ /**
61
+ * Write's input looks identical whether the file existed or not — only the tool
62
+ * result distinguishes them. Absent during streaming, hence the "write" default.
63
+ */
64
+ function writeOp(output: string | undefined): FileChangeOp {
65
+ return output && /created successfully/i.test(output) ? "create" : "write";
66
+ }
67
+
68
+ type RawChange = { filePath: string; op: FileChangeOp; fragments: Array<{ oldStr: string; newStr: string }> };
69
+
70
+ function extractChange(tool: string, input: Record<string, unknown>, result?: string): RawChange | null {
71
+ switch (tool) {
72
+ case "Write": {
73
+ const filePath = str(input.file_path);
74
+ if (!filePath) return null;
75
+ return { filePath, op: writeOp(result), fragments: [{ oldStr: "", newStr: str(input.content) }] };
76
+ }
77
+ case "Edit": {
78
+ const filePath = str(input.file_path);
79
+ if (!filePath) return null;
80
+ return {
81
+ filePath,
82
+ op: "edit",
83
+ fragments: [{ oldStr: str(input.old_string), newStr: str(input.new_string) }],
84
+ };
85
+ }
86
+ case "MultiEdit": {
87
+ const filePath = str(input.file_path);
88
+ if (!filePath || !Array.isArray(input.edits)) return null;
89
+ const fragments = (input.edits as unknown[])
90
+ .filter((e): e is Record<string, unknown> => !!e && typeof e === "object")
91
+ .map((e) => ({ oldStr: str(e.old_string), newStr: str(e.new_string) }));
92
+ if (fragments.length === 0) return null;
93
+ return { filePath, op: "edit", fragments };
94
+ }
95
+ case "NotebookEdit": {
96
+ // Real param is `notebook_path`; file_path accepted because callers vary.
97
+ const filePath = str(input.notebook_path) || str(input.file_path);
98
+ if (!filePath) return null;
99
+ return { filePath, op: "notebook", fragments: [{ oldStr: "", newStr: str(input.new_source) }] };
100
+ }
101
+ default:
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /** Index tool_result output by toolUseId, recursing into sub-agent children. */
107
+ function collectResults(events: ChatEvent[] | undefined, out: Map<string, string>): void {
108
+ if (!events) return;
109
+ for (const ev of events) {
110
+ if (ev.type === "tool_result" && ev.toolUseId) out.set(ev.toolUseId, ev.output ?? "");
111
+ if (ev.type === "tool_use") {
112
+ // The WS layer also embeds the result onto the tool_use for reconnect replay.
113
+ const embedded = (ev as { result?: { output?: string } }).result;
114
+ if (ev.toolUseId && embedded?.output != null && !out.has(ev.toolUseId)) {
115
+ out.set(ev.toolUseId, embedded.output);
116
+ }
117
+ collectResults(ev.children, out);
118
+ }
119
+ }
120
+ }
121
+
122
+ function walk(
123
+ events: ChatEvent[] | undefined,
124
+ viaSubagent: boolean,
125
+ files: Map<string, TurnFileChange>,
126
+ results: Map<string, string>,
127
+ ): void {
128
+ if (!events) return;
129
+ for (const ev of events) {
130
+ if (ev.type !== "tool_use") continue;
131
+ // Agent/Task wrap their own event stream — everything inside is sub-agent work.
132
+ if (ev.children?.length) walk(ev.children, true, files, results);
133
+ if (!FILE_MUTATION_TOOLS.has(ev.tool)) continue;
134
+
135
+ const input = ev.input && typeof ev.input === "object" ? (ev.input as Record<string, unknown>) : null;
136
+ if (!input) continue;
137
+ const raw = extractChange(ev.tool, input, ev.toolUseId ? results.get(ev.toolUseId) : undefined);
138
+ if (!raw) continue;
139
+
140
+ let entry = files.get(raw.filePath);
141
+ if (!entry) {
142
+ // Map insertion order gives first-touched ordering for free.
143
+ entry = {
144
+ filePath: raw.filePath,
145
+ op: raw.op,
146
+ editCount: 0,
147
+ linesAdded: 0,
148
+ linesRemoved: 0,
149
+ edits: [],
150
+ viaSubagent: false,
151
+ };
152
+ files.set(raw.filePath, entry);
153
+ }
154
+ if (OP_RANK[raw.op] > OP_RANK[entry.op]) entry.op = raw.op;
155
+ if (viaSubagent) entry.viaSubagent = true;
156
+
157
+ raw.fragments.forEach((frag, editIndex) => {
158
+ const { added, removed } = countLines(frag.oldStr, frag.newStr);
159
+ entry!.linesAdded += added;
160
+ entry!.linesRemoved += removed;
161
+ entry!.editCount += 1;
162
+ entry!.edits.push({
163
+ ...frag,
164
+ toolUseId: ev.toolUseId,
165
+ editIndex,
166
+ editRef: ev.toolUseId ? `${ev.toolUseId}-${editIndex}` : undefined,
167
+ viaSubagent,
168
+ });
169
+ });
170
+ }
171
+ }
172
+
173
+ /** Files mutated by one turn, in the order the turn first touched them. */
174
+ export function aggregateTurnFileChanges(messages: ChatMessage[]): TurnFileChange[] {
175
+ const results = new Map<string, string>();
176
+ for (const msg of messages) collectResults(msg.events, results);
177
+
178
+ const files = new Map<string, TurnFileChange>();
179
+ for (const msg of messages) walk(msg.events, false, files, results);
180
+ return [...files.values()];
181
+ }
182
+
183
+ /**
184
+ * A turn spans every consecutive assistant message ending at `lastAssistantIndex`
185
+ * (text and tool segments arrive as separate messages).
186
+ */
187
+ export function collectTurnMessages(messages: ChatMessage[], lastAssistantIndex: number): ChatMessage[] {
188
+ const out: ChatMessage[] = [];
189
+ for (let i = lastAssistantIndex; i >= 0 && messages[i]?.role === "assistant"; i--) {
190
+ out.unshift(messages[i]!);
191
+ }
192
+ return out;
193
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Clipboard write with an insecure-context fallback.
3
+ *
4
+ * `navigator.clipboard` only exists in a secure context (HTTPS or localhost).
5
+ * PPM is regularly reached over plain HTTP — LAN IP, Tailscale MagicDNS name —
6
+ * where the API is absent and every copy button silently no-ops. The legacy
7
+ * `execCommand` path keeps copy working on those origins.
8
+ */
9
+
10
+ /** Copy text to the clipboard. Returns false only if both paths fail. */
11
+ export async function copyToClipboard(text: string): Promise<boolean> {
12
+ if (navigator.clipboard?.writeText) {
13
+ try {
14
+ await navigator.clipboard.writeText(text);
15
+ return true;
16
+ } catch {
17
+ // Denied permission or lost user activation — fall through to execCommand.
18
+ }
19
+ }
20
+ return legacyCopy(text);
21
+ }
22
+
23
+ function legacyCopy(text: string): boolean {
24
+ const textarea = document.createElement("textarea");
25
+ textarea.value = text;
26
+ textarea.setAttribute("readonly", "");
27
+ // Must stay rendered and non-zero sized: iOS Safari refuses to select a
28
+ // hidden or display:none element, which breaks the copy.
29
+ textarea.style.cssText =
30
+ "position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:none;opacity:0;";
31
+ document.body.appendChild(textarea);
32
+
33
+ const selection = document.getSelection();
34
+ const previousRange =
35
+ selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
36
+
37
+ try {
38
+ textarea.focus();
39
+ textarea.select();
40
+ // setSelectionRange is what actually selects on iOS; select() alone is ignored.
41
+ textarea.setSelectionRange(0, text.length);
42
+ return document.execCommand("copy");
43
+ } catch {
44
+ return false;
45
+ } finally {
46
+ textarea.remove();
47
+ if (selection && previousRange) {
48
+ selection.removeAllRanges();
49
+ selection.addRange(previousRange);
50
+ }
51
+ }
52
+ }
@@ -60,16 +60,6 @@ export function openGithubIssue(body: string) {
60
60
  window.open(url, "_blank");
61
61
  }
62
62
 
63
- /** Copy text to clipboard */
64
- export async function copyToClipboard(text: string): Promise<boolean> {
65
- try {
66
- await navigator.clipboard.writeText(text);
67
- return true;
68
- } catch {
69
- return false;
70
- }
71
- }
72
-
73
63
  /** Open bug report popup globally via custom event */
74
64
  export function openBugReportPopup(
75
65
  version: string | null,
@@ -145,6 +145,22 @@ samp,
145
145
  font-feature-settings: "liga" 0, "calt" 0;
146
146
  }
147
147
 
148
+ /* Jump-target flash, set imperatively by jump-to-edit.ts and cleared after 1400ms.
149
+ Attribute-driven rather than React state so highlighting one card in a long
150
+ transcript does not re-render the rest. */
151
+ [data-flash="card"] {
152
+ border-color: color-mix(in srgb, var(--accent) 55%, transparent);
153
+ box-shadow:
154
+ 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent),
155
+ 0 0 22px -6px color-mix(in srgb, var(--accent) 50%, transparent);
156
+ transition: border-color 200ms ease-out, box-shadow 200ms ease-out;
157
+ }
158
+
159
+ [data-flash="edit"] {
160
+ background: color-mix(in srgb, var(--accent) 13%, transparent);
161
+ transition: background 200ms ease-out;
162
+ }
163
+
148
164
  /* Scrollbar styling — thin & unobtrusive */
149
165
  ::-webkit-scrollbar {
150
166
  width: 5px;