@saadnvd1/agent-os 0.3.649 → 0.3.653
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/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/sidebar/visible-order.ts +56 -0
- 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"
|
|
@@ -7,12 +7,13 @@ import { useDocsWorkspace } from "@/data/lumifyhub/docs";
|
|
|
7
7
|
import { useFreshStart, usePinSession } from "@/data/sessions";
|
|
8
8
|
import { useOrchestratorsQuery } from "@/data/orchestrators";
|
|
9
9
|
import { docsUiActions } from "@/stores/docsUi";
|
|
10
|
-
import
|
|
10
|
+
import { visibleSessionIds } from "@/lib/sidebar/visible-order";
|
|
11
11
|
import type { OrchestratorOverview } from "@/lib/orchestrator/overview";
|
|
12
12
|
import { useSessionListMutations } from "./hooks/useSessionListMutations";
|
|
13
13
|
import { useBulkDelete } from "./hooks/useBulkDelete";
|
|
14
14
|
import { useSidebarData } from "./useSidebarData";
|
|
15
15
|
import { useSessionTips } from "./useSessionTips";
|
|
16
|
+
import { useSessionSwitching } from "./useSessionSwitching";
|
|
16
17
|
import { RowProvider, type RowContextValue } from "./RowContext";
|
|
17
18
|
import { SidebarHeader } from "./SidebarHeader";
|
|
18
19
|
import { useAddProject } from "@/components/Projects/AddProject/useAddProject";
|
|
@@ -28,9 +29,6 @@ export type { SessionListProps } from "./SessionList.types";
|
|
|
28
29
|
// Shared, so a query still loading doesn't hand rows a new array each render.
|
|
29
30
|
const NO_ORCHESTRATORS: OrchestratorOverview[] = [];
|
|
30
31
|
|
|
31
|
-
const flatten = (rows: SidebarRow[]): string[] =>
|
|
32
|
-
rows.flatMap((r) => [r.session.id, ...flatten(r.workers)]);
|
|
33
|
-
|
|
34
32
|
// One flat list of the workspace's sessions, on shelves: Pinned, Needs
|
|
35
33
|
// you, Working, In review, Done.
|
|
36
34
|
export function SessionList({
|
|
@@ -59,19 +57,20 @@ export function SessionList({
|
|
|
59
57
|
|
|
60
58
|
const orderedIds = useMemo(
|
|
61
59
|
() =>
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
60
|
+
visibleSessionIds(shelves, data.machines, {
|
|
61
|
+
doneCollapsed: ui.doneCollapsed,
|
|
62
|
+
snoozedCollapsed: ui.snoozedCollapsed,
|
|
63
|
+
donePages: ui.donePages,
|
|
64
|
+
}),
|
|
65
|
+
[
|
|
66
|
+
shelves,
|
|
67
|
+
data.machines,
|
|
68
|
+
ui.doneCollapsed,
|
|
69
|
+
ui.snoozedCollapsed,
|
|
70
|
+
ui.donePages,
|
|
71
|
+
]
|
|
74
72
|
);
|
|
73
|
+
useSessionSwitching(orderedIds, activeSessionId, onSelect);
|
|
75
74
|
|
|
76
75
|
// Rows read their handlers through the latest render, so the context only
|
|
77
76
|
// changes when what rows draw does: a status push redraws the rows it
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { MessageSquare } from "lucide-react";
|
|
4
|
+
import { useKeybindings } from "@/hooks/useKeybindings";
|
|
5
|
+
import { usePaletteCommands } from "@/hooks/usePaletteCommands";
|
|
6
|
+
import { chordHint } from "@/lib/keybindings/chord";
|
|
7
|
+
import { fieldOwnsKey } from "@/lib/keybindings/focus";
|
|
8
|
+
import { neighbourSession } from "@/lib/sidebar/visible-order";
|
|
9
|
+
|
|
10
|
+
const PREVIOUS = "mod+up";
|
|
11
|
+
const NEXT = "mod+down";
|
|
12
|
+
|
|
13
|
+
// ⌘↑ / ⌘↓ (Ctrl off a Mac) select the row above or below the active one, as
|
|
14
|
+
// a click would, in the order the sidebar shows. Nothing happens at either end.
|
|
15
|
+
export function useSessionSwitching(
|
|
16
|
+
orderedIds: string[],
|
|
17
|
+
activeSessionId: string | undefined,
|
|
18
|
+
onSelect: (id: string) => void
|
|
19
|
+
) {
|
|
20
|
+
const step = (by: 1 | -1) => {
|
|
21
|
+
const id = neighbourSession(orderedIds, activeSessionId, by);
|
|
22
|
+
if (!id) return;
|
|
23
|
+
onSelect(id);
|
|
24
|
+
requestAnimationFrame(() =>
|
|
25
|
+
document
|
|
26
|
+
.querySelector(`[data-session-row="${CSS.escape(id)}"]`)
|
|
27
|
+
?.scrollIntoView({ block: "nearest" })
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
const free = (e: KeyboardEvent) => !fieldOwnsKey(e.target);
|
|
31
|
+
useKeybindings("session-switching", [
|
|
32
|
+
{ id: "session.previous", keys: PREVIOUS, when: free, run: () => step(-1) },
|
|
33
|
+
{ id: "session.next", keys: NEXT, when: free, run: () => step(1) },
|
|
34
|
+
]);
|
|
35
|
+
usePaletteCommands("session-switching", [
|
|
36
|
+
{
|
|
37
|
+
id: "session.previous",
|
|
38
|
+
title: "Previous session",
|
|
39
|
+
group: "Sessions",
|
|
40
|
+
hint: chordHint(PREVIOUS),
|
|
41
|
+
keywords: ["switch", "up"],
|
|
42
|
+
icon: MessageSquare,
|
|
43
|
+
run: () => step(-1),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "session.next",
|
|
47
|
+
title: "Next session",
|
|
48
|
+
group: "Sessions",
|
|
49
|
+
hint: chordHint(NEXT),
|
|
50
|
+
keywords: ["switch", "down"],
|
|
51
|
+
icon: MessageSquare,
|
|
52
|
+
run: () => step(1),
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { keybindings, type Keybinding } from "@/lib/keybindings/registry";
|
|
5
|
+
|
|
6
|
+
// Binds chords while the caller is mounted. `when` and `run` always see the
|
|
7
|
+
// latest render, so callers don't memoise them.
|
|
8
|
+
export function useKeybindings(source: string, bindings: Keybinding[]) {
|
|
9
|
+
const latest = useRef(bindings);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
latest.current = bindings;
|
|
12
|
+
});
|
|
13
|
+
const keys = bindings
|
|
14
|
+
.map((b) => `${b.id}:${b.keys}:${b.repeat ? 1 : 0}`)
|
|
15
|
+
.join("|");
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const at = (id: string) => latest.current.find((b) => b.id === id)!;
|
|
18
|
+
return keybindings.register(
|
|
19
|
+
source,
|
|
20
|
+
latest.current.map((b) => ({
|
|
21
|
+
...b,
|
|
22
|
+
when: (e) => at(b.id).when?.(e) ?? true,
|
|
23
|
+
run: () => at(b.id).run(),
|
|
24
|
+
}))
|
|
25
|
+
);
|
|
26
|
+
}, [source, keys]);
|
|
27
|
+
}
|
package/lib/chat/background.ts
CHANGED
|
@@ -24,28 +24,6 @@ export function backgroundable(
|
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// Ctrl+B, nothing else held, and only while there is something to move;
|
|
28
|
-
// otherwise the key is left to the page.
|
|
29
|
-
export function backgroundKey(
|
|
30
|
-
e: {
|
|
31
|
-
key: string;
|
|
32
|
-
ctrlKey: boolean;
|
|
33
|
-
metaKey: boolean;
|
|
34
|
-
altKey: boolean;
|
|
35
|
-
shiftKey: boolean;
|
|
36
|
-
repeat?: boolean;
|
|
37
|
-
isComposing?: boolean;
|
|
38
|
-
},
|
|
39
|
-
available: boolean
|
|
40
|
-
): boolean {
|
|
41
|
-
return (
|
|
42
|
-
available &&
|
|
43
|
-
e.key.toLowerCase() === "b" &&
|
|
44
|
-
e.ctrlKey &&
|
|
45
|
-
!(e.metaKey || e.altKey || e.shiftKey || e.repeat || e.isComposing)
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
27
|
// Errors that mean the agent never got the request.
|
|
50
28
|
const UNREACHED = new Set(["unsupported", "unavailable", "timeout"]);
|
|
51
29
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export interface Chord {
|
|
2
|
+
key: string;
|
|
3
|
+
ctrl: boolean;
|
|
4
|
+
meta: boolean;
|
|
5
|
+
alt: boolean;
|
|
6
|
+
shift: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface KeyLike {
|
|
10
|
+
key: string;
|
|
11
|
+
ctrlKey: boolean;
|
|
12
|
+
metaKey: boolean;
|
|
13
|
+
altKey: boolean;
|
|
14
|
+
shiftKey: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MODS: Record<string, "ctrl" | "meta" | "alt" | "shift"> = {
|
|
18
|
+
ctrl: "ctrl",
|
|
19
|
+
control: "ctrl",
|
|
20
|
+
cmd: "meta",
|
|
21
|
+
meta: "meta",
|
|
22
|
+
alt: "alt",
|
|
23
|
+
option: "alt",
|
|
24
|
+
shift: "shift",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const KEY_NAMES: Record<string, string> = {
|
|
28
|
+
esc: "escape",
|
|
29
|
+
space: " ",
|
|
30
|
+
return: "enter",
|
|
31
|
+
up: "arrowup",
|
|
32
|
+
down: "arrowdown",
|
|
33
|
+
left: "arrowleft",
|
|
34
|
+
right: "arrowright",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const isMac = () =>
|
|
38
|
+
typeof navigator !== "undefined" &&
|
|
39
|
+
/mac|iphone|ipad/i.test(navigator.platform);
|
|
40
|
+
|
|
41
|
+
// "ctrl+b", "shift+tab", "meta+k". Modifiers must match exactly: ctrl+b is
|
|
42
|
+
// not cmd+b and not ctrl+shift+b. "mod" is Cmd on a Mac and Ctrl elsewhere.
|
|
43
|
+
export function parseChord(keys: string, mac: boolean = isMac()): Chord {
|
|
44
|
+
const chord: Chord = {
|
|
45
|
+
key: "",
|
|
46
|
+
ctrl: false,
|
|
47
|
+
meta: false,
|
|
48
|
+
alt: false,
|
|
49
|
+
shift: false,
|
|
50
|
+
};
|
|
51
|
+
for (const raw of keys.toLowerCase().split("+")) {
|
|
52
|
+
const part = raw.trim();
|
|
53
|
+
const mod = part === "mod" ? (mac ? "meta" : "ctrl") : MODS[part];
|
|
54
|
+
if (mod) chord[mod] = true;
|
|
55
|
+
else if (chord.key) throw new Error(`Two keys in chord "${keys}"`);
|
|
56
|
+
else chord.key = KEY_NAMES[part] ?? part;
|
|
57
|
+
}
|
|
58
|
+
if (!chord.key) throw new Error(`No key in chord "${keys}"`);
|
|
59
|
+
return chord;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function matchChord(chord: Chord, e: KeyLike): boolean {
|
|
63
|
+
return (
|
|
64
|
+
e.key.toLowerCase() === chord.key &&
|
|
65
|
+
e.ctrlKey === chord.ctrl &&
|
|
66
|
+
e.metaKey === chord.meta &&
|
|
67
|
+
e.altKey === chord.alt &&
|
|
68
|
+
e.shiftKey === chord.shift
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// "Ctrl+B" for display (the label users know from Claude Code).
|
|
73
|
+
export function formatChord(keys: string): string {
|
|
74
|
+
return keys
|
|
75
|
+
.split("+")
|
|
76
|
+
.map((p) => {
|
|
77
|
+
const t = p.trim();
|
|
78
|
+
return t.length === 1
|
|
79
|
+
? t.toUpperCase()
|
|
80
|
+
: t[0].toUpperCase() + t.slice(1).toLowerCase();
|
|
81
|
+
})
|
|
82
|
+
.join("+");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const SYMBOLS: Record<string, string> = {
|
|
86
|
+
up: "↑",
|
|
87
|
+
down: "↓",
|
|
88
|
+
left: "←",
|
|
89
|
+
right: "→",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// "mod+up" as a hint: "⌘↑" on a Mac, "Ctrl+↑" elsewhere.
|
|
93
|
+
export function chordHint(keys: string, mac: boolean = isMac()): string {
|
|
94
|
+
const parts = keys.split("+").map((p) => p.trim().toLowerCase());
|
|
95
|
+
const label = (p: string) =>
|
|
96
|
+
SYMBOLS[p] ??
|
|
97
|
+
(p.length === 1 ? p.toUpperCase() : p[0].toUpperCase() + p.slice(1));
|
|
98
|
+
if (!mac)
|
|
99
|
+
return parts
|
|
100
|
+
.map((p) =>
|
|
101
|
+
p === "mod" || p === "cmd" || p === "meta" ? "Ctrl" : label(p)
|
|
102
|
+
)
|
|
103
|
+
.join("+");
|
|
104
|
+
const sym: Record<string, string> = {
|
|
105
|
+
mod: "⌘",
|
|
106
|
+
cmd: "⌘",
|
|
107
|
+
meta: "⌘",
|
|
108
|
+
ctrl: "⌃",
|
|
109
|
+
alt: "⌥",
|
|
110
|
+
option: "⌥",
|
|
111
|
+
shift: "⇧",
|
|
112
|
+
};
|
|
113
|
+
return parts.map((p) => sym[p] ?? label(p)).join("");
|
|
114
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const NON_TEXT = new Set([
|
|
2
|
+
"checkbox",
|
|
3
|
+
"radio",
|
|
4
|
+
"button",
|
|
5
|
+
"submit",
|
|
6
|
+
"reset",
|
|
7
|
+
"range",
|
|
8
|
+
"color",
|
|
9
|
+
"file",
|
|
10
|
+
"image",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
// Whether a vertical-arrow chord should be left to the focused field: it has
|
|
14
|
+
// text, so ⌘↑/⌘↓ move its caret. An empty field, a terminal, or no field
|
|
15
|
+
// at all lets the app have the key. A dialog (the palette) keeps its keys.
|
|
16
|
+
export function fieldOwnsKey(target: EventTarget | null): boolean {
|
|
17
|
+
const el = target as HTMLElement | null;
|
|
18
|
+
if (!el || typeof el.closest !== "function") return false;
|
|
19
|
+
if (el.closest(".xterm")) return false;
|
|
20
|
+
if (el.closest('[role="dialog"]')) return true;
|
|
21
|
+
const tag = el.tagName;
|
|
22
|
+
if (tag === "TEXTAREA") return (el as HTMLTextAreaElement).value.length > 0;
|
|
23
|
+
if (tag === "INPUT") {
|
|
24
|
+
const input = el as HTMLInputElement;
|
|
25
|
+
return !NON_TEXT.has(input.type) && input.value.length > 0;
|
|
26
|
+
}
|
|
27
|
+
if (el.isContentEditable) return (el.textContent ?? "").trim().length > 0;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { matchChord, parseChord, type Chord, type KeyLike } from "./chord";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Key chords tied to commands. Features register theirs under a source name
|
|
5
|
+
* and take them back the same way; one keydown listener serves them all.
|
|
6
|
+
*
|
|
7
|
+
* To add a shortcut, add one line to the `useKeybindings` call of the
|
|
8
|
+
* feature that owns the action: { id, keys: "ctrl+b", when, run }.
|
|
9
|
+
*/
|
|
10
|
+
export interface Keybinding {
|
|
11
|
+
id: string;
|
|
12
|
+
// "ctrl+b", "shift+tab", "esc": modifiers must match exactly.
|
|
13
|
+
keys: string;
|
|
14
|
+
// Whether it applies right now. The key is left alone when it doesn't.
|
|
15
|
+
when?: (e: KeyboardEvent) => boolean;
|
|
16
|
+
// Also fire while the key is held down.
|
|
17
|
+
repeat?: boolean;
|
|
18
|
+
run: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Compiled {
|
|
22
|
+
binding: Keybinding;
|
|
23
|
+
chord: Chord;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class KeybindingRegistry {
|
|
27
|
+
private sources = new Map<string, Compiled[]>();
|
|
28
|
+
private attached = false;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private target: Pick<Window, "addEventListener" | "removeEventListener">
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
// Replaces what a source binds; returns how to take it back.
|
|
35
|
+
register(source: string, bindings: Keybinding[]): () => void {
|
|
36
|
+
const compiled = bindings.map((binding) => ({
|
|
37
|
+
binding,
|
|
38
|
+
chord: parseChord(binding.keys),
|
|
39
|
+
}));
|
|
40
|
+
this.sources.set(source, compiled);
|
|
41
|
+
this.attach();
|
|
42
|
+
return () => {
|
|
43
|
+
if (this.sources.get(source) !== compiled) return;
|
|
44
|
+
this.sources.delete(source);
|
|
45
|
+
if (!this.sources.size) this.detach();
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Runs the first binding whose chord matches and whose `when` holds, and
|
|
50
|
+
// reports whether one did (the caller then stops the key).
|
|
51
|
+
handle(e: KeyboardEvent): boolean {
|
|
52
|
+
for (const list of this.sources.values())
|
|
53
|
+
for (const { binding, chord } of list) {
|
|
54
|
+
if (!matchChord(chord, e as KeyLike)) continue;
|
|
55
|
+
if (e.isComposing || (e.repeat && !binding.repeat)) continue;
|
|
56
|
+
if (binding.when && !binding.when(e)) continue;
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
binding.run();
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private onKey = (e: Event) => {
|
|
65
|
+
this.handle(e as KeyboardEvent);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
private attach() {
|
|
69
|
+
if (this.attached) return;
|
|
70
|
+
this.attached = true;
|
|
71
|
+
this.target.addEventListener("keydown", this.onKey, true);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private detach() {
|
|
75
|
+
this.attached = false;
|
|
76
|
+
this.target.removeEventListener("keydown", this.onKey, true);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const keybindings = new KeybindingRegistry(
|
|
81
|
+
typeof window === "undefined"
|
|
82
|
+
? { addEventListener() {}, removeEventListener() {} }
|
|
83
|
+
: window
|
|
84
|
+
);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { MachineGroup } from "./machines";
|
|
2
|
+
import { doneLimit, type Shelves, type SidebarRow } from "./shelves";
|
|
3
|
+
|
|
4
|
+
export interface ShelfFolds {
|
|
5
|
+
doneCollapsed: boolean;
|
|
6
|
+
snoozedCollapsed: boolean;
|
|
7
|
+
donePages: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// The Snoozed and Done rows the sidebar draws: none while folded, Done cut
|
|
11
|
+
// to the pages shown. SessionShelves renders from this too, so the two agree.
|
|
12
|
+
export function shownShelves(shelves: Shelves, folds: ShelfFolds) {
|
|
13
|
+
return {
|
|
14
|
+
snoozed: folds.snoozedCollapsed ? [] : shelves.snoozed,
|
|
15
|
+
done: folds.doneCollapsed
|
|
16
|
+
? []
|
|
17
|
+
: shelves.done.slice(0, doneLimit(folds.donePages)),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const flatten = (rows: SidebarRow[]): string[] =>
|
|
22
|
+
rows.flatMap((r) => [r.session.id, ...flatten(r.workers)]);
|
|
23
|
+
|
|
24
|
+
// Session ids top to bottom, as the sidebar draws them: folded shelves are
|
|
25
|
+
// out, a conductor's workers follow it. The one order for switching sessions.
|
|
26
|
+
export function visibleSessionIds(
|
|
27
|
+
shelves: Shelves,
|
|
28
|
+
machines: MachineGroup[],
|
|
29
|
+
folds: ShelfFolds
|
|
30
|
+
): string[] {
|
|
31
|
+
const shown = shownShelves(shelves, folds);
|
|
32
|
+
return flatten([
|
|
33
|
+
...shelves.orchestrators,
|
|
34
|
+
...shelves.pinned,
|
|
35
|
+
...shelves.needsYou,
|
|
36
|
+
...shelves.working,
|
|
37
|
+
...shelves.inReview,
|
|
38
|
+
...shelves.ongoing,
|
|
39
|
+
...shown.snoozed,
|
|
40
|
+
...shown.done,
|
|
41
|
+
...machines.flatMap((g) => g.rows),
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The neighbour of `activeId` one step up (-1) or down (1); null at either
|
|
46
|
+
// end (no wrap), and the first or last row when nothing is active yet.
|
|
47
|
+
export function neighbourSession(
|
|
48
|
+
ids: string[],
|
|
49
|
+
activeId: string | undefined,
|
|
50
|
+
step: 1 | -1
|
|
51
|
+
): string | null {
|
|
52
|
+
if (!ids.length) return null;
|
|
53
|
+
const at = activeId ? ids.indexOf(activeId) : -1;
|
|
54
|
+
if (at < 0) return step === 1 ? ids[0] : ids[ids.length - 1];
|
|
55
|
+
return ids[at + step] ?? null;
|
|
56
|
+
}
|