@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.
@@ -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
- const shiftTab = useRef(togglePlan);
382
- useEffect(() => {
383
- shiftTab.current = togglePlan;
384
- });
385
- useEffect(() => {
386
- const onKey = (e: KeyboardEvent) => {
387
- if (e.key !== "Tab" || !e.shiftKey || e.metaKey || e.ctrlKey) return;
388
- const target = e.target as HTMLElement;
389
- if (
390
- !shiftTab.current ||
391
- !rootRef.current?.contains(target) ||
392
- !(target === rootRef.current || target.closest(".chat-composer"))
393
- )
394
- return;
395
- e.preventDefault();
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
- const moveRef = useRef({ canMove, backgroundTask });
470
- useEffect(() => {
471
- moveRef.current = { canMove, backgroundTask };
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
- useEffect(() => {
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 { placeQuote, quotePick, type Placement } from "./quote-selection";
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: { shift?: boolean; coarse?: boolean } = {},
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", { key, shiftKey: opts.shift });
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 { placeQuote, quotePick, rangeText } from "./quote-selection";
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
  {
@@ -56,6 +56,7 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
56
56
 
57
57
  const content = (
58
58
  <RowFrame
59
+ data-session-row={session.id}
59
60
  data-tip={`${label}\n${summary}`}
60
61
  onActivate={() => !renaming && ctx.onSelect(session.id)}
61
62
  className={
@@ -76,7 +76,7 @@ export const SessionRow = memo(function SessionRow({
76
76
  );
77
77
 
78
78
  return (
79
- <div>
79
+ <div data-session-row={session.id}>
80
80
  <RowShell
81
81
  title={session.name}
82
82
  tipKey={`session:${session.id}`}
@@ -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
- {!snoozedCollapsed &&
214
- shelves.snoozed.map((r) => (
215
- <SessionRow key={r.session.id} row={r} muted />
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
- {!doneCollapsed &&
227
- shown.map((r) => <SessionRow key={r.session.id} row={r} muted />)}
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 type { SidebarRow } from "@/lib/sidebar/shelves";
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
- flatten([
63
- ...shelves.orchestrators,
64
- ...shelves.pinned,
65
- ...shelves.needsYou,
66
- ...shelves.working,
67
- ...shelves.inReview,
68
- ...shelves.ongoing,
69
- ...shelves.snoozed,
70
- ...shelves.done,
71
- ...data.machines.flatMap((g) => g.rows),
72
- ]),
73
- [shelves, data.machines]
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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.649",
3
+ "version": "0.3.653",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"