@saadnvd1/agent-os 0.3.649 → 0.3.654
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/components/Chat/ChatPanel.tsx +49 -52
- package/components/Chat/QuoteButton.tsx +24 -7
- package/components/Chat/composer/editor.test.ts +39 -2
- package/components/Chat/quote-selection.test.ts +184 -2
- package/components/Chat/quote-selection.ts +70 -0
- package/components/Chat/useChatCommands.ts +19 -3
- package/components/SessionList/OrchestratorPinRow.tsx +1 -0
- package/components/SessionList/SessionRow.tsx +1 -1
- package/components/SessionList/SessionShelves.tsx +9 -6
- package/components/SessionList/index.tsx +15 -16
- package/components/SessionList/useSessionSwitching.ts +55 -0
- package/hooks/useKeybindings.ts +27 -0
- package/lib/agents/brief.ts +3 -1
- package/lib/chat/background.ts +0 -22
- package/lib/keybindings/chord.ts +114 -0
- package/lib/keybindings/focus.ts +29 -0
- package/lib/keybindings/registry.ts +84 -0
- package/lib/orchestrator/shell-guard.ts +29 -2
- package/lib/orchestrator/write-guard.ts +61 -7
- package/lib/secret-paths.ts +12 -0
- package/lib/sidebar/visible-order.ts +56 -0
- package/lib/simulator/brief.ts +1 -1
- package/package.json +1 -1
|
@@ -43,10 +43,11 @@ import { LearningCardsHost } from "@/components/LearningCards/LearningCard";
|
|
|
43
43
|
import { composerCompacts } from "@/lib/chat/stale";
|
|
44
44
|
import { useFreshStartQuery } from "@/data/fresh-start";
|
|
45
45
|
import { QuoteButton } from "./QuoteButton";
|
|
46
|
+
import { quoteIntoComposer } from "./quote-selection";
|
|
46
47
|
import { ToolBodies, ToolGroup } from "./Tools";
|
|
47
48
|
import { RowState, RowStore } from "./rowState";
|
|
49
|
+
import { useKeybindings } from "@/hooks/useKeybindings";
|
|
48
50
|
import { Background, type BackgroundValue } from "./background";
|
|
49
|
-
import { backgroundKey } from "@/lib/chat/background";
|
|
50
51
|
import { FindBar } from "./FindBar";
|
|
51
52
|
import { FIND_EVENT, isFindKey, useFind } from "./useFind";
|
|
52
53
|
import { McpServers } from "./McpServers";
|
|
@@ -365,6 +366,15 @@ export function ChatPanel({
|
|
|
365
366
|
?.focus(),
|
|
366
367
|
[]
|
|
367
368
|
);
|
|
369
|
+
// Quote button or Enter: the text goes in, and the caret is ready to reply.
|
|
370
|
+
const quote = (text: string) =>
|
|
371
|
+
quoteIntoComposer(
|
|
372
|
+
text,
|
|
373
|
+
// After the draft, so the caret ends at the end of the composer.
|
|
374
|
+
(t) =>
|
|
375
|
+
setInsert({ text: quoteMarkdown(t), at: Date.now(), append: true }),
|
|
376
|
+
focusComposer
|
|
377
|
+
);
|
|
368
378
|
const togglePlan = plan === null ? undefined : () => setPlan(!plan);
|
|
369
379
|
const sendText = (text: string) => {
|
|
370
380
|
toEnd();
|
|
@@ -377,39 +387,24 @@ export function ChatPanel({
|
|
|
377
387
|
const asIs = asIsFor === sessionId;
|
|
378
388
|
const setAsIs = (on: boolean) => setAsIsFor(on ? sessionId : null);
|
|
379
389
|
|
|
390
|
+
const inPanel = (e: KeyboardEvent) =>
|
|
391
|
+
!!rootRef.current?.contains(e.target as Node);
|
|
380
392
|
// Shift+Tab switches plan mode from the composer, as in the terminal.
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
shiftTab.current();
|
|
397
|
-
};
|
|
398
|
-
window.addEventListener("keydown", onKey, true);
|
|
399
|
-
return () => window.removeEventListener("keydown", onKey, true);
|
|
400
|
-
}, []);
|
|
401
|
-
|
|
402
|
-
useChatCommands({
|
|
403
|
-
sessionId,
|
|
404
|
-
connected,
|
|
405
|
-
running,
|
|
406
|
-
context,
|
|
407
|
-
plan,
|
|
408
|
-
send: sendText,
|
|
409
|
-
setPlan,
|
|
410
|
-
interrupt,
|
|
411
|
-
focusComposer,
|
|
412
|
-
});
|
|
393
|
+
useKeybindings(`chat-plan:${sessionId}`, [
|
|
394
|
+
{
|
|
395
|
+
id: "chat.plan",
|
|
396
|
+
keys: "shift+tab",
|
|
397
|
+
when: (e) => {
|
|
398
|
+
const target = e.target as HTMLElement;
|
|
399
|
+
return (
|
|
400
|
+
!!togglePlan &&
|
|
401
|
+
inPanel(e) &&
|
|
402
|
+
(target === rootRef.current || !!target.closest(".chat-composer"))
|
|
403
|
+
);
|
|
404
|
+
},
|
|
405
|
+
run: () => togglePlan?.(),
|
|
406
|
+
},
|
|
407
|
+
]);
|
|
413
408
|
|
|
414
409
|
const blocks = useMemo(
|
|
415
410
|
() => groupTimeline(items, { foldEvents: !!orchestratorOf }),
|
|
@@ -466,20 +461,27 @@ export function ChatPanel({
|
|
|
466
461
|
),
|
|
467
462
|
[canBackground, foregroundTasks]
|
|
468
463
|
);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
464
|
+
useKeybindings(`chat:${sessionId}`, [
|
|
465
|
+
{
|
|
466
|
+
id: "chat.background",
|
|
467
|
+
keys: "ctrl+b",
|
|
468
|
+
when: (e) => canMove && inPanel(e),
|
|
469
|
+
run: () => backgroundTask(),
|
|
470
|
+
},
|
|
471
|
+
]);
|
|
472
|
+
useChatCommands({
|
|
473
|
+
sessionId,
|
|
474
|
+
connected,
|
|
475
|
+
running,
|
|
476
|
+
context,
|
|
477
|
+
plan,
|
|
478
|
+
send: sendText,
|
|
479
|
+
setPlan,
|
|
480
|
+
interrupt,
|
|
481
|
+
focusComposer,
|
|
482
|
+
background: canMove ? () => backgroundTask() : undefined,
|
|
472
483
|
});
|
|
473
|
-
|
|
474
|
-
const onKey = (e: KeyboardEvent) => {
|
|
475
|
-
if (!rootRef.current?.contains(e.target as Node)) return;
|
|
476
|
-
if (!backgroundKey(e, moveRef.current.canMove)) return;
|
|
477
|
-
e.preventDefault();
|
|
478
|
-
moveRef.current.backgroundTask();
|
|
479
|
-
};
|
|
480
|
-
window.addEventListener("keydown", onKey, true);
|
|
481
|
-
return () => window.removeEventListener("keydown", onKey, true);
|
|
482
|
-
}, []);
|
|
484
|
+
|
|
483
485
|
const itemsById = useMemo(
|
|
484
486
|
() => new Map(items.map((i) => [i.id, i])),
|
|
485
487
|
[items]
|
|
@@ -790,12 +792,7 @@ export function ChatPanel({
|
|
|
790
792
|
onStop={stopTask}
|
|
791
793
|
onLoadOutput={loadTaskOutput}
|
|
792
794
|
/>
|
|
793
|
-
<QuoteButton
|
|
794
|
-
rootRef={scrollRef}
|
|
795
|
-
onQuote={(text) =>
|
|
796
|
-
setInsert({ text: quoteMarkdown(text), at: Date.now() })
|
|
797
|
-
}
|
|
798
|
-
/>
|
|
795
|
+
<QuoteButton rootRef={scrollRef} onQuote={quote} />
|
|
799
796
|
<PausedBanner sessionId={sessionId} />
|
|
800
797
|
<Queue
|
|
801
798
|
queue={queue}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState, type RefObject } from "react";
|
|
3
|
+
import { useEffect, useId, useState, type RefObject } from "react";
|
|
4
4
|
import { Quote } from "lucide-react";
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
import { useCoarsePointer } from "./composer/useCoarsePointer";
|
|
7
|
-
import {
|
|
7
|
+
import { useKeybindings } from "@/hooks/useKeybindings";
|
|
8
|
+
import {
|
|
9
|
+
quoteAction,
|
|
10
|
+
quoteEnter,
|
|
11
|
+
placeQuote,
|
|
12
|
+
quotePick,
|
|
13
|
+
type Placement,
|
|
14
|
+
} from "./quote-selection";
|
|
8
15
|
|
|
9
16
|
interface Picked {
|
|
10
17
|
text: string;
|
|
@@ -57,12 +64,16 @@ export function QuoteButton({
|
|
|
57
64
|
};
|
|
58
65
|
}, [rootRef]);
|
|
59
66
|
|
|
67
|
+
const shown = !!picked && (coarse || !!picked.at);
|
|
68
|
+
const quote = quoteAction(picked?.text, {
|
|
69
|
+
onQuote,
|
|
70
|
+
clearSelection: () => window.getSelection()?.removeAllRanges(),
|
|
71
|
+
hide: () => setPicked(null),
|
|
72
|
+
});
|
|
73
|
+
// One source per button: each open chat pane has its own.
|
|
74
|
+
useKeybindings(`chat-quote:${useId()}`, [quoteEnter(shown, quote)]);
|
|
75
|
+
|
|
60
76
|
if (!picked) return null;
|
|
61
|
-
const quote = () => {
|
|
62
|
-
onQuote(picked.text);
|
|
63
|
-
window.getSelection()?.removeAllRanges();
|
|
64
|
-
setPicked(null);
|
|
65
|
-
};
|
|
66
77
|
const button = (
|
|
67
78
|
<button
|
|
68
79
|
type="button"
|
|
@@ -73,6 +84,12 @@ export function QuoteButton({
|
|
|
73
84
|
>
|
|
74
85
|
<Quote className="h-3.5 w-3.5" />
|
|
75
86
|
Quote
|
|
87
|
+
<kbd
|
|
88
|
+
aria-hidden
|
|
89
|
+
className="text-muted-foreground bg-muted hidden rounded px-1 font-mono text-[10px] leading-4 md:inline"
|
|
90
|
+
>
|
|
91
|
+
↵
|
|
92
|
+
</kbd>
|
|
76
93
|
</button>
|
|
77
94
|
);
|
|
78
95
|
if (coarse)
|
|
@@ -55,13 +55,25 @@ const keys = (over: Partial<KeyHandlers> = {}): KeyHandlers => ({
|
|
|
55
55
|
|
|
56
56
|
function press(
|
|
57
57
|
key: string,
|
|
58
|
-
opts: {
|
|
58
|
+
opts: {
|
|
59
|
+
shift?: boolean;
|
|
60
|
+
coarse?: boolean;
|
|
61
|
+
alt?: boolean;
|
|
62
|
+
ctrl?: boolean;
|
|
63
|
+
meta?: boolean;
|
|
64
|
+
} = {},
|
|
59
65
|
h = keys()
|
|
60
66
|
) {
|
|
61
67
|
window.matchMedia = ((q: string) => ({
|
|
62
68
|
matches: Boolean(opts.coarse) && q.includes("coarse"),
|
|
63
69
|
})) as never;
|
|
64
|
-
const event = new KeyboardEvent("keydown", {
|
|
70
|
+
const event = new KeyboardEvent("keydown", {
|
|
71
|
+
key,
|
|
72
|
+
shiftKey: opts.shift,
|
|
73
|
+
altKey: opts.alt,
|
|
74
|
+
ctrlKey: opts.ctrl,
|
|
75
|
+
metaKey: opts.meta,
|
|
76
|
+
});
|
|
65
77
|
if (!handleKeyDown(editor, event, h)) {
|
|
66
78
|
editor.view.someProp("handleKeyDown", (f) => f(editor.view, event));
|
|
67
79
|
}
|
|
@@ -289,6 +301,31 @@ describe("Enter", () => {
|
|
|
289
301
|
expect(taken).toBe(2);
|
|
290
302
|
});
|
|
291
303
|
|
|
304
|
+
it("→ leaves the caret alone with no suggestion, a modifier, or an open menu", () => {
|
|
305
|
+
make();
|
|
306
|
+
let taken = 0;
|
|
307
|
+
const on = keys({ suggestion: true, onAcceptSuggestion: () => taken++ });
|
|
308
|
+
const off = keys({ suggestion: false, onAcceptSuggestion: () => taken++ });
|
|
309
|
+
press("ArrowRight", {}, off);
|
|
310
|
+
for (const mod of [
|
|
311
|
+
{ shift: true },
|
|
312
|
+
{ alt: true },
|
|
313
|
+
{ ctrl: true },
|
|
314
|
+
{ meta: true },
|
|
315
|
+
])
|
|
316
|
+
press("ArrowRight", mod, on);
|
|
317
|
+
const open = keys({
|
|
318
|
+
suggestion: true,
|
|
319
|
+
menuOpen: true,
|
|
320
|
+
menuHasMatches: true,
|
|
321
|
+
onAcceptSuggestion: () => taken++,
|
|
322
|
+
});
|
|
323
|
+
press("ArrowRight", {}, open);
|
|
324
|
+
expect(taken).toBe(0);
|
|
325
|
+
press("ArrowRight", {}, on);
|
|
326
|
+
expect(taken).toBe(1);
|
|
327
|
+
});
|
|
328
|
+
|
|
292
329
|
it("Tab picks from an open menu before taking ghost text", () => {
|
|
293
330
|
make();
|
|
294
331
|
const got: string[] = [];
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { JSDOM } from "jsdom";
|
|
2
|
-
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { KeybindingRegistry } from "@/lib/keybindings/registry";
|
|
4
|
+
import {
|
|
5
|
+
placeQuote,
|
|
6
|
+
quoteAction,
|
|
7
|
+
quoteEnter,
|
|
8
|
+
quoteIntoComposer,
|
|
9
|
+
quotePick,
|
|
10
|
+
rangeText,
|
|
11
|
+
} from "./quote-selection";
|
|
4
12
|
|
|
5
13
|
// The suite's setup needs node, so the DOM is built here.
|
|
6
14
|
const { window } = new JSDOM("<!doctype html><body></body>");
|
|
@@ -165,3 +173,177 @@ describe("placeQuote", () => {
|
|
|
165
173
|
).toBeNull();
|
|
166
174
|
});
|
|
167
175
|
});
|
|
176
|
+
|
|
177
|
+
describe("Enter quotes", () => {
|
|
178
|
+
let root: HTMLElement;
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
root = chat();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const reply = () => text("#a2 p", 1);
|
|
184
|
+
const key = (target: EventTarget, extra: object = {}) =>
|
|
185
|
+
({
|
|
186
|
+
key: "Enter",
|
|
187
|
+
code: "Enter",
|
|
188
|
+
target,
|
|
189
|
+
ctrlKey: false,
|
|
190
|
+
metaKey: false,
|
|
191
|
+
altKey: false,
|
|
192
|
+
shiftKey: false,
|
|
193
|
+
isComposing: false,
|
|
194
|
+
repeat: false,
|
|
195
|
+
preventDefault() {},
|
|
196
|
+
...extra,
|
|
197
|
+
}) as unknown as KeyboardEvent;
|
|
198
|
+
|
|
199
|
+
// Runs a key through a real registry holding the binding.
|
|
200
|
+
function press(shown: boolean, e: KeyboardEvent) {
|
|
201
|
+
const run = vi.fn();
|
|
202
|
+
const reg = new KeybindingRegistry({
|
|
203
|
+
addEventListener() {},
|
|
204
|
+
removeEventListener() {},
|
|
205
|
+
});
|
|
206
|
+
reg.register("t", [quoteEnter(shown, run, () => window.getSelection())]);
|
|
207
|
+
return { handled: reg.handle(e), run };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
it("fires with a selection in a reply and the button showing", () => {
|
|
211
|
+
select([reply(), 4], [reply(), 8]);
|
|
212
|
+
const { handled, run } = press(true, key(root));
|
|
213
|
+
expect(handled).toBe(true);
|
|
214
|
+
expect(run).toHaveBeenCalledOnce();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("leaves Enter alone when the button isn't showing", () => {
|
|
218
|
+
select([reply(), 4], [reply(), 8]);
|
|
219
|
+
expect(press(false, key(root)).handled).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("leaves Enter alone with no selection", () => {
|
|
223
|
+
window.getSelection()!.removeAllRanges();
|
|
224
|
+
expect(press(true, key(root)).handled).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("leaves Enter alone in the composer, key or selection", () => {
|
|
228
|
+
const composer = document.getElementById("composer") as HTMLTextAreaElement;
|
|
229
|
+
select([reply(), 4], [reply(), 8]);
|
|
230
|
+
expect(press(true, key(composer)).handled).toBe(false);
|
|
231
|
+
composer.select();
|
|
232
|
+
select([composer.parentNode!, 0], [composer.parentNode!, 1]);
|
|
233
|
+
const inside = document.createElement("div");
|
|
234
|
+
inside.setAttribute("contenteditable", "true");
|
|
235
|
+
inside.textContent = "draft";
|
|
236
|
+
document.body.append(inside);
|
|
237
|
+
select([inside.firstChild!, 0], [inside.firstChild!, 3]);
|
|
238
|
+
expect(press(true, key(root)).handled).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("leaves a focused button to its own Enter", () => {
|
|
242
|
+
select([reply(), 4], [reply(), 8]);
|
|
243
|
+
const button = document.querySelector("#a2 button")!;
|
|
244
|
+
expect(press(true, key(button)).handled).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it.each(["shiftKey", "metaKey", "ctrlKey", "altKey"])(
|
|
248
|
+
"ignores Enter with %s",
|
|
249
|
+
(mod) => {
|
|
250
|
+
select([reply(), 4], [reply(), 8]);
|
|
251
|
+
expect(press(true, key(root, { [mod]: true })).handled).toBe(false);
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
it("ignores IME composition and repeats", () => {
|
|
256
|
+
select([reply(), 4], [reply(), 8]);
|
|
257
|
+
expect(press(true, key(root, { isComposing: true })).handled).toBe(false);
|
|
258
|
+
expect(press(true, key(root, { repeat: true })).handled).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("quoteIntoComposer", () => {
|
|
263
|
+
it("puts the text in, then focuses the composer", () => {
|
|
264
|
+
const order: string[] = [];
|
|
265
|
+
quoteIntoComposer(
|
|
266
|
+
"hello",
|
|
267
|
+
(t) => order.push(`put:${t}`),
|
|
268
|
+
() => order.push("focus")
|
|
269
|
+
);
|
|
270
|
+
expect(order).toEqual(["put:hello", "focus"]);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("Enter quoting wiring", () => {
|
|
275
|
+
const sel = () => window.getSelection()!;
|
|
276
|
+
const enter = (): KeyboardEvent =>
|
|
277
|
+
({
|
|
278
|
+
key: "Enter",
|
|
279
|
+
code: "Enter",
|
|
280
|
+
target: document.body,
|
|
281
|
+
ctrlKey: false,
|
|
282
|
+
metaKey: false,
|
|
283
|
+
altKey: false,
|
|
284
|
+
shiftKey: false,
|
|
285
|
+
isComposing: false,
|
|
286
|
+
repeat: false,
|
|
287
|
+
preventDefault() {},
|
|
288
|
+
}) as unknown as KeyboardEvent;
|
|
289
|
+
const registry = () =>
|
|
290
|
+
new KeybindingRegistry({
|
|
291
|
+
addEventListener() {},
|
|
292
|
+
removeEventListener() {},
|
|
293
|
+
});
|
|
294
|
+
const pick = () => {
|
|
295
|
+
const root = chat();
|
|
296
|
+
select([text("#a2 p", 1), 4], [text("#a2 p", 1), 8]);
|
|
297
|
+
return root;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
it("Enter hands the text over, clears the selection, hides, focuses", () => {
|
|
301
|
+
pick();
|
|
302
|
+
const calls: string[] = [];
|
|
303
|
+
const action = quoteAction("very", {
|
|
304
|
+
onQuote: (t) =>
|
|
305
|
+
quoteIntoComposer(
|
|
306
|
+
t,
|
|
307
|
+
(m) => calls.push(`put:${m}`),
|
|
308
|
+
() => calls.push("focus")
|
|
309
|
+
),
|
|
310
|
+
clearSelection: () => {
|
|
311
|
+
sel().removeAllRanges();
|
|
312
|
+
calls.push("clear");
|
|
313
|
+
},
|
|
314
|
+
hide: () => calls.push("hide"),
|
|
315
|
+
});
|
|
316
|
+
const reg = registry();
|
|
317
|
+
reg.register("a", [quoteEnter(true, action, sel)]);
|
|
318
|
+
expect(reg.handle(enter())).toBe(true);
|
|
319
|
+
expect(calls).toEqual(["put:very", "focus", "clear", "hide"]);
|
|
320
|
+
expect(sel().isCollapsed).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("does nothing without picked text", () => {
|
|
324
|
+
const onQuote = vi.fn();
|
|
325
|
+
quoteAction(undefined, {
|
|
326
|
+
onQuote,
|
|
327
|
+
clearSelection: vi.fn(),
|
|
328
|
+
hide: vi.fn(),
|
|
329
|
+
})();
|
|
330
|
+
expect(onQuote).not.toHaveBeenCalled();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("keeps one binding per source: panes don't replace each other", () => {
|
|
334
|
+
pick();
|
|
335
|
+
const reg = registry();
|
|
336
|
+
const a = vi.fn();
|
|
337
|
+
const b = vi.fn();
|
|
338
|
+
const offA = reg.register("chat-quote:a", [quoteEnter(true, a, sel)]);
|
|
339
|
+
const offB = reg.register("chat-quote:b", [quoteEnter(false, b, sel)]);
|
|
340
|
+
expect(reg.handle(enter())).toBe(true);
|
|
341
|
+
expect(a).toHaveBeenCalledOnce();
|
|
342
|
+
expect(b).not.toHaveBeenCalled();
|
|
343
|
+
offB();
|
|
344
|
+
expect(reg.handle(enter())).toBe(true);
|
|
345
|
+
expect(a).toHaveBeenCalledTimes(2);
|
|
346
|
+
offA();
|
|
347
|
+
expect(reg.handle(enter())).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Keybinding } from "@/lib/keybindings/registry";
|
|
1
2
|
// What a selection in the chat quotes: the parts of it inside replies
|
|
2
3
|
// (elements marked data-quotable), whatever its ends touch. A triple-click
|
|
3
4
|
// or a drag past a reply's last line ends the range outside the reply, in
|
|
@@ -114,3 +115,72 @@ export function placeQuote(sel: Box, view: Box, size = 48): Placement | null {
|
|
|
114
115
|
side: "below",
|
|
115
116
|
};
|
|
116
117
|
}
|
|
118
|
+
|
|
119
|
+
const TYPES_OR_ACTS =
|
|
120
|
+
'input, textarea, select, button, a[href], summary, [contenteditable]:not([contenteditable="false"])';
|
|
121
|
+
|
|
122
|
+
function inTypingOrAction(node: Node | null): boolean {
|
|
123
|
+
const el =
|
|
124
|
+
node && (node.nodeType === 1 ? (node as Element) : node.parentElement);
|
|
125
|
+
return !!el?.closest(TYPES_OR_ACTS);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Whether Enter quotes: the button is showing for a live selection, and
|
|
129
|
+
// neither the key's target nor the selection sits in the composer or any
|
|
130
|
+
// other field, where Enter keeps its own meaning.
|
|
131
|
+
export function enterQuotes(
|
|
132
|
+
visible: boolean,
|
|
133
|
+
target: EventTarget | null,
|
|
134
|
+
sel: Selection | null
|
|
135
|
+
): boolean {
|
|
136
|
+
if (!visible || !sel || sel.isCollapsed || !sel.rangeCount) return false;
|
|
137
|
+
if (!sel.toString().trim()) return false;
|
|
138
|
+
return (
|
|
139
|
+
!inTypingOrAction(target as Node | null) &&
|
|
140
|
+
!inTypingOrAction(sel.anchorNode) &&
|
|
141
|
+
!inTypingOrAction(sel.focusNode)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// What Quote does, from the button or from Enter: the text goes in the
|
|
146
|
+
// composer, then the composer takes the caret so a reply can follow.
|
|
147
|
+
export function quoteIntoComposer(
|
|
148
|
+
text: string,
|
|
149
|
+
put: (markdown: string) => void,
|
|
150
|
+
focus: () => void
|
|
151
|
+
) {
|
|
152
|
+
put(text);
|
|
153
|
+
focus();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Enter does what the Quote button does, while it's showing.
|
|
157
|
+
export function quoteEnter(
|
|
158
|
+
shown: boolean,
|
|
159
|
+
run: () => void,
|
|
160
|
+
getSel: () => Selection | null = () => window.getSelection()
|
|
161
|
+
): Keybinding {
|
|
162
|
+
return {
|
|
163
|
+
id: "chat.quote",
|
|
164
|
+
keys: "enter",
|
|
165
|
+
when: (e) => enterQuotes(shown, e.target, getSel()),
|
|
166
|
+
run,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// What the button does with the picked text, by click or by Enter: hand it
|
|
171
|
+
// over, clear the selection, hide the button.
|
|
172
|
+
export function quoteAction(
|
|
173
|
+
text: string | undefined,
|
|
174
|
+
io: {
|
|
175
|
+
onQuote: (text: string) => void;
|
|
176
|
+
clearSelection: () => void;
|
|
177
|
+
hide: () => void;
|
|
178
|
+
}
|
|
179
|
+
) {
|
|
180
|
+
return () => {
|
|
181
|
+
if (text === undefined) return;
|
|
182
|
+
io.onQuote(text);
|
|
183
|
+
io.clearSelection();
|
|
184
|
+
io.hide();
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef } from "react";
|
|
4
4
|
import { useSnapshot } from "valtio";
|
|
5
|
-
import { ListChecks, Minimize2, Square } from "lucide-react";
|
|
5
|
+
import { Layers, ListChecks, Minimize2, Square } from "lucide-react";
|
|
6
6
|
import type { ChatContext } from "@/lib/chat/events";
|
|
7
7
|
import { usePaletteCommands } from "@/hooks/usePaletteCommands";
|
|
8
8
|
import { chatMeta, chatMetaActions, type ChatActions } from "@/stores/chatMeta";
|
|
@@ -15,6 +15,7 @@ export function useChatCommands({
|
|
|
15
15
|
running,
|
|
16
16
|
context,
|
|
17
17
|
plan,
|
|
18
|
+
background,
|
|
18
19
|
...handlers
|
|
19
20
|
}: ChatActions & {
|
|
20
21
|
sessionId: string;
|
|
@@ -22,10 +23,12 @@ export function useChatCommands({
|
|
|
22
23
|
running: boolean;
|
|
23
24
|
context: ChatContext | null;
|
|
24
25
|
plan: boolean | null;
|
|
26
|
+
// Set while a running call can move to the background.
|
|
27
|
+
background?: () => void;
|
|
25
28
|
}) {
|
|
26
|
-
const latest = useRef(handlers);
|
|
29
|
+
const latest = useRef({ ...handlers, background });
|
|
27
30
|
useEffect(() => {
|
|
28
|
-
latest.current = handlers;
|
|
31
|
+
latest.current = { ...handlers, background };
|
|
29
32
|
});
|
|
30
33
|
|
|
31
34
|
useEffect(() => {
|
|
@@ -69,6 +72,19 @@ export function useChatCommands({
|
|
|
69
72
|
icon: Minimize2,
|
|
70
73
|
run: () => latest.current.send("/compact"),
|
|
71
74
|
},
|
|
75
|
+
...(background
|
|
76
|
+
? [
|
|
77
|
+
{
|
|
78
|
+
id: "chat.background",
|
|
79
|
+
title: "Run in background",
|
|
80
|
+
group: "Chat",
|
|
81
|
+
keywords: ["move", "detach", "task"],
|
|
82
|
+
hint: "Ctrl+B",
|
|
83
|
+
icon: Layers,
|
|
84
|
+
run: () => latest.current.background?.(),
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
: []),
|
|
72
88
|
...(running
|
|
73
89
|
? [
|
|
74
90
|
{
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
doneSummary,
|
|
9
9
|
type Shelves,
|
|
10
10
|
} from "@/lib/sidebar/shelves";
|
|
11
|
+
import { shownShelves } from "@/lib/sidebar/visible-order";
|
|
11
12
|
import type { MachineGroup } from "@/lib/sidebar/machines";
|
|
12
13
|
import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
13
14
|
import { cn } from "@/lib/utils";
|
|
@@ -124,8 +125,10 @@ export function SessionShelves({
|
|
|
124
125
|
snoozedCollapsed: boolean;
|
|
125
126
|
donePages: number;
|
|
126
127
|
}) {
|
|
128
|
+
const folds = { doneCollapsed, snoozedCollapsed, donePages };
|
|
127
129
|
const shown = shelves.done.slice(0, doneLimit(donePages));
|
|
128
130
|
const hidden = shelves.done.length - shown.length;
|
|
131
|
+
const visible = shownShelves(shelves, folds);
|
|
129
132
|
const pendingCards = usePendingCardSessions();
|
|
130
133
|
const doneCards = shelves.done.reduce(
|
|
131
134
|
(n, r) => n + (pendingCards.get(r.session.id) ?? 0),
|
|
@@ -210,10 +213,9 @@ export function SessionShelves({
|
|
|
210
213
|
collapsed={snoozedCollapsed}
|
|
211
214
|
onToggle={sidebarUiActions.toggleSnoozed}
|
|
212
215
|
/>
|
|
213
|
-
{
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
))}
|
|
216
|
+
{visible.snoozed.map((r) => (
|
|
217
|
+
<SessionRow key={r.session.id} row={r} muted />
|
|
218
|
+
))}
|
|
217
219
|
</section>
|
|
218
220
|
)}
|
|
219
221
|
{shelves.done.length > 0 && (
|
|
@@ -223,8 +225,9 @@ export function SessionShelves({
|
|
|
223
225
|
collapsed={doneCollapsed}
|
|
224
226
|
onToggle={sidebarUiActions.toggleDone}
|
|
225
227
|
/>
|
|
226
|
-
{
|
|
227
|
-
|
|
228
|
+
{visible.done.map((r) => (
|
|
229
|
+
<SessionRow key={r.session.id} row={r} muted />
|
|
230
|
+
))}
|
|
228
231
|
{!doneCollapsed && hidden > 0 && (
|
|
229
232
|
<button
|
|
230
233
|
type="button"
|