@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
|
@@ -17,6 +17,7 @@ import { CodexUsagePanel } from "./codex-usage-panel";
|
|
|
17
17
|
import { TeamActivityPanel } from "./team-activity-panel";
|
|
18
18
|
import { ProviderBadge } from "./provider-selector";
|
|
19
19
|
import { formatRelativeDate } from "@/lib/format-date";
|
|
20
|
+
import { copyToClipboard } from "@/lib/clipboard";
|
|
20
21
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|
21
22
|
import type { SessionInfo, SessionListResponse, ProjectTag } from "../../../types/chat";
|
|
22
23
|
import type { UsageInfo } from "../../../types/chat";
|
|
@@ -113,10 +114,11 @@ function DebugCopyButton({ sessionId, projectName }: { sessionId: string; projec
|
|
|
113
114
|
|
|
114
115
|
const copy = () => {
|
|
115
116
|
if (!info) return;
|
|
116
|
-
|
|
117
|
+
copyToClipboard(info).then((ok) => {
|
|
118
|
+
if (!ok) return;
|
|
117
119
|
setCopied(true);
|
|
118
120
|
setTimeout(() => setCopied(false), 1500);
|
|
119
|
-
})
|
|
121
|
+
});
|
|
120
122
|
};
|
|
121
123
|
|
|
122
124
|
const body = (
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scroll the transcript to a specific edit and flash it — the change tray's primary
|
|
3
|
+
* action.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately not `scrollIntoView`: that ignores the desired offset and picks its own
|
|
6
|
+
* scroll container. Offsets are computed against the real container instead, matching
|
|
7
|
+
* chat-scroll-nav's approach.
|
|
8
|
+
*
|
|
9
|
+
* Sub-agent tool cards render inside their own `overflow-y-auto` box, so an edit can be
|
|
10
|
+
* scrolled out of view *within* that box while the transcript is already in position.
|
|
11
|
+
* Nested scrollers are therefore aligned innermost-first before the transcript moves.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const JUMP_OFFSET_PX = 60;
|
|
15
|
+
const NESTED_OFFSET_PX = 8;
|
|
16
|
+
const FLASH_MS = 1400;
|
|
17
|
+
|
|
18
|
+
export type AnchorLevel = "edit" | "card";
|
|
19
|
+
|
|
20
|
+
function isScrollable(el: HTMLElement): boolean {
|
|
21
|
+
if (el.scrollHeight <= el.clientHeight) return false;
|
|
22
|
+
const overflowY = getComputedStyle(el).overflowY;
|
|
23
|
+
return overflowY === "auto" || overflowY === "scroll";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Scrollable elements between `target` and `container`, innermost first. */
|
|
27
|
+
function scrollableAncestors(target: HTMLElement, container: HTMLElement): HTMLElement[] {
|
|
28
|
+
const out: HTMLElement[] = [];
|
|
29
|
+
let el = target.parentElement;
|
|
30
|
+
while (el && el !== container) {
|
|
31
|
+
if (isScrollable(el)) out.push(el);
|
|
32
|
+
el = el.parentElement;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function prefersReducedMotion(): boolean {
|
|
38
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve an edit ref to its element. Falls back to the tool card when the card is
|
|
43
|
+
* collapsed, since the per-edit wrapper is then unmounted.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveAnchor(
|
|
46
|
+
container: HTMLElement,
|
|
47
|
+
editRef: string,
|
|
48
|
+
): { el: HTMLElement; level: AnchorLevel } | null {
|
|
49
|
+
const edit = container.querySelector<HTMLElement>(`[data-edit-ref="${CSS.escape(editRef)}"]`);
|
|
50
|
+
if (edit) return { el: edit, level: "edit" };
|
|
51
|
+
|
|
52
|
+
const toolUseId = editRef.replace(/-\d+$/, "");
|
|
53
|
+
const card = container.querySelector<HTMLElement>(`[data-tool-ref="${CSS.escape(toolUseId)}"]`);
|
|
54
|
+
return card ? { el: card, level: "card" } : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Scroll `container` so `target` sits `offset` px below the top of `scroller`. */
|
|
58
|
+
function alignWithin(scroller: HTMLElement, target: HTMLElement, offset: number, smooth: boolean): void {
|
|
59
|
+
const top = scroller.scrollTop + target.getBoundingClientRect().top
|
|
60
|
+
- scroller.getBoundingClientRect().top - offset;
|
|
61
|
+
scroller.scrollTo({ top, behavior: smooth ? "smooth" : "auto" });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Scroll to `editRef` and flash it. Returns a cleanup that cancels the pending flash
|
|
66
|
+
* clear — call it on unmount, and keep it so a re-jump to the same element does not
|
|
67
|
+
* leave a stale timer behind.
|
|
68
|
+
*/
|
|
69
|
+
export function jumpToEdit(container: HTMLElement, editRef: string): () => void {
|
|
70
|
+
const found = resolveAnchor(container, editRef);
|
|
71
|
+
if (!found) return () => {};
|
|
72
|
+
const { el, level } = found;
|
|
73
|
+
|
|
74
|
+
// Nested scrollers move instantly; animating them alongside the transcript is janky.
|
|
75
|
+
for (const scroller of scrollableAncestors(el, container)) {
|
|
76
|
+
alignWithin(scroller, el, NESTED_OFFSET_PX, false);
|
|
77
|
+
}
|
|
78
|
+
alignWithin(container, el, JUMP_OFFSET_PX, !prefersReducedMotion());
|
|
79
|
+
|
|
80
|
+
el.dataset.flash = level;
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
delete el.dataset.flash;
|
|
83
|
+
}, FLASH_MS);
|
|
84
|
+
|
|
85
|
+
return () => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
delete el.dataset.flash;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
|
|
|
2
2
|
import { Copy, Check } from "lucide-react";
|
|
3
3
|
import { cn } from "@/lib/utils";
|
|
4
4
|
import { formatRelativeDate } from "@/lib/format-date";
|
|
5
|
+
import { copyToClipboard } from "@/lib/clipboard";
|
|
5
6
|
|
|
6
7
|
/** Full timestamp for the relative-time tooltip. */
|
|
7
8
|
function fullDate(iso: string): string {
|
|
@@ -50,7 +51,7 @@ function CopyButton({ content }: { content: string }) {
|
|
|
50
51
|
label={copied ? "Copied" : "Copy"}
|
|
51
52
|
title="Copy message to clipboard"
|
|
52
53
|
onClick={() => {
|
|
53
|
-
|
|
54
|
+
void copyToClipboard(content);
|
|
54
55
|
setCopied(true);
|
|
55
56
|
setTimeout(() => setCopied(false), 1500);
|
|
56
57
|
}}
|
|
@@ -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
|
-
<
|
|
518
|
+
<TurnChangeRollup
|
|
473
519
|
timestamp={message.timestamp}
|
|
474
520
|
content={turnCopyText ?? assistantMessageText(message)}
|
|
475
521
|
accountLabel={message.accountLabel}
|
|
476
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
+
}
|