@saadnvd1/agent-os 0.3.544 → 0.3.545
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 +17 -1
- package/components/Chat/composer/ComposerBody.tsx +3 -1
- package/components/Chat/composer/autofocus.test.ts +139 -2
- package/components/Chat/composer/autofocus.ts +80 -15
- package/components/Chat/composer/layout.ts +20 -0
- package/components/Pane/index.tsx +1 -0
- package/components/views/DesktopView.tsx +3 -2
- package/components/views/types.ts +1 -0
- package/package.json +1 -1
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:
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
|
6
|
-
//
|
|
7
|
-
// from someone busy elsewhere: typing in another
|
|
8
|
-
// already working inside this panel. With a dialog
|
|
9
|
-
// one that opened the session (⌘K) is about to close
|
|
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
|
-
|
|
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
|
|
55
|
+
!editor?.contains(selection.anchorNode)
|
|
38
56
|
)
|
|
39
57
|
return "leave";
|
|
40
58
|
const active = doc.activeElement;
|
|
41
|
-
if (!active || active === doc.body || editor
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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)
|
|
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)
|
|
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;
|