@hienlh/ppm 0.17.30 → 0.17.32
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-D1efMh5f.js → ai-resource-editor-ByZApv3Y.js} +1 -1
- package/dist/web/assets/{audio-preview-BfaIfDsY.js → audio-preview-izB0KGPQ.js} +1 -1
- package/dist/web/assets/chat-tab-CwWL5DbD.js +13 -0
- package/dist/web/assets/{code-editor-hKFw2M2d.js → code-editor-Bx-Q27Nm.js} +2 -2
- package/dist/web/assets/{conflict-editor-BRDEJEq6.js → conflict-editor-B60UimS8.js} +1 -1
- package/dist/web/assets/{csv-preview-D32vaKWU.js → csv-preview-BZv2SuCz.js} +1 -1
- package/dist/web/assets/{database-viewer-B-ukNIhW.js → database-viewer-B5J_4lvX.js} +1 -1
- package/dist/web/assets/{diff-viewer-DfFzvZND.js → diff-viewer-DiytoNvX.js} +1 -1
- package/dist/web/assets/{docx-preview-B1kTBbEc.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-mQPWwlxb.js → extension-webview-BSiQUxFD.js} +1 -1
- package/dist/web/assets/{git-log-panel-CIB6xuoc.js → git-log-panel-Fb5EefXW.js} +1 -1
- package/dist/web/assets/{glide-data-grid-DM0v58AP.js → glide-data-grid-Cjve7mDb.js} +1 -1
- package/dist/web/assets/{group-chat-tab-pfDZpGO_.js → group-chat-tab-DT0X7Cbz.js} +1 -1
- package/dist/web/assets/{image-preview-Dxvk6fiw.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-BDQFSrPz.js → keybindings-store-sakq3gsF.js} +1 -1
- package/dist/web/assets/libesm-Dd8zbj1v.js +6 -0
- package/dist/web/assets/{markdown-renderer-TCeFZDxj.js → markdown-renderer-BsKIIM83.js} +1 -1
- package/dist/web/assets/{markdown-renderer-zRn368SN.js → markdown-renderer-Cct9JHnx.js} +1 -1
- package/dist/web/assets/{notification-store-BcEM83wQ.js → notification-store-D4TsAnN3.js} +1 -1
- package/dist/web/assets/{pdf-preview-Bx_RsWdB.js → pdf-preview-B6G1QpE6.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DJXDXZbE.js → postgres-viewer-CdFu2Yj9.js} +1 -1
- 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-UDQo7Bck.js → sql-query-editor-C8XXOY2R.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BaQq64OX.js → sqlite-viewer-B1eXvaLK.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-P1uy84Os.js → system-monitor-tab-D2QcCXl1.js} +1 -1
- package/dist/web/assets/{terminal-tab-mI-vAmOV.js → terminal-tab-D2-buBAQ.js} +1 -1
- 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-CZKIw2ZG.js → use-monaco-theme-DMlVAYKD.js} +1 -1
- package/dist/web/assets/{video-preview-TGDSUs0J.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/chat.ts +4 -1
- package/src/server/routes/cloud.ts +35 -0
- package/src/services/db.service.ts +12 -1
- package/src/services/session-branch.service.ts +43 -10
- 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/jump-to-edit.ts +89 -0
- 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/layout/cloud-alias-row.tsx +163 -0
- package/src/web/components/layout/cloud-share-popover.tsx +32 -20
- 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/nav-section-rail.tsx +9 -0
- package/src/web/components/settings/settings-tab.tsx +4 -4
- package/src/web/lib/aggregate-turn-file-changes.ts +193 -0
- package/src/web/styles/globals.css +16 -0
- package/dist/web/assets/chat-tab-CVfcHLiQ.js +0 -13
- package/dist/web/assets/index-Cp6hb7uj.js +0 -50
- package/dist/web/assets/index-DCJ29LBG.css +0 -2
- package/dist/web/assets/settings-tab-CAJ4skP0.js +0 -1
- package/dist/web/assets/tool-cards-B6jz0GZE.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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { Loader2, Pencil, ExternalLink, Check, X } from "lucide-react";
|
|
3
|
+
import { api } from "@/lib/api-client";
|
|
4
|
+
|
|
5
|
+
/** Cloud's slug rules — mirrored here so a bad name fails before a round-trip. */
|
|
6
|
+
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{1,31}$/;
|
|
7
|
+
const SLUG_HINT = "2–32 characters: lowercase letters, numbers, hyphens.";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
/** Base URL of the cloud instance, e.g. https://cloud.ppm.sh */
|
|
11
|
+
cloudUrl: string;
|
|
12
|
+
/** Machine name — seeds the input with a sensible default slug. */
|
|
13
|
+
deviceName: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Strip the scheme so the permanent link reads like an address, not a URL. */
|
|
17
|
+
function displayHost(cloudUrl: string): string {
|
|
18
|
+
return cloudUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function suggestSlug(deviceName: string | null): string {
|
|
22
|
+
if (!deviceName) return "";
|
|
23
|
+
const slug = deviceName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
|
|
24
|
+
return SLUG_REGEX.test(slug) ? slug : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Permanent link for this machine. The tunnel URL rotates on every restart;
|
|
29
|
+
* the alias is the stable address that always resolves to the current one.
|
|
30
|
+
*/
|
|
31
|
+
export function CloudAliasRow({ cloudUrl, deviceName }: Props) {
|
|
32
|
+
const [slug, setSlug] = useState<string | null>(null);
|
|
33
|
+
const [loading, setLoading] = useState(true);
|
|
34
|
+
const [unreadable, setUnreadable] = useState(false);
|
|
35
|
+
const [editing, setEditing] = useState(false);
|
|
36
|
+
const [draft, setDraft] = useState("");
|
|
37
|
+
const [saving, setSaving] = useState(false);
|
|
38
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
(async () => {
|
|
42
|
+
try {
|
|
43
|
+
const res = await api.get<{ slug: string | null }>("/api/cloud/alias");
|
|
44
|
+
setSlug(res.slug);
|
|
45
|
+
} catch {
|
|
46
|
+
// A failed read must not look like "no link yet": saving uses the device
|
|
47
|
+
// secret, so it would succeed and silently replace a link already in use.
|
|
48
|
+
setUnreadable(true);
|
|
49
|
+
}
|
|
50
|
+
setLoading(false);
|
|
51
|
+
})();
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const startEdit = useCallback(() => {
|
|
55
|
+
setDraft(slug ?? suggestSlug(deviceName));
|
|
56
|
+
setError(null);
|
|
57
|
+
setEditing(true);
|
|
58
|
+
}, [slug, deviceName]);
|
|
59
|
+
|
|
60
|
+
const handleSave = useCallback(async () => {
|
|
61
|
+
const next = draft.trim().toLowerCase();
|
|
62
|
+
if (!SLUG_REGEX.test(next)) {
|
|
63
|
+
setError(SLUG_HINT);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setSaving(true);
|
|
67
|
+
setError(null);
|
|
68
|
+
try {
|
|
69
|
+
const res = await api.patch<{ slug: string }>("/api/cloud/alias", { slug: next });
|
|
70
|
+
setSlug(res.slug);
|
|
71
|
+
setEditing(false);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
setError(e instanceof Error ? e.message : "Could not save that name");
|
|
74
|
+
} finally {
|
|
75
|
+
setSaving(false);
|
|
76
|
+
}
|
|
77
|
+
}, [draft]);
|
|
78
|
+
|
|
79
|
+
if (loading) return null;
|
|
80
|
+
|
|
81
|
+
if (unreadable) {
|
|
82
|
+
return (
|
|
83
|
+
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
|
84
|
+
Couldn't check this machine's permanent link. Open the cloud dashboard, or sign in again.
|
|
85
|
+
</p>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (editing) {
|
|
90
|
+
return (
|
|
91
|
+
<div className="space-y-1.5">
|
|
92
|
+
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Permanent link</span>
|
|
93
|
+
<div className="flex items-center gap-1">
|
|
94
|
+
<span className="text-xs font-mono text-muted-foreground shrink-0">{displayHost(cloudUrl)}/</span>
|
|
95
|
+
<input
|
|
96
|
+
autoFocus
|
|
97
|
+
value={draft}
|
|
98
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
99
|
+
onKeyDown={(e) => {
|
|
100
|
+
if (e.key === "Enter") handleSave();
|
|
101
|
+
if (e.key === "Escape") setEditing(false);
|
|
102
|
+
}}
|
|
103
|
+
placeholder="my-mac"
|
|
104
|
+
// 16px font on mobile — anything smaller makes iOS zoom on focus.
|
|
105
|
+
className="flex-1 min-w-0 text-base md:text-xs font-mono text-foreground bg-muted px-2 min-h-11 md:min-h-0 md:py-1.5 rounded border border-border outline-none focus:border-primary"
|
|
106
|
+
/>
|
|
107
|
+
<button
|
|
108
|
+
onClick={handleSave}
|
|
109
|
+
disabled={saving}
|
|
110
|
+
title="Save"
|
|
111
|
+
className="flex items-center justify-center size-11 md:size-8 rounded border border-border text-primary bg-muted hover:bg-accent transition-colors shrink-0 disabled:opacity-50"
|
|
112
|
+
>
|
|
113
|
+
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
|
114
|
+
</button>
|
|
115
|
+
<button
|
|
116
|
+
onClick={() => setEditing(false)}
|
|
117
|
+
title="Cancel"
|
|
118
|
+
className="flex items-center justify-center size-11 md:size-8 rounded border border-border text-muted-foreground bg-muted hover:bg-accent transition-colors shrink-0"
|
|
119
|
+
>
|
|
120
|
+
<X className="size-3.5" />
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
<p className="text-[10px] text-muted-foreground">{error ?? SLUG_HINT}</p>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!slug) {
|
|
129
|
+
return (
|
|
130
|
+
<button
|
|
131
|
+
onClick={startEdit}
|
|
132
|
+
className="w-full flex items-center justify-center gap-1.5 px-3 min-h-11 md:min-h-0 md:py-2 text-xs font-medium rounded-md border border-border hover:bg-muted transition-colors"
|
|
133
|
+
>
|
|
134
|
+
<Pencil className="size-3.5" />
|
|
135
|
+
Set a permanent link
|
|
136
|
+
</button>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="space-y-1">
|
|
142
|
+
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Permanent link</span>
|
|
143
|
+
<div className="flex items-center gap-1">
|
|
144
|
+
<a
|
|
145
|
+
href={`${cloudUrl}/${slug}`}
|
|
146
|
+
target="_blank"
|
|
147
|
+
rel="noopener noreferrer"
|
|
148
|
+
className="flex-1 min-w-0 flex items-center gap-1 text-xs font-mono text-primary hover:underline truncate"
|
|
149
|
+
>
|
|
150
|
+
<span className="truncate">{displayHost(cloudUrl)}/{slug}</span>
|
|
151
|
+
<ExternalLink className="size-3 shrink-0" />
|
|
152
|
+
</a>
|
|
153
|
+
<button
|
|
154
|
+
onClick={startEdit}
|
|
155
|
+
title="Change permanent link"
|
|
156
|
+
className="flex items-center justify-center size-11 md:size-8 rounded border border-border text-muted-foreground bg-muted hover:bg-accent hover:text-foreground transition-colors shrink-0"
|
|
157
|
+
>
|
|
158
|
+
<Pencil className="size-3.5" />
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Cloud, Share2, Loader2, Copy, Check, X, LogOut, Link, Unlink, ExternalL
|
|
|
3
3
|
import { QRCodeSVG } from "qrcode.react";
|
|
4
4
|
import { api } from "@/lib/api-client";
|
|
5
5
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
6
|
+
import { CloudAliasRow } from "./cloud-alias-row";
|
|
6
7
|
|
|
7
8
|
interface CloudStatus {
|
|
8
9
|
logged_in: boolean;
|
|
@@ -250,13 +251,21 @@ export function CloudSharePopover({ onClose, variant = "popover" }: Props) {
|
|
|
250
251
|
)}
|
|
251
252
|
</div>
|
|
252
253
|
) : (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
254
|
+
<>
|
|
255
|
+
{shareUrl && (
|
|
256
|
+
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
|
257
|
+
This link changes every time PPM restarts. Sign in to get a permanent one
|
|
258
|
+
that always points here — private to your account.
|
|
259
|
+
</p>
|
|
260
|
+
)}
|
|
261
|
+
<button
|
|
262
|
+
onClick={handleCloudLogin}
|
|
263
|
+
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium rounded-md border border-border hover:bg-muted transition-colors"
|
|
264
|
+
>
|
|
265
|
+
<Cloud className="size-3.5" />
|
|
266
|
+
{shareUrl ? "Get a permanent link" : "Sign in to PPM Cloud"}
|
|
267
|
+
</button>
|
|
268
|
+
</>
|
|
260
269
|
)}
|
|
261
270
|
</div>
|
|
262
271
|
|
|
@@ -264,20 +273,23 @@ export function CloudSharePopover({ onClose, variant = "popover" }: Props) {
|
|
|
264
273
|
{cloud?.logged_in && (
|
|
265
274
|
<div className="space-y-1.5">
|
|
266
275
|
{cloud.linked ? (
|
|
267
|
-
|
|
268
|
-
<div className="flex items-center
|
|
269
|
-
<
|
|
270
|
-
|
|
276
|
+
<>
|
|
277
|
+
<div className="flex items-center justify-between text-xs">
|
|
278
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
279
|
+
<Link className="size-3 text-primary shrink-0" />
|
|
280
|
+
<span className="text-foreground truncate">{cloud.device_name}</span>
|
|
281
|
+
</div>
|
|
282
|
+
<button
|
|
283
|
+
onClick={handleUnlink}
|
|
284
|
+
disabled={unlinking}
|
|
285
|
+
className="text-text-subtle hover:text-destructive p-1 rounded hover:bg-muted transition-colors shrink-0"
|
|
286
|
+
title="Unlink device"
|
|
287
|
+
>
|
|
288
|
+
{unlinking ? <Loader2 className="size-3 animate-spin" /> : <Unlink className="size-3" />}
|
|
289
|
+
</button>
|
|
271
290
|
</div>
|
|
272
|
-
<
|
|
273
|
-
|
|
274
|
-
disabled={unlinking}
|
|
275
|
-
className="text-text-subtle hover:text-destructive p-1 rounded hover:bg-muted transition-colors shrink-0"
|
|
276
|
-
title="Unlink device"
|
|
277
|
-
>
|
|
278
|
-
{unlinking ? <Loader2 className="size-3 animate-spin" /> : <Unlink className="size-3" />}
|
|
279
|
-
</button>
|
|
280
|
-
</div>
|
|
291
|
+
<CloudAliasRow cloudUrl={cloud.cloud_url} deviceName={cloud.device_name} />
|
|
292
|
+
</>
|
|
281
293
|
) : (
|
|
282
294
|
<button
|
|
283
295
|
onClick={handleLink}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
RefreshCw,
|
|
18
18
|
Plus,
|
|
19
19
|
Columns2,
|
|
20
|
+
Cloud,
|
|
20
21
|
} from "lucide-react";
|
|
21
22
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
22
23
|
import { useProjectStore } from "@/stores/project-store";
|
|
@@ -193,6 +194,7 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
193
194
|
{ id: "new-db-query", label: "New DB Query", icon: Database, action: () => { useTabStore.getState().openNewFile({ language: "sql", title: "SQL Query" }); onClose(); }, keywords: "sql database query scratchpad new", group: "action" },
|
|
194
195
|
{ id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action", shortcut: formatShortcut(getBinding("open-terminal")) },
|
|
195
196
|
{ id: "tunnels", label: "Cloudflare Tunnels", icon: Globe, action: () => { if (sidebarCollapsed) toggleSidebar(); setSidebarActiveTab("tunnels"); onClose(); }, keywords: "web preview localhost port forward tunnel cloudflare url", group: "action" },
|
|
197
|
+
{ id: "cloud-share", label: "PPM Cloud & Share", icon: Cloud, action: () => { window.dispatchEvent(new CustomEvent("open-cloud-share")); onClose(); }, keywords: "cloud permanent link alias share phone remote device qr sign in login", group: "action" },
|
|
196
198
|
{ id: "postgres", label: "PostgreSQL", icon: Database, action: openNewTab("postgres", "PostgreSQL"), keywords: "database pg sql query", group: "action" },
|
|
197
199
|
{ id: "voice-input", label: "Voice Input", icon: Mic, action: () => { window.dispatchEvent(new CustomEvent("toggle-voice-input")); onClose(); }, keywords: "speech microphone dictate voice", group: "action", shortcut: formatShortcut(getBinding("voice-input")) },
|
|
198
200
|
{ id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action", shortcut: formatShortcut(getBinding("open-git-status")) },
|
|
@@ -21,6 +21,7 @@ import { openBugReportPopup } from "@/lib/report-bug";
|
|
|
21
21
|
import { UpgradeButton } from "@/components/layout/upgrade-button";
|
|
22
22
|
import { CloudSharePopover } from "@/components/layout/cloud-share-popover";
|
|
23
23
|
import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
|
|
24
|
+
import { isMobileDevice } from "@/hooks/use-is-mobile";
|
|
24
25
|
import { cn } from "@/lib/utils";
|
|
25
26
|
|
|
26
27
|
// Tab ids the mobile drawer can render content for. `search` is desktop-only for now;
|
|
@@ -62,6 +63,15 @@ export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps)
|
|
|
62
63
|
if (tabs.length > 0 && !tabs.some((t) => t.id === activeTab)) setActiveTab(tabs[0]!.id);
|
|
63
64
|
}, [tabs, activeTab]);
|
|
64
65
|
|
|
66
|
+
// Command palette request — the desktop rail is hidden on mobile, so the
|
|
67
|
+
// sheet here is what opens. The sheet portals to body, so it shows even while
|
|
68
|
+
// the drawer itself is closed.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const open = () => { if (isMobileDevice()) setCloudOpen(true); };
|
|
71
|
+
window.addEventListener("open-cloud-share", open);
|
|
72
|
+
return () => window.removeEventListener("open-cloud-share", open);
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
65
75
|
const handleReportBug = useCallback(() => openBugReportPopup(version), [version]);
|
|
66
76
|
|
|
67
77
|
const noProject = (
|
|
@@ -13,6 +13,7 @@ import { useNotificationStore, selectProjectUnread } from "@/stores/notification
|
|
|
13
13
|
import { NotificationBellPopover } from "./notification-bell-popover";
|
|
14
14
|
import { CloudSharePopover } from "./cloud-share-popover";
|
|
15
15
|
import { openBugReportPopup } from "@/lib/report-bug";
|
|
16
|
+
import { isMobileDevice } from "@/hooks/use-is-mobile";
|
|
16
17
|
import { cn } from "@/lib/utils";
|
|
17
18
|
|
|
18
19
|
function Badge({ count }: { count: number }) {
|
|
@@ -135,6 +136,14 @@ export const NavSectionRail = memo(function NavSectionRail({ className }: { clas
|
|
|
135
136
|
setPopoverPos({ left: rect.right + 6, bottom: window.innerHeight - rect.bottom });
|
|
136
137
|
}, [cloudOpen]);
|
|
137
138
|
|
|
139
|
+
// Command palette can't reach the popover's local state, so it asks via event.
|
|
140
|
+
// The rail is `hidden md:flex`, so on mobile the drawer's bottom sheet answers.
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const open = () => { if (!isMobileDevice()) setCloudOpen(true); };
|
|
143
|
+
window.addEventListener("open-cloud-share", open);
|
|
144
|
+
return () => window.removeEventListener("open-cloud-share", open);
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
138
147
|
const handleReportBug = () => openBugReportPopup(version);
|
|
139
148
|
|
|
140
149
|
// Rail tab click: collapsed → open on any tab; open → clicking the active tab closes it.
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|