@saadnvd1/agent-os 0.3.544 → 0.3.546

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/app/page.tsx CHANGED
@@ -46,6 +46,7 @@ import type { TerminalHandle } from "@/components/Terminal";
46
46
  import { getProvider } from "@/lib/providers";
47
47
  import { CLAUDE_STATUS_SETTINGS_FLAG } from "@/lib/program-status/claude-flag";
48
48
  import { DesktopView } from "@/components/views/DesktopView";
49
+ import { focusComposerInGesture } from "@/components/Chat/composer/autofocus";
49
50
  import { MobileView } from "@/components/views/MobileView";
50
51
  import { getPendingPrompt, clearPendingPrompt } from "@/stores/initialPrompt";
51
52
  import { useDraftKeys } from "@/hooks/useDraftKeys";
@@ -533,6 +534,20 @@ function HomeContent() {
533
534
  return () => window.removeEventListener("keydown", handleKeyDown);
534
535
  }, []);
535
536
 
537
+ // Picking a session with a tap, ⌘K or the keyboard: the composer is focused
538
+ // inside the gesture, which iOS requires. Other ways in (URL, notification,
539
+ // a session just created) use attachToSession's later focus.
540
+ const selectSession = useCallback(
541
+ (session: Session) => {
542
+ focusComposerInGesture(
543
+ document.querySelector(`[data-pane-id="${focusedPaneId}"]`),
544
+ session
545
+ );
546
+ void attachToSession(session);
547
+ },
548
+ [attachToSession, focusedPaneId]
549
+ );
550
+
536
551
  // Session selection handler
537
552
  const handleSelectSession = useCallback(
538
553
  (sessionId: string) => {
@@ -661,7 +676,7 @@ function HomeContent() {
661
676
 
662
677
  useAppCommands({
663
678
  sessions,
664
- onSelectSession: attachToSession,
679
+ onSelectSession: selectSession,
665
680
  onSearchCode: () => setShowQuickSwitcher(true),
666
681
  });
667
682
  useMoveCommands(activeSession);
@@ -683,6 +698,7 @@ function HomeContent() {
683
698
  setShowQuickSwitcher,
684
699
  notificationSettings,
685
700
  attachToSession,
701
+ selectSession,
686
702
  openSessionInNewTab,
687
703
  handleNewSessionInProject,
688
704
  handleOpenTerminal,
@@ -30,6 +30,7 @@ import { useImageDrop } from "./useImageDrop";
30
30
  import type { MenuKey } from "./keys";
31
31
  import { useComposerEditor, type ComposerHandlers } from "./useComposerEditor";
32
32
  import { useFocusOnShow } from "./autofocus";
33
+ import { usePhoneLayout } from "./layout";
33
34
 
34
35
  // Handled here rather than sent: picking it opens the model picker.
35
36
  const MODEL_COMMAND: ChatCommand = {
@@ -108,6 +109,7 @@ export function ComposerBody({
108
109
  const [modelOpen, setModelOpen] = useState(false);
109
110
  const handlers = useRef<ComposerHandlers | null>(null);
110
111
  const coarse = useCoarsePointer();
112
+ const phone = usePhoneLayout();
111
113
  const [historyIndex, setHistoryIndex] = useState(-1);
112
114
  // The editor shows it only while it's empty, as its placeholder.
113
115
  const { editor, text, setText, caret, plain, setPlain } = useComposerEditor({
@@ -138,7 +140,7 @@ export function ComposerBody({
138
140
  setImages(saved?.images ?? []);
139
141
  setFiles(saved?.files ?? []);
140
142
  }, [draftKey, editor, setText]);
141
- useFocusOnShow(editor, focusKey, coarse, disabled);
143
+ useFocusOnShow(editor, focusKey, phone, disabled);
142
144
 
143
145
  const prefilled = useRef<number | null>(null);
144
146
  const inserted = useRef<number | null>(null);
@@ -3,7 +3,13 @@ import { act, createElement } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { Editor } from "@tiptap/react";
6
- import { DIALOG_WAIT_MS, focusVerdict, useFocusOnShow } from "./autofocus";
6
+ import {
7
+ DIALOG_WAIT_MS,
8
+ focusComposerInGesture,
9
+ focusVerdict,
10
+ useFocusOnShow,
11
+ } from "./autofocus";
12
+ import { isPhoneLayout } from "./layout";
7
13
 
8
14
  (
9
15
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
@@ -35,6 +41,131 @@ beforeEach(() => {
35
41
  document.getSelection()?.removeAllRanges();
36
42
  });
37
43
 
44
+ function stubMedia(matches: Record<string, boolean>) {
45
+ window.matchMedia = ((q: string) => ({
46
+ matches: matches[q] ?? false,
47
+ addEventListener: () => {},
48
+ removeEventListener: () => {},
49
+ })) as never;
50
+ }
51
+
52
+ afterEach(() => {
53
+ delete (window as { matchMedia?: unknown }).matchMedia;
54
+ document.querySelectorAll("[data-composer-proxy]").forEach((e) => e.remove());
55
+ });
56
+
57
+ describe("focusComposerInGesture", () => {
58
+ const chat = { view: "chat" };
59
+ let paneA: HTMLElement;
60
+ let paneB: HTMLElement;
61
+ let hostEl: HTMLElement;
62
+ const panelOf = (pane: HTMLElement) =>
63
+ pane.querySelector("[data-composer-scope]")!;
64
+
65
+ // Two panes, each with a chat panel and composer. jsdom has no layout, so
66
+ // a hidden tab is a panel marked data-hidden that reports no client rects.
67
+ beforeEach(() => {
68
+ hostEl = document.createElement("div");
69
+ hostEl.innerHTML = ["a", "b"]
70
+ .map(
71
+ (id) =>
72
+ `<div data-pane-id="${id}"><div data-composer-scope tabindex="-1"><div class="chat-composer"><div id="ed-${id}" contenteditable="true"></div></div></div></div>`
73
+ )
74
+ .join("");
75
+ document.body.append(hostEl);
76
+ paneA = hostEl.querySelector('[data-pane-id="a"]')!;
77
+ paneB = hostEl.querySelector('[data-pane-id="b"]')!;
78
+ vi.spyOn(HTMLElement.prototype, "getClientRects").mockImplementation(
79
+ function (this: HTMLElement) {
80
+ return this.closest("[data-hidden]") ? ([] as never) : ([{}] as never);
81
+ } as never
82
+ );
83
+ // The page's own composer is gone: only the panes have one.
84
+ editorDom.parentElement!.remove();
85
+ });
86
+ afterEach(() => {
87
+ vi.restoreAllMocks();
88
+ hostEl.remove();
89
+ });
90
+
91
+ it("focuses the composer in the target pane, not another pane's", () => {
92
+ stubMedia({ "(pointer: coarse)": true });
93
+ sidebarRow.focus();
94
+ focusComposerInGesture(paneB, chat);
95
+ expect(document.activeElement?.id).toBe("ed-b");
96
+ sidebarRow.focus();
97
+ focusComposerInGesture(paneA, chat);
98
+ expect(document.activeElement?.id).toBe("ed-a");
99
+ });
100
+
101
+ it("skips a hidden tab's composer and falls back to the proxy", () => {
102
+ stubMedia({});
103
+ panelOf(paneB).setAttribute("data-hidden", "");
104
+ sidebarRow.focus();
105
+ focusComposerInGesture(paneB, chat);
106
+ expect(document.activeElement?.hasAttribute("data-composer-proxy")).toBe(
107
+ true
108
+ );
109
+ expect(document.activeElement?.id).not.toBe("ed-b");
110
+ });
111
+
112
+ it("never for a terminal session", () => {
113
+ stubMedia({});
114
+ sidebarRow.focus();
115
+ focusComposerInGesture(paneB, { view: "terminal" });
116
+ expect(document.activeElement).toBe(sidebarRow);
117
+ expect(document.querySelector("[data-composer-proxy]")).toBeNull();
118
+ });
119
+
120
+ it("does nothing in the phone layout", () => {
121
+ stubMedia({ "(max-width: 767px)": true });
122
+ sidebarRow.focus();
123
+ focusComposerInGesture(paneB, chat);
124
+ expect(document.activeElement).toBe(sidebarRow);
125
+ expect(document.querySelector("[data-composer-proxy]")).toBeNull();
126
+ });
127
+
128
+ it("leaves someone typing elsewhere, or working in the panel, alone", () => {
129
+ stubMedia({});
130
+ search.focus();
131
+ focusComposerInGesture(paneB, chat);
132
+ expect(document.activeElement).toBe(search);
133
+ // Focus inside a panel with no composer showing keeps the proxy away too.
134
+ const inPanel = document.createElement("button");
135
+ panelOf(paneB).append(inPanel);
136
+ panelOf(paneB).querySelector(".chat-composer")!.remove();
137
+ inPanel.focus();
138
+ focusComposerInGesture(paneB, chat);
139
+ expect(document.activeElement).toBe(inPanel);
140
+ expect(document.querySelector("[data-composer-proxy]")).toBeNull();
141
+ });
142
+
143
+ it("the hook hands the proxy's focus to the editor and drops the proxy", async () => {
144
+ stubMedia({});
145
+ panelOf(paneB).setAttribute("data-hidden", "");
146
+ sidebarRow.focus();
147
+ focusComposerInGesture(paneB, chat);
148
+ const proxy = document.querySelector("[data-composer-proxy]")!;
149
+ const dom = hostEl.querySelector<HTMLElement>("#ed-a")!;
150
+ const editor = {
151
+ view: { dom },
152
+ commands: { focus: () => dom.focus() },
153
+ isEditable: true,
154
+ on: () => {},
155
+ off: () => {},
156
+ } as never;
157
+ function Probe() {
158
+ useFocusOnShow(editor, "a", false);
159
+ return null;
160
+ }
161
+ const root = createRoot(document.createElement("div"));
162
+ await act(() => root.render(createElement(Probe)));
163
+ expect(document.activeElement).toBe(dom);
164
+ expect(proxy.isConnected).toBe(false);
165
+ await act(() => root.unmount());
166
+ });
167
+ });
168
+
38
169
  describe("focusVerdict", () => {
39
170
  it("takes the caret from nothing, a sidebar row, or itself", () => {
40
171
  (document.activeElement as HTMLElement | null)?.blur();
@@ -45,11 +176,17 @@ describe("focusVerdict", () => {
45
176
  expect(focusVerdict(editorDom, false)).toBe("take");
46
177
  });
47
178
 
48
- it("never on a touch screen, where it raises the keyboard", () => {
179
+ it("never in the phone layout, where it raises the keyboard", () => {
49
180
  sidebarRow.focus();
50
181
  expect(focusVerdict(editorDom, true)).toBe("leave");
51
182
  });
52
183
 
184
+ it("does in the desktop layout on a coarse pointer (iPad)", () => {
185
+ stubMedia({ "(pointer: coarse)": true, "(max-width: 767px)": false });
186
+ sidebarRow.focus();
187
+ expect(focusVerdict(editorDom, isPhoneLayout())).toBe("take");
188
+ });
189
+
53
190
  it("leaves someone typing in another field (search, rename) alone", () => {
54
191
  search.focus();
55
192
  expect(focusVerdict(editorDom, false)).toBe("leave");
@@ -1,12 +1,19 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import type { Editor } from "@tiptap/react";
3
+ import { isPhoneLayout } from "./layout";
3
4
 
4
5
  // Whether a composer just shown (a session or draft opened) may take the
5
- // caret, so its conversation can be typed into straight away. Not on a touch
6
- // screen, where it would raise the keyboard over the conversation, and never
7
- // from someone busy elsewhere: typing in another field, selecting text, or
8
- // already working inside this panel. With a dialog or menu open it waits:
9
- // one that opened the session (⌘K) is about to close and hand focus back.
6
+ // caret, so its conversation can be typed into straight away. Not in the phone
7
+ // layout (MobileView), where it would raise the keyboard over the
8
+ // conversation, and never from someone busy elsewhere: typing in another
9
+ // field, selecting text, or already working inside this panel. With a dialog
10
+ // or menu open it waits: one that opened the session (⌘K) is about to close
11
+ // and hand focus back.
12
+ //
13
+ // The rule follows the layout, not `pointer: coarse`: an iPad shows the
14
+ // desktop layout with a coarse pointer (often with a keyboard attached), and
15
+ // is meant to type straight away. A raised on-screen keyboard there is
16
+ // accepted. Width is what picks the layout, so touch laptops are covered too.
10
17
 
11
18
  // Something open on top that keeps the focus it has.
12
19
  const OPEN_ON_TOP = [
@@ -23,29 +30,86 @@ const EDITABLE =
23
30
 
24
31
  export type FocusVerdict = "take" | "wait" | "leave";
25
32
 
33
+ // A hidden field focused inside a tap, to be handed to the editor once it
34
+ // mounts (see focusComposerInGesture).
35
+ const PROXY = "[data-composer-proxy]";
36
+
26
37
  export function focusVerdict(
27
38
  editor: HTMLElement,
28
- coarse: boolean
39
+ phone: boolean
40
+ ): FocusVerdict {
41
+ if (phone) return "leave";
42
+ return verdictFor(editor.ownerDocument, editor);
43
+ }
44
+
45
+ function verdictFor(
46
+ doc: Document,
47
+ editor: HTMLElement | null,
48
+ panel: Element | null = editor?.closest("[data-composer-scope]") ?? null
29
49
  ): FocusVerdict {
30
- if (coarse) return "leave";
31
- const doc = editor.ownerDocument;
32
50
  if (doc.querySelector(OPEN_ON_TOP)) return "wait";
33
51
  const selection = doc.getSelection();
34
52
  if (
35
53
  selection &&
36
54
  !selection.isCollapsed &&
37
- !editor.contains(selection.anchorNode)
55
+ !editor?.contains(selection.anchorNode)
38
56
  )
39
57
  return "leave";
40
58
  const active = doc.activeElement;
41
- if (!active || active === doc.body || editor.contains(active)) return "take";
59
+ if (!active || active === doc.body || editor?.contains(active)) return "take";
42
60
  // Still in a dialog on its way out (⌘K's search box as it closes).
43
61
  if (active.closest(DIALOG)) return "wait";
62
+ if (active.closest(PROXY)) return "take";
44
63
  if (active.closest(EDITABLE)) return "leave";
45
64
  // The panel it belongs to (a chat, a draft): focus already inside it stays
46
65
  // where the user put it.
47
- const scope = editor.closest("[data-composer-scope]");
48
- return scope && scope !== active && scope.contains(active) ? "leave" : "take";
66
+ return panel && panel !== active && panel.contains(active) ? "leave" : "take";
67
+ }
68
+
69
+ const PANEL = "[data-composer-scope]";
70
+ const COMPOSER = '.chat-composer [contenteditable="true"]';
71
+ const PROXY_MS = 3000;
72
+
73
+ // Hidden tabs are display:none, and every tab of every pane stays mounted.
74
+ const visible = (el: Element) => el.getClientRects().length > 0;
75
+
76
+ // iOS WebKit only honours focus() inside the tap or key press that asked for
77
+ // it, and a session's composer is focused later (after it reconnects). So the
78
+ // handler that selects a chat session from a real gesture (a sidebar tap, ⌘K,
79
+ // the keyboard switch) calls this, synchronously: it focuses the composer
80
+ // showing in `pane` (the one the session opens into) if there is one, else a
81
+ // hidden field that useFocusOnShow hands over to the editor when it mounts
82
+ // (focus moving on from a focused element is allowed). Never for a terminal
83
+ // session, whose keystrokes the hidden field would take. Other browsers don't
84
+ // need it; the late focus works there.
85
+ export function focusComposerInGesture(
86
+ pane: Element | null,
87
+ session: { view?: string },
88
+ doc: Document = document
89
+ ) {
90
+ if (session.view !== "chat" || isPhoneLayout()) return;
91
+ const panel =
92
+ [...(pane?.querySelectorAll(PANEL) ?? [])].find(visible) ?? null;
93
+ const editor =
94
+ [...(panel?.querySelectorAll<HTMLElement>(COMPOSER) ?? [])].find(visible) ??
95
+ null;
96
+ if (verdictFor(doc, editor, panel) !== "take") return;
97
+ if (editor) {
98
+ editor.focus({ preventScroll: true });
99
+ return;
100
+ }
101
+ if (doc.querySelector(PROXY)) return;
102
+ const proxy = doc.createElement("textarea");
103
+ proxy.setAttribute("data-composer-proxy", "");
104
+ proxy.setAttribute("aria-hidden", "true");
105
+ proxy.tabIndex = -1;
106
+ // 16px: iOS zooms into smaller fields. Offscreen, not display:none, which
107
+ // can't take focus.
108
+ proxy.style.cssText =
109
+ "position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;font-size:16px";
110
+ doc.body.append(proxy);
111
+ proxy.focus({ preventScroll: true });
112
+ setTimeout(() => proxy.remove(), PROXY_MS);
49
113
  }
50
114
 
51
115
  // How long a session opened from a dialog waits for it to close.
@@ -58,7 +122,7 @@ export const DIALOG_WAIT_MS = 1500;
58
122
  export function useFocusOnShow(
59
123
  editor: Editor | null,
60
124
  key: string | null | undefined,
61
- coarse: boolean,
125
+ phone: boolean,
62
126
  disabled = false
63
127
  ) {
64
128
  const shown = useRef<string | null>(null);
@@ -95,6 +159,7 @@ export function useFocusOnShow(
95
159
  const take = (at: "end" | null) => {
96
160
  pending.current = null;
97
161
  editor.commands.focus(at, { scrollIntoView: false });
162
+ doc.querySelectorAll(PROXY).forEach((el) => el.remove());
98
163
  holding.current = true;
99
164
  };
100
165
  if (pending.current === null) {
@@ -105,7 +170,7 @@ export function useFocusOnShow(
105
170
  let timer: ReturnType<typeof setTimeout> | undefined;
106
171
  // `settled`: no dialog was seen closing just now.
107
172
  const decide = (settled: boolean) => {
108
- const verdict = focusVerdict(editor.view.dom, coarse);
173
+ const verdict = focusVerdict(editor.view.dom, phone);
109
174
  if (verdict === "take" && settled) return take("end");
110
175
  // A dialog just closed and hands focus back on a timer of its own:
111
176
  // decide after that.
@@ -116,5 +181,5 @@ export function useFocusOnShow(
116
181
  };
117
182
  decide(true);
118
183
  return () => clearTimeout(timer);
119
- }, [editor, key, coarse, disabled]);
184
+ }, [editor, key, phone, disabled]);
120
185
  }
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+
5
+ // The phone layout (MobileView) is the one under 768px, as in useViewport.
6
+ const QUERY = "(max-width: 767px)";
7
+
8
+ export function isPhoneLayout(): boolean {
9
+ return window.matchMedia(QUERY).matches;
10
+ }
11
+
12
+ function subscribe(listener: () => void) {
13
+ const mq = window.matchMedia(QUERY);
14
+ mq.addEventListener("change", listener);
15
+ return () => mq.removeEventListener("change", listener);
16
+ }
17
+
18
+ export function usePhoneLayout(): boolean {
19
+ return useSyncExternalStore(subscribe, isPhoneLayout, () => false);
20
+ }
@@ -415,6 +415,7 @@ export const Pane = memo(function Pane({
415
415
  "bg-card rounded-xl shadow-(--elevation) ring-1 transition-shadow duration-200",
416
416
  !isMobile && (isFocused ? "ring-primary/40" : "ring-foreground/[0.07]")
417
417
  )}
418
+ data-pane-id={paneId}
418
419
  onClick={handleFocus}
419
420
  >
420
421
  {/* Tab Bar - Mobile vs Desktop */}
@@ -18,6 +18,7 @@ export function DesktopView({
18
18
  setShowQuickSwitcher,
19
19
  notificationSettings,
20
20
  attachToSession,
21
+ selectSession,
21
22
  openSessionInNewTab,
22
23
  handleNewSessionInProject,
23
24
  handleOpenTerminal,
@@ -38,7 +39,7 @@ export function DesktopView({
38
39
  sessionStatuses={sessionStatuses}
39
40
  onSelect={(id) => {
40
41
  const session = sessions.find((s) => s.id === id);
41
- if (session) attachToSession(session);
42
+ if (session) selectSession(session);
42
43
  }}
43
44
  onOpenInTab={(id) => {
44
45
  const session = sessions.find((s) => s.id === id);
@@ -82,7 +83,7 @@ export function DesktopView({
82
83
  activeSessionWorkingDir={activeSession?.working_directory ?? undefined}
83
84
  onSelectSession={(sessionId) => {
84
85
  const session = sessions.find((s) => s.id === sessionId);
85
- if (session) attachToSession(session);
86
+ if (session) selectSession(session);
86
87
  }}
87
88
  onSelectFile={(file, line) => {
88
89
  // Convert relative path to absolute by prepending working directory
@@ -43,6 +43,7 @@ export interface ViewProps {
43
43
 
44
44
  // Handlers
45
45
  attachToSession: (session: Session) => void;
46
+ selectSession: (session: Session) => void;
46
47
  openSessionInNewTab: (session: Session) => void;
47
48
  handleNewSessionInProject: (projectId: string) => void;
48
49
  handleOpenTerminal: (projectId: string) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.544",
3
+ "version": "0.3.546",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"