@hienlh/ppm 0.17.26 → 0.17.28
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 +16 -0
- package/CLAUDE.md +1 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/cli-reference.md +4 -4
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-qnr1gXKX.js → ai-resource-editor-BCgyv1KF.js} +1 -1
- package/dist/web/assets/{audio-preview-IJyTkZhv.js → audio-preview-BPuiX8ma.js} +1 -1
- package/dist/web/assets/chat-tab-WnnM6GKA.js +13 -0
- package/dist/web/assets/{code-editor-DbgRk7IQ.js → code-editor-CnGUyOuU.js} +2 -2
- package/dist/web/assets/{conflict-editor-7379QQTo.js → conflict-editor-BuLnwxFE.js} +1 -1
- package/dist/web/assets/{csv-preview-CkZtgJJd.js → csv-preview-DdA_IpTo.js} +1 -1
- package/dist/web/assets/{database-viewer-BOWXg1eU.js → database-viewer-BcF3uuk5.js} +1 -1
- package/dist/web/assets/{diff-viewer-3q8WtMhX.js → diff-viewer-tyMHX20V.js} +1 -1
- package/dist/web/assets/{docx-preview-uAcT8IrY.js → docx-preview-BTclTd2v.js} +1 -1
- package/dist/web/assets/{extension-webview-C9B286ef.js → extension-webview-CBLWXhjt.js} +1 -1
- package/dist/web/assets/{git-log-panel-CWfaQmIt.js → git-log-panel-tmGgY2kK.js} +1 -1
- package/dist/web/assets/{glide-data-grid-BTbN5x8y.js → glide-data-grid-CkCW7sFw.js} +3 -3
- package/dist/web/assets/{group-chat-tab-CD1tHFCt.js → group-chat-tab-D_x5CRae.js} +1 -1
- package/dist/web/assets/{image-preview-D35tUSfv.js → image-preview-CQ57RN6l.js} +1 -1
- package/dist/web/assets/{index-DMEso_fH.js → index-Cp3e9olf.js} +3 -3
- package/dist/web/assets/index-DCJ29LBG.css +2 -0
- package/dist/web/assets/{keybindings-store-C56hagRp.js → keybindings-store-CpgEwnFA.js} +1 -1
- package/dist/web/assets/{markdown-renderer-w7cIJc1V.js → markdown-renderer-C7clwuDe.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CaVoLF6f.js → markdown-renderer-devgS0zw.js} +1 -1
- package/dist/web/assets/{notification-store-CijmEBze.js → notification-store-BohufOcS.js} +1 -1
- package/dist/web/assets/{pdf-preview-URqY4tEa.js → pdf-preview-Cw_GRd7e.js} +1 -1
- package/dist/web/assets/{postgres-viewer-D3iSQpJq.js → postgres-viewer-DN40Bdc-.js} +1 -1
- package/dist/web/assets/{settings-tab-BMAlaxBD.js → settings-tab-DkpOd_kU.js} +1 -1
- package/dist/web/assets/{sql-query-editor-Qrom7wG2.js → sql-query-editor-Df1pzXAj.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CPzA2RA4.js → sqlite-viewer-Dbh3LluT.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-DEXXqZIz.js → system-monitor-tab-bbct9aEk.js} +1 -1
- package/dist/web/assets/{terminal-tab-Cn1szPMh.js → terminal-tab-Cqs7xZJt.js} +1 -1
- package/dist/web/assets/{tool-cards-DNftJX5Y.js → tool-cards-BonPvpUI.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-XO5ZyYBW.js → use-monaco-theme-Cqo17yKA.js} +1 -1
- package/dist/web/assets/{video-preview-U2aMrCxv.js → video-preview-kZgggFAn.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +2 -1
- package/docs/lessons-learned.md +24 -0
- package/package.json +1 -1
- package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
- package/scripts/check-ppm-dir-usage.sh +0 -0
- package/scripts/install.sh +0 -0
- package/scripts/release.sh +0 -0
- package/src/services/file-watcher/ignore-rules.ts +63 -0
- package/src/services/file-watcher/watch-tree.ts +244 -0
- package/src/services/file-watcher.service.ts +68 -29
- package/src/web/components/chat/chat-scroll-nav.tsx +234 -0
- package/src/web/components/chat/chat-tab.tsx +14 -0
- package/src/web/components/chat/message-history-recall.ts +22 -0
- package/src/web/components/chat/message-input.tsx +57 -4
- package/src/web/components/chat/message-list.tsx +7 -210
- package/src/web/components/chat/user-message-parse.ts +159 -0
- package/src/web/hooks/use-global-keybindings.ts +15 -0
- package/src/web/lib/owns-global-shortcut.ts +21 -0
- package/src/web/stores/keybindings-store.ts +2 -0
- package/dist/web/assets/chat-tab-Bf7bP_mH.js +0 -13
- package/dist/web/assets/index-DjkJR3PP.css +0 -2
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { WatchTree } from "./file-watcher/watch-tree.ts";
|
|
2
2
|
|
|
3
|
-
const IGNORE = new Set([
|
|
4
|
-
".git", "node_modules", "dist", "build", ".next",
|
|
5
|
-
".turbo", "coverage", "__pycache__", "bun.lock",
|
|
6
|
-
]);
|
|
7
3
|
const DEBOUNCE_MS = 500;
|
|
4
|
+
/**
|
|
5
|
+
* Directory budgets. One covered directory costs one inotify watch on Linux, and
|
|
6
|
+
* the machine-wide default ceiling is 524288 shared with every other process, so
|
|
7
|
+
* PPM stays well inside it even with several projects watched at once.
|
|
8
|
+
*
|
|
9
|
+
* The per-project cap is hard. The total is best-effort: a native recursive watch
|
|
10
|
+
* picks up directories created later without telling us, so exact accounting is not
|
|
11
|
+
* possible — the worst case is a few projects at their own cap, still ~4% of the
|
|
12
|
+
* kernel ceiling.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_DIRS_PER_PROJECT = 8_000;
|
|
15
|
+
const MAX_DIRS_TOTAL = 20_000;
|
|
8
16
|
|
|
9
17
|
type ChangeCallback = (projectName: string, path: string) => void;
|
|
10
18
|
|
|
11
19
|
interface WatchEntry {
|
|
12
|
-
|
|
20
|
+
tree: WatchTree;
|
|
13
21
|
refCount: number;
|
|
14
22
|
timer?: ReturnType<typeof setTimeout>;
|
|
15
23
|
pending: Set<string>;
|
|
@@ -24,9 +32,28 @@ export function onFileChange(cb: ChangeCallback): void {
|
|
|
24
32
|
changeCallbacks.push(cb);
|
|
25
33
|
}
|
|
26
34
|
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
function totalCoveredDirs(): number {
|
|
36
|
+
let total = 0;
|
|
37
|
+
for (const entry of watchers.values()) total += entry.tree.stats().dirs;
|
|
38
|
+
return total;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Emit on a fixed window rather than restarting the timer per event: a project
|
|
43
|
+
* under continuous churn (a build, a large checkout) would otherwise keep pushing
|
|
44
|
+
* the deadline back and never notify the UI at all.
|
|
45
|
+
*/
|
|
46
|
+
function queue(entry: WatchEntry, projectName: string, relPath: string): void {
|
|
47
|
+
entry.pending.add(relPath);
|
|
48
|
+
if (entry.timer) return;
|
|
49
|
+
entry.timer = setTimeout(() => {
|
|
50
|
+
entry.timer = undefined;
|
|
51
|
+
const paths = [...entry.pending];
|
|
52
|
+
entry.pending.clear();
|
|
53
|
+
for (const path of paths) {
|
|
54
|
+
for (const cb of changeCallbacks) cb(projectName, path);
|
|
55
|
+
}
|
|
56
|
+
}, DEBOUNCE_MS);
|
|
30
57
|
}
|
|
31
58
|
|
|
32
59
|
/** Start watching a project directory (ref-counted — safe to call multiple times) */
|
|
@@ -37,28 +64,40 @@ export function startWatching(projectName: string, projectPath: string): void {
|
|
|
37
64
|
return;
|
|
38
65
|
}
|
|
39
66
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
const maxDirs = Math.max(0, Math.min(MAX_DIRS_PER_PROJECT, MAX_DIRS_TOTAL - totalCoveredDirs()));
|
|
68
|
+
const entry: WatchEntry = {
|
|
69
|
+
tree: new WatchTree({
|
|
70
|
+
root: projectPath,
|
|
71
|
+
maxDirs,
|
|
72
|
+
onChange: (relPath) => {
|
|
73
|
+
// Look the entry up again: it may have been stopped while an event was queued.
|
|
74
|
+
const current = watchers.get(projectName);
|
|
75
|
+
if (current) queue(current, projectName, relPath);
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
refCount: 1,
|
|
79
|
+
pending: new Set(),
|
|
80
|
+
};
|
|
81
|
+
watchers.set(projectName, entry);
|
|
45
82
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
entry.timer = setTimeout(() => {
|
|
49
|
-
const paths = [...entry.pending];
|
|
50
|
-
entry.pending.clear();
|
|
51
|
-
for (const p of paths) {
|
|
52
|
-
const relPath = p.replaceAll("\\", "/");
|
|
53
|
-
for (const cb of changeCallbacks) cb(projectName, relPath);
|
|
54
|
-
}
|
|
55
|
-
}, DEBOUNCE_MS);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
watchers.set(projectName, { watcher, refCount: 1, pending: new Set() });
|
|
59
|
-
console.log(`[file-watcher] Started watching: ${projectName}`);
|
|
83
|
+
try {
|
|
84
|
+
entry.tree.start();
|
|
60
85
|
} catch (e) {
|
|
86
|
+
entry.tree.close(); // release whatever attached before the failure
|
|
87
|
+
watchers.delete(projectName);
|
|
61
88
|
console.warn(`[file-watcher] Failed to watch ${projectPath}: ${(e as Error).message}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { dirs, watchers: handles, truncated } = entry.tree.stats();
|
|
93
|
+
console.log(
|
|
94
|
+
`[file-watcher] Started watching: ${projectName} (${dirs} dirs, ${handles} handles${truncated ? ", capped" : ""})`,
|
|
95
|
+
);
|
|
96
|
+
if (truncated) {
|
|
97
|
+
console.warn(
|
|
98
|
+
`[file-watcher] ${projectName} exceeded the ${maxDirs}-directory budget — ` +
|
|
99
|
+
`parts of the tree are not watched. Add build/cache directories to the ignore list.`,
|
|
100
|
+
);
|
|
62
101
|
}
|
|
63
102
|
}
|
|
64
103
|
|
|
@@ -69,7 +108,7 @@ export function stopWatching(projectName: string): void {
|
|
|
69
108
|
entry.refCount--;
|
|
70
109
|
if (entry.refCount <= 0) {
|
|
71
110
|
if (entry.timer) clearTimeout(entry.timer);
|
|
72
|
-
entry.
|
|
111
|
+
entry.tree.close();
|
|
73
112
|
watchers.delete(projectName);
|
|
74
113
|
console.log(`[file-watcher] Stopped watching: ${projectName}`);
|
|
75
114
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { ChevronUp, ChevronDown, ChevronsDown, ChevronsUpDown } from "lucide-react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { useIsMobile } from "@/hooks/use-is-mobile";
|
|
5
|
+
import { ownsGlobalShortcut } from "@/lib/owns-global-shortcut";
|
|
6
|
+
|
|
7
|
+
/** Collapse delay after opening or after the last navigation. */
|
|
8
|
+
const AUTO_COLLAPSE_MS = 3200;
|
|
9
|
+
/** Shorter collapse used when a keyboard shortcut opens the cluster just as feedback. */
|
|
10
|
+
const KEYBOARD_PEEK_MS = 1000;
|
|
11
|
+
|
|
12
|
+
interface NavState {
|
|
13
|
+
hasAbove: boolean;
|
|
14
|
+
atBottom: boolean;
|
|
15
|
+
/** 1-based position of the last user message at or above the viewport top. */
|
|
16
|
+
idx: number;
|
|
17
|
+
total: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const INITIAL: NavState = { hasAbove: false, atBottom: true, idx: 0, total: 0 };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Floating bottom-right navigation between the user's own messages.
|
|
24
|
+
*
|
|
25
|
+
* At rest this is a single dim 30px puck so it reads as chrome and stops covering
|
|
26
|
+
* the tail of long user bubbles. Clicking it expands a vertical cluster
|
|
27
|
+
* (previous · position · next · latest) that auto-collapses after use.
|
|
28
|
+
*
|
|
29
|
+
* Every bubble is in the real DOM (no virtualization), so navigation queries the
|
|
30
|
+
* rendered rows by their `data-msg-index`. Scrolling stays `behavior: "auto"` to
|
|
31
|
+
* avoid fighting `use-stick-to-bottom`.
|
|
32
|
+
*/
|
|
33
|
+
export function ChatScrollNav({ scrollElement, userIndices, scrollToBottom }: {
|
|
34
|
+
scrollElement: HTMLDivElement | null;
|
|
35
|
+
userIndices: number[];
|
|
36
|
+
scrollToBottom: (opts?: { animation?: "instant" | "smooth" }) => void | Promise<boolean> | boolean;
|
|
37
|
+
}) {
|
|
38
|
+
const isMobile = useIsMobile();
|
|
39
|
+
const [nav, setNav] = useState<NavState>(INITIAL);
|
|
40
|
+
const [open, setOpen] = useState(false);
|
|
41
|
+
/** Bumped on every jump so the collapse timer restarts even when `idx` is unchanged. */
|
|
42
|
+
const [jumpTick, setJumpTick] = useState(0);
|
|
43
|
+
const collapseMsRef = useRef(AUTO_COLLAPSE_MS);
|
|
44
|
+
const clusterRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
const puckRef = useRef<HTMLButtonElement>(null);
|
|
46
|
+
/** Set while focus lives inside the cluster, so collapsing can hand it back. */
|
|
47
|
+
const heldFocusRef = useRef(false);
|
|
48
|
+
/** True when the cluster was opened by activating the puck rather than by a shortcut. */
|
|
49
|
+
const focusOnOpenRef = useRef(false);
|
|
50
|
+
|
|
51
|
+
// Top of a row in the scroll container's content coordinates (scrollTop space).
|
|
52
|
+
const rowTop = (el: HTMLElement, row: HTMLElement, elTop: number) =>
|
|
53
|
+
row.getBoundingClientRect().top - elTop + el.scrollTop;
|
|
54
|
+
|
|
55
|
+
const userRows = useCallback((el: HTMLElement) =>
|
|
56
|
+
Array.from(el.querySelectorAll<HTMLElement>("[data-msg-index]"))
|
|
57
|
+
.filter((r) => userIndices.includes(Number(r.dataset.msgIndex))),
|
|
58
|
+
[userIndices]);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const el = scrollElement;
|
|
62
|
+
if (!el) return;
|
|
63
|
+
let raf = 0;
|
|
64
|
+
const recompute = () => {
|
|
65
|
+
const top = el.scrollTop;
|
|
66
|
+
const elTop = el.getBoundingClientRect().top;
|
|
67
|
+
const tops = userRows(el).map((r) => rowTop(el, r, elTop));
|
|
68
|
+
let idx = 0;
|
|
69
|
+
for (let i = 0; i < tops.length; i++) if (tops[i]! <= top + 12) idx = i + 1;
|
|
70
|
+
const next: NavState = {
|
|
71
|
+
hasAbove: tops.some((t) => t < top - 4),
|
|
72
|
+
atBottom: el.scrollHeight - top - el.clientHeight < 8,
|
|
73
|
+
idx,
|
|
74
|
+
total: tops.length,
|
|
75
|
+
};
|
|
76
|
+
setNav((prev) => (
|
|
77
|
+
prev.hasAbove === next.hasAbove && prev.atBottom === next.atBottom
|
|
78
|
+
&& prev.idx === next.idx && prev.total === next.total
|
|
79
|
+
) ? prev : next);
|
|
80
|
+
};
|
|
81
|
+
const schedule = () => {
|
|
82
|
+
if (raf) cancelAnimationFrame(raf);
|
|
83
|
+
raf = requestAnimationFrame(recompute);
|
|
84
|
+
};
|
|
85
|
+
recompute();
|
|
86
|
+
el.addEventListener("scroll", schedule, { passive: true });
|
|
87
|
+
const ro = new ResizeObserver(schedule);
|
|
88
|
+
ro.observe(el);
|
|
89
|
+
return () => {
|
|
90
|
+
if (raf) cancelAnimationFrame(raf);
|
|
91
|
+
el.removeEventListener("scroll", schedule);
|
|
92
|
+
ro.disconnect();
|
|
93
|
+
};
|
|
94
|
+
}, [scrollElement, userRows]);
|
|
95
|
+
|
|
96
|
+
// Auto-collapse. Restarts on every jump so a burst of navigation keeps it open.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!open) return;
|
|
99
|
+
const t = setTimeout(() => setOpen(false), collapseMsRef.current);
|
|
100
|
+
return () => clearTimeout(t);
|
|
101
|
+
}, [open, nav.idx, jumpTick]);
|
|
102
|
+
|
|
103
|
+
// Opening unmounts the puck, so focus would otherwise fall to <body> and restart
|
|
104
|
+
// tab order at the document top. Only pull focus when the puck was activated —
|
|
105
|
+
// the keyboard shortcut fires while the user is reading and must not steal it.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!open || !focusOnOpenRef.current) return;
|
|
108
|
+
focusOnOpenRef.current = false;
|
|
109
|
+
const box = clusterRef.current;
|
|
110
|
+
(box?.querySelector<HTMLButtonElement>("button:not(:disabled)") ?? box)?.focus();
|
|
111
|
+
}, [open]);
|
|
112
|
+
|
|
113
|
+
// Collapsing unmounts whatever the user was on — hand focus back to the puck.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (open || !heldFocusRef.current) return;
|
|
116
|
+
heldFocusRef.current = false;
|
|
117
|
+
puckRef.current?.focus();
|
|
118
|
+
}, [open]);
|
|
119
|
+
|
|
120
|
+
const afterJump = useCallback(() => {
|
|
121
|
+
collapseMsRef.current = AUTO_COLLAPSE_MS;
|
|
122
|
+
setJumpTick((n) => n + 1);
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
const goUp = useCallback(() => {
|
|
126
|
+
const el = scrollElement;
|
|
127
|
+
if (!el) return;
|
|
128
|
+
const top = el.scrollTop;
|
|
129
|
+
const elTop = el.getBoundingClientRect().top;
|
|
130
|
+
let target: number | undefined;
|
|
131
|
+
for (const r of userRows(el)) {
|
|
132
|
+
const t = rowTop(el, r, elTop);
|
|
133
|
+
if (t < top - 4) target = t; // ascending — keep the last one still above
|
|
134
|
+
else break;
|
|
135
|
+
}
|
|
136
|
+
if (target != null) el.scrollTo({ top: target, behavior: "auto" });
|
|
137
|
+
afterJump();
|
|
138
|
+
}, [scrollElement, userRows, afterJump]);
|
|
139
|
+
|
|
140
|
+
const goDown = useCallback(() => {
|
|
141
|
+
const el = scrollElement;
|
|
142
|
+
if (!el) return;
|
|
143
|
+
const top = el.scrollTop;
|
|
144
|
+
const elTop = el.getBoundingClientRect().top;
|
|
145
|
+
const next = userRows(el).map((r) => rowTop(el, r, elTop)).find((t) => t > top + 4);
|
|
146
|
+
if (next != null) el.scrollTo({ top: next, behavior: "auto" });
|
|
147
|
+
else scrollToBottom({ animation: "instant" });
|
|
148
|
+
afterJump();
|
|
149
|
+
}, [scrollElement, userRows, scrollToBottom, afterJump]);
|
|
150
|
+
|
|
151
|
+
const goBottom = useCallback(() => {
|
|
152
|
+
scrollToBottom({ animation: "instant" });
|
|
153
|
+
afterJump();
|
|
154
|
+
}, [scrollToBottom, afterJump]);
|
|
155
|
+
|
|
156
|
+
// Global Alt+Arrow shortcuts arrive as window events from useGlobalKeybindings.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
const el = scrollElement;
|
|
159
|
+
if (!el) return;
|
|
160
|
+
const run = (fn: () => void) => () => {
|
|
161
|
+
if (!ownsGlobalShortcut(el)) return;
|
|
162
|
+
fn();
|
|
163
|
+
// Must follow the jump — the jump handlers reset the delay to the full value.
|
|
164
|
+
collapseMsRef.current = KEYBOARD_PEEK_MS;
|
|
165
|
+
setOpen(true);
|
|
166
|
+
};
|
|
167
|
+
const onPrev = run(goUp);
|
|
168
|
+
const onNext = run(goDown);
|
|
169
|
+
window.addEventListener("chat-nav-prev", onPrev);
|
|
170
|
+
window.addEventListener("chat-nav-next", onNext);
|
|
171
|
+
return () => {
|
|
172
|
+
window.removeEventListener("chat-nav-prev", onPrev);
|
|
173
|
+
window.removeEventListener("chat-nav-next", onNext);
|
|
174
|
+
};
|
|
175
|
+
}, [scrollElement, goUp, goDown]);
|
|
176
|
+
|
|
177
|
+
const rowClass = cn(
|
|
178
|
+
"w-[34px] flex items-center justify-center rounded-[9px] text-text-secondary",
|
|
179
|
+
"motion-safe:transition-colors hover:bg-surface-elevated hover:text-foreground",
|
|
180
|
+
"disabled:opacity-30 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-text-secondary",
|
|
181
|
+
isMobile ? "h-9" : "h-[30px]",
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="absolute bottom-3 right-2.5 z-10 flex flex-col items-end gap-1.5">
|
|
186
|
+
{open ? (
|
|
187
|
+
<div
|
|
188
|
+
ref={clusterRef}
|
|
189
|
+
tabIndex={-1}
|
|
190
|
+
className="flex flex-col p-[3px] rounded-xl border border-border bg-surface-elevated/95 backdrop-blur-[8px] shadow-lg outline-none"
|
|
191
|
+
onFocus={() => { heldFocusRef.current = true; }}
|
|
192
|
+
onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
|
|
193
|
+
>
|
|
194
|
+
<button type="button" onClick={goUp} disabled={!nav.hasAbove} aria-label="Jump to previous message" className={rowClass}>
|
|
195
|
+
<ChevronUp className="size-[15px]" />
|
|
196
|
+
</button>
|
|
197
|
+
<span
|
|
198
|
+
className="font-mono text-[10px] font-medium text-text-subtle text-center py-px"
|
|
199
|
+
aria-label={`Message ${nav.idx || 1} of ${nav.total}`}
|
|
200
|
+
>
|
|
201
|
+
{nav.total ? `${nav.idx || 1}/${nav.total}` : "0/0"}
|
|
202
|
+
</span>
|
|
203
|
+
<button type="button" onClick={goDown} disabled={nav.atBottom} aria-label="Jump to next message" className={rowClass}>
|
|
204
|
+
<ChevronDown className="size-[15px]" />
|
|
205
|
+
</button>
|
|
206
|
+
<button type="button" onClick={goBottom} disabled={nav.atBottom} aria-label="Jump to latest" className={rowClass}>
|
|
207
|
+
<ChevronsDown className="size-[13px]" />
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
) : (
|
|
211
|
+
<button
|
|
212
|
+
ref={puckRef}
|
|
213
|
+
type="button"
|
|
214
|
+
onClick={() => { collapseMsRef.current = AUTO_COLLAPSE_MS; focusOnOpenRef.current = true; setOpen(true); }}
|
|
215
|
+
aria-label="Navigate messages"
|
|
216
|
+
aria-expanded={false}
|
|
217
|
+
title="Navigate messages"
|
|
218
|
+
// Mobile keeps a 36px circle inside a 44px hit area.
|
|
219
|
+
className={cn("group flex items-center justify-center", isMobile ? "size-11" : "size-[30px]")}
|
|
220
|
+
>
|
|
221
|
+
<span
|
|
222
|
+
className={cn(
|
|
223
|
+
"flex items-center justify-center rounded-full border border-border bg-surface-elevated/55 backdrop-blur-[6px] text-text-subtle",
|
|
224
|
+
"motion-safe:transition-colors group-hover:bg-surface-elevated group-hover:text-foreground",
|
|
225
|
+
isMobile ? "size-9" : "size-[30px]",
|
|
226
|
+
)}
|
|
227
|
+
>
|
|
228
|
+
<ChevronsUpDown className="size-[15px]" />
|
|
229
|
+
</span>
|
|
230
|
+
</button>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -411,6 +411,19 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
411
411
|
[handleSend, clearDraft, editFork, sessionId, projectName, handleEditSend, buildMessageWithAttachments],
|
|
412
412
|
);
|
|
413
413
|
|
|
414
|
+
// Past user messages for the composer's ArrowUp/Down recall. Read through a ref
|
|
415
|
+
// so the getter identity stays stable — `messages` changes on every stream chunk,
|
|
416
|
+
// and a fresh array each time would break MessageInput's memo().
|
|
417
|
+
const messagesRef = useRef(messages);
|
|
418
|
+
messagesRef.current = messages;
|
|
419
|
+
const getUserHistory = useCallback(
|
|
420
|
+
() =>
|
|
421
|
+
messagesRef.current
|
|
422
|
+
.filter((m) => m.role === "user" && m.content.trim())
|
|
423
|
+
.map((m) => m.content),
|
|
424
|
+
[],
|
|
425
|
+
);
|
|
426
|
+
|
|
414
427
|
/** Draft auto-save callback — called by MessageInput on content change */
|
|
415
428
|
const handleContentChange = useCallback(
|
|
416
429
|
(content: string, attachments?: DraftAttachment[]) => {
|
|
@@ -680,6 +693,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
680
693
|
onExternalPathsConsumed={handleExternalPathsConsumed}
|
|
681
694
|
onDisambiguate={handleDisambiguate}
|
|
682
695
|
onContentChange={handleContentChange}
|
|
696
|
+
getUserHistory={getUserHistory}
|
|
683
697
|
permissionMode={permissionMode}
|
|
684
698
|
onModeChange={setPermissionMode}
|
|
685
699
|
providerId={providerId}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell-style recall over a chat session's past user messages.
|
|
3
|
+
*
|
|
4
|
+
* `index` counts back from the newest message (0 = newest, 1 = the one before
|
|
5
|
+
* it, …); -1 means the composer holds a live draft rather than a recalled
|
|
6
|
+
* message. `history` arrives oldest-first, matching transcript order.
|
|
7
|
+
*/
|
|
8
|
+
export interface HistoryStep {
|
|
9
|
+
index: number;
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Move `delta` steps into the past (+1) or back toward the draft (-1).
|
|
15
|
+
* Returns null when the step would fall outside the history.
|
|
16
|
+
*/
|
|
17
|
+
export function stepHistory(history: string[], index: number, delta: number): HistoryStep | null {
|
|
18
|
+
if (history.length === 0) return null;
|
|
19
|
+
const next = index + delta;
|
|
20
|
+
if (next < -1 || next >= history.length) return null;
|
|
21
|
+
return { index: next, text: next === -1 ? "" : history[history.length - 1 - next]! };
|
|
22
|
+
}
|
|
@@ -3,8 +3,11 @@ import { ArrowUp, Square, Paperclip, Loader2, Mic, MicOff, Zap, ListOrdered, Clo
|
|
|
3
3
|
import { useVoiceInput } from "@/hooks/use-voice-input";
|
|
4
4
|
import { api, projectUrl, getAuthToken } from "@/lib/api-client";
|
|
5
5
|
import { randomId } from "@/lib/utils";
|
|
6
|
+
import { ownsGlobalShortcut } from "@/lib/owns-global-shortcut";
|
|
6
7
|
import { isImageFile } from "@/lib/file-support";
|
|
7
8
|
import { AttachmentChips } from "./attachment-chips";
|
|
9
|
+
import { stepHistory } from "./message-history-recall";
|
|
10
|
+
import { toComposerDraft } from "./user-message-parse";
|
|
8
11
|
import { ModeSelector, getModeLabel, getModeIcon } from "./mode-selector";
|
|
9
12
|
import { ProviderSelector } from "./provider-selector";
|
|
10
13
|
import { ModelThinkingSelector } from "./model-thinking-selector";
|
|
@@ -56,6 +59,8 @@ interface MessageInputProps {
|
|
|
56
59
|
clearSignal?: number;
|
|
57
60
|
/** Called on content change for draft auto-save */
|
|
58
61
|
onContentChange?: (content: string, attachments?: Array<{ name: string; path: string }>) => void;
|
|
62
|
+
/** Returns this session's user messages, oldest first — powers ArrowUp/Down recall */
|
|
63
|
+
getUserHistory?: () => string[];
|
|
59
64
|
/** Auto-focus textarea on mount */
|
|
60
65
|
autoFocus?: boolean;
|
|
61
66
|
/** Current permission mode */
|
|
@@ -98,6 +103,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
98
103
|
initialValue,
|
|
99
104
|
clearSignal,
|
|
100
105
|
onContentChange,
|
|
106
|
+
getUserHistory,
|
|
101
107
|
autoFocus,
|
|
102
108
|
permissionMode,
|
|
103
109
|
onModeChange,
|
|
@@ -156,6 +162,34 @@ export const MessageInput = memo(function MessageInput({
|
|
|
156
162
|
: mobileTextareaRef.current;
|
|
157
163
|
}, []);
|
|
158
164
|
|
|
165
|
+
// Shell-style history recall. Counts back from the newest user message
|
|
166
|
+
// (0 = newest); -1 means not browsing, so the input holds a live draft.
|
|
167
|
+
const historyIdxRef = useRef(-1);
|
|
168
|
+
|
|
169
|
+
/** Step through past user messages. Returns false when the step is out of range. */
|
|
170
|
+
const recallHistory = useCallback(
|
|
171
|
+
(delta: number) => {
|
|
172
|
+
const step = stepHistory(getUserHistory?.() ?? [], historyIdxRef.current, delta);
|
|
173
|
+
if (!step) return false;
|
|
174
|
+
historyIdxRef.current = step.index;
|
|
175
|
+
// Recalled content still carries its send-time wrappers — unwrap it back
|
|
176
|
+
// into what was typed, with the delegated agent restored as a chip.
|
|
177
|
+
const { agent, text } = toComposerDraft(step.text);
|
|
178
|
+
setAgentTag(agent);
|
|
179
|
+
writeTextareas(text);
|
|
180
|
+
const ta = getVisibleTextarea();
|
|
181
|
+
if (!ta) return true;
|
|
182
|
+
ta.focus();
|
|
183
|
+
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = text.length; });
|
|
184
|
+
if (needsJsResize.current) {
|
|
185
|
+
ta.style.height = "auto";
|
|
186
|
+
ta.style.height = Math.min(ta.scrollHeight, ta === mobileTextareaRef.current ? 80 : 160) + "px";
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
},
|
|
190
|
+
[getUserHistory, writeTextareas, getVisibleTextarea],
|
|
191
|
+
);
|
|
192
|
+
|
|
159
193
|
// Voice input (Web Speech API)
|
|
160
194
|
const voice = useVoiceInput();
|
|
161
195
|
// Store pre-voice text so voice appends to existing input
|
|
@@ -184,12 +218,18 @@ export const MessageInput = memo(function MessageInput({
|
|
|
184
218
|
}
|
|
185
219
|
}, [voice.isListening, voice.start, voice.stop, voiceResultCb]);
|
|
186
220
|
|
|
187
|
-
// Listen for global keyboard shortcut (Cmd+Shift+V) to toggle voice
|
|
221
|
+
// Listen for global keyboard shortcut (Cmd+Shift+V) to toggle voice.
|
|
222
|
+
// Guarded so that only the focused panel's chat reacts — every chat tab stays
|
|
223
|
+
// mounted, so an unguarded listener toggles the mic in all of them at once.
|
|
188
224
|
useEffect(() => {
|
|
189
|
-
const handler = () => {
|
|
225
|
+
const handler = () => {
|
|
226
|
+
if (!voice.supported) return;
|
|
227
|
+
if (!ownsGlobalShortcut(getVisibleTextarea())) return;
|
|
228
|
+
handleVoiceToggle();
|
|
229
|
+
};
|
|
190
230
|
window.addEventListener("toggle-voice-input", handler);
|
|
191
231
|
return () => window.removeEventListener("toggle-voice-input", handler);
|
|
192
|
-
}, [voice.supported, handleVoiceToggle]);
|
|
232
|
+
}, [voice.supported, handleVoiceToggle, getVisibleTextarea]);
|
|
193
233
|
|
|
194
234
|
// Listen for "Send to Chat" from terminal or other tabs — add as attachment chip
|
|
195
235
|
useEffect(() => {
|
|
@@ -485,6 +525,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
485
525
|
setAgentTag(null);
|
|
486
526
|
setPendingSend(false);
|
|
487
527
|
setPriority('next');
|
|
528
|
+
historyIdxRef.current = -1;
|
|
488
529
|
if (needsJsResize.current) {
|
|
489
530
|
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
|
490
531
|
if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
|
|
@@ -520,6 +561,16 @@ export const MessageInput = memo(function MessageInput({
|
|
|
520
561
|
handleSend();
|
|
521
562
|
return;
|
|
522
563
|
}
|
|
564
|
+
// History recall. Only starts from an empty input so ArrowUp keeps moving
|
|
565
|
+
// the caret inside a multi-line draft. The slash/@ pickers swallow these
|
|
566
|
+
// keys before they reach here while either one is open.
|
|
567
|
+
// Bare arrows only — Alt+Arrow is the global chat-transcript jump.
|
|
568
|
+
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
569
|
+
const browsing = historyIdxRef.current >= 0;
|
|
570
|
+
if (!browsing && (e.key === "ArrowDown" || valueRef.current !== "" || agentTag)) return;
|
|
571
|
+
if (recallHistory(e.key === "ArrowUp" ? 1 : -1)) e.preventDefault();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
523
574
|
// Shift+Tab: cycle permission mode
|
|
524
575
|
if (e.shiftKey && e.key === "Tab") {
|
|
525
576
|
e.preventDefault();
|
|
@@ -529,7 +580,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
529
580
|
onModeChange?.(next);
|
|
530
581
|
}
|
|
531
582
|
},
|
|
532
|
-
[handleSend, permissionMode, onModeChange],
|
|
583
|
+
[handleSend, permissionMode, onModeChange, recallHistory, agentTag],
|
|
533
584
|
);
|
|
534
585
|
|
|
535
586
|
const updatePickerState = useCallback(
|
|
@@ -582,6 +633,8 @@ export const MessageInput = memo(function MessageInput({
|
|
|
582
633
|
const el = e.target;
|
|
583
634
|
const text = el.value;
|
|
584
635
|
valueRef.current = text;
|
|
636
|
+
// Typing turns a recalled message back into a live draft.
|
|
637
|
+
historyIdxRef.current = -1;
|
|
585
638
|
// Sync the other textarea (handles viewport rotation edge case)
|
|
586
639
|
const other = el === textareaRef.current ? mobileTextareaRef.current : textareaRef.current;
|
|
587
640
|
if (other) other.value = text;
|