@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.
- package/CHANGELOG.md +17 -0
- package/README.md +2 -1
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +3 -1
- package/dist/web/assets/{ai-resource-editor-BCgyv1KF.js → ai-resource-editor-ByZApv3Y.js} +1 -1
- package/dist/web/assets/{audio-preview-BPuiX8ma.js → audio-preview-izB0KGPQ.js} +1 -1
- package/dist/web/assets/chat-tab-CwWL5DbD.js +13 -0
- package/dist/web/assets/code-editor-Bx-Q27Nm.js +10 -0
- package/dist/web/assets/{conflict-editor-BuLnwxFE.js → conflict-editor-B60UimS8.js} +1 -1
- package/dist/web/assets/{csv-preview-DdA_IpTo.js → csv-preview-BZv2SuCz.js} +1 -1
- package/dist/web/assets/{database-viewer-BcF3uuk5.js → database-viewer-B5J_4lvX.js} +1 -1
- package/dist/web/assets/{diff-viewer-tyMHX20V.js → diff-viewer-DiytoNvX.js} +1 -1
- package/dist/web/assets/{docx-preview-BTclTd2v.js → docx-preview-CrjmF16z.js} +1 -1
- package/dist/web/assets/{edit-diff-preview-DDLG3m68.js → edit-diff-preview-BSjgyGAL.js} +5 -10
- package/dist/web/assets/{extension-webview-CBLWXhjt.js → extension-webview-BSiQUxFD.js} +1 -1
- package/dist/web/assets/{git-log-panel-tmGgY2kK.js → git-log-panel-Fb5EefXW.js} +1 -1
- package/dist/web/assets/glide-data-grid-Cjve7mDb.js +138 -0
- package/dist/web/assets/group-chat-tab-DT0X7Cbz.js +1 -0
- package/dist/web/assets/{image-preview-CQ57RN6l.js → image-preview-BiVYVqa9.js} +1 -1
- package/dist/web/assets/index-BW2cOdzp.css +2 -0
- package/dist/web/assets/index-CuAijZGh.js +50 -0
- package/dist/web/assets/keybindings-store-sakq3gsF.js +1 -0
- package/dist/web/assets/libesm-Dd8zbj1v.js +6 -0
- package/dist/web/assets/{markdown-renderer-devgS0zw.js → markdown-renderer-BsKIIM83.js} +2 -2
- package/dist/web/assets/{markdown-renderer-C7clwuDe.js → markdown-renderer-Cct9JHnx.js} +1 -1
- package/dist/web/assets/notification-store-D4TsAnN3.js +1 -0
- package/dist/web/assets/{pdf-preview-Cw_GRd7e.js → pdf-preview-B6G1QpE6.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DN40Bdc-.js → postgres-viewer-CdFu2Yj9.js} +2 -2
- package/dist/web/assets/{settings-store-UwT_fNhg.js → settings-store-DFMz_Oer.js} +2 -2
- package/dist/web/assets/settings-tab-Dh3bD_dg.js +1 -0
- package/dist/web/assets/{sql-query-editor-Df1pzXAj.js → sql-query-editor-C8XXOY2R.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Dbh3LluT.js → sqlite-viewer-B1eXvaLK.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-bbct9aEk.js → system-monitor-tab-D2QcCXl1.js} +1 -1
- package/dist/web/assets/terminal-tab-D2-buBAQ.js +3 -0
- package/dist/web/assets/tool-cards-DDxShmxN.js +7 -0
- package/dist/web/assets/{use-is-mobile-CE6AamG6.js → use-is-mobile-BcuCjH19.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-Cqo17yKA.js → use-monaco-theme-DMlVAYKD.js} +1 -1
- package/dist/web/assets/{video-preview-kZgggFAn.js → video-preview-Cg3A7vrt.js} +1 -1
- package/dist/web/index.html +4 -4
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/index.ts +6 -0
- package/src/server/routes/cloud.ts +35 -0
- package/src/web/components/chat/account-dialogs.tsx +3 -3
- package/src/web/components/chat/change-edit-list.tsx +50 -0
- package/src/web/components/chat/change-file-row.tsx +99 -0
- package/src/web/components/chat/chat-history-bar.tsx +4 -2
- package/src/web/components/chat/jump-to-edit.ts +89 -0
- package/src/web/components/chat/message-action-bar.tsx +2 -1
- package/src/web/components/chat/message-list.tsx +50 -3
- package/src/web/components/chat/tool-cards.tsx +16 -6
- package/src/web/components/chat/turn-change-pill.tsx +57 -0
- package/src/web/components/chat/turn-change-rollup.tsx +76 -0
- package/src/web/components/chat/turn-change-sheet.tsx +178 -0
- package/src/web/components/chat/turn-change-tray.tsx +140 -0
- package/src/web/components/database/connection-import-export.tsx +5 -2
- package/src/web/components/database/export-button.tsx +4 -3
- package/src/web/components/explorer/file-tree.tsx +3 -2
- package/src/web/components/layout/cloud-alias-row.tsx +163 -0
- package/src/web/components/layout/cloud-share-popover.tsx +34 -21
- package/src/web/components/layout/command-palette.tsx +2 -0
- package/src/web/components/layout/mobile-drawer.tsx +10 -0
- package/src/web/components/layout/mobile-nav.tsx +3 -2
- package/src/web/components/layout/nav-section-rail.tsx +9 -0
- package/src/web/components/layout/project-bottom-sheet.tsx +2 -1
- package/src/web/components/layout/project-switcher.tsx +2 -1
- package/src/web/components/layout/tab-bar.tsx +3 -2
- package/src/web/components/settings/accounts-settings-section.tsx +3 -3
- package/src/web/components/settings/proxy-settings-section.tsx +8 -7
- package/src/web/components/settings/settings-tab.tsx +4 -4
- package/src/web/components/shared/bug-report-popup.tsx +2 -1
- package/src/web/components/shared/markdown-code-block.tsx +3 -2
- package/src/web/components/terminal/terminal-tab.tsx +2 -1
- package/src/web/components/tunnels/tunnel-manager-tab.tsx +4 -2
- package/src/web/lib/aggregate-turn-file-changes.ts +193 -0
- package/src/web/lib/clipboard.ts +52 -0
- package/src/web/lib/report-bug.ts +0 -10
- package/src/web/styles/globals.css +16 -0
- package/dist/web/assets/chat-tab-WnnM6GKA.js +0 -13
- package/dist/web/assets/code-editor-CnGUyOuU.js +0 -10
- package/dist/web/assets/glide-data-grid-CkCW7sFw.js +0 -138
- package/dist/web/assets/group-chat-tab-D_x5CRae.js +0 -1
- package/dist/web/assets/index-Cp3e9olf.js +0 -50
- package/dist/web/assets/index-DCJ29LBG.css +0 -2
- package/dist/web/assets/keybindings-store-CpgEwnFA.js +0 -1
- package/dist/web/assets/notification-store-BohufOcS.js +0 -1
- package/dist/web/assets/settings-tab-DkpOd_kU.js +0 -1
- package/dist/web/assets/terminal-tab-Cqs7xZJt.js +0 -3
- package/dist/web/assets/tool-cards-BonPvpUI.js +0 -7
- /package/dist/web/assets/{api-themes-eWBIYCbh.js → api-themes-BpoKUPkQ.js} +0 -0
- /package/dist/web/assets/{csv-parser-BOlh-1bz.js → csv-parser-BSnYgPcx.js} +0 -0
- /package/dist/web/assets/{lib-CvYn0SaB.js → lib-DA3QSc5_.js} +0 -0
- /package/dist/web/assets/{use-blob-url-BMZ0zo_4.js → use-blob-url-kvtWmJtt.js} +0 -0
- /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
|
|
41
|
-
|
|
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={() =>
|
|
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={() =>
|
|
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={() =>
|
|
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={() =>
|
|
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={() =>
|
|
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
|
-
{/*
|
|
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">
|
|
287
|
+
<span className="text-xs">Not available yet</span>
|
|
288
288
|
</div>
|
|
289
289
|
<p className="text-[11px] text-muted-foreground">
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
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={() =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
})
|
|
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;
|