Skip to content

Commit b784c91

Browse files
committed
fix(web): auto-rotate draft launcher on narrow panes
1 parent b5b3e13 commit b784c91

2 files changed

Lines changed: 198 additions & 12 deletions

File tree

packages/web/src/features/agent-panes/views/shared/draft-launcher.test.tsx

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { createEvent, fireEvent, render, screen } from "@testing-library/react";
1+
import { act, createEvent, fireEvent, render, screen } from "@testing-library/react";
22
import { createStore, Provider } from "jotai";
3-
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { localeAtom } from "../../../../atoms/app-ui";
55
import { wsClientAtom } from "../../../../atoms/connection";
66
import { WORKSPACE_PATH_DRAG_MIME } from "../../../../lib/workspace-path-drag";
77
import { DraftLauncher } from "./draft-launcher";
88

99
const mockUseProviderLauncher = vi.fn();
10+
const originalResizeObserver = global.ResizeObserver;
1011

1112
vi.mock("../../actions/use-provider-launcher", () => ({
1213
useProviderLauncher: (...args: unknown[]) => mockUseProviderLauncher(...args),
@@ -31,7 +32,57 @@ function createRuntimeState(providerId: "claude" | "codex") {
3132
};
3233
}
3334

35+
function createDraftLauncherStore() {
36+
const store = createStore();
37+
38+
store.set(localeAtom, "en");
39+
store.set(wsClientAtom, {
40+
sendCommand: vi.fn(),
41+
subscribe: vi.fn(() => () => {}),
42+
} as never);
43+
44+
return store;
45+
}
46+
47+
function installResizeObserverMock() {
48+
let callback: ResizeObserverCallback | null = null;
49+
50+
class ResizeObserverMock {
51+
constructor(observerCallback: ResizeObserverCallback) {
52+
callback = observerCallback;
53+
}
54+
55+
observe() {}
56+
disconnect() {}
57+
}
58+
59+
global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
60+
61+
return {
62+
resize(target: Element, width: number) {
63+
if (!callback) {
64+
throw new Error("ResizeObserver was not created");
65+
}
66+
67+
callback(
68+
[
69+
{
70+
target,
71+
contentRect: { width },
72+
} as ResizeObserverEntry,
73+
],
74+
{} as ResizeObserver
75+
);
76+
},
77+
};
78+
}
79+
3480
describe("DraftLauncher", () => {
81+
afterEach(() => {
82+
global.ResizeObserver = originalResizeObserver;
83+
vi.useRealTimers();
84+
});
85+
3586
beforeEach(() => {
3687
vi.clearAllMocks();
3788
mockUseProviderLauncher.mockReturnValue({
@@ -126,13 +177,7 @@ describe("DraftLauncher", () => {
126177
});
127178

128179
it("switches draft launcher carousel panels", () => {
129-
const store = createStore();
130-
131-
store.set(localeAtom, "en");
132-
store.set(wsClientAtom, {
133-
sendCommand: vi.fn(),
134-
subscribe: vi.fn(() => () => {}),
135-
} as never);
180+
const store = createDraftLauncherStore();
136181

137182
const { container } = render(
138183
<Provider store={store}>
@@ -155,6 +200,69 @@ describe("DraftLauncher", () => {
155200
expect(carouselTrack).toHaveClass("agent-draft-component-row--file");
156201
});
157202

203+
it("auto-rotates draft launcher carousel panels in compact layout", async () => {
204+
vi.useFakeTimers();
205+
const resizeObserver = installResizeObserverMock();
206+
const store = createDraftLauncherStore();
207+
208+
const { container } = render(
209+
<Provider store={store}>
210+
<DraftLauncher workspaceId="ws-123" />
211+
</Provider>
212+
);
213+
214+
const launcher = container.querySelector(".agent-draft-launcher");
215+
const agentButton = screen.getByRole("button", { name: "Agent" });
216+
const fileButton = screen.getByRole("button", { name: "File Editor" });
217+
const carouselTrack = container.querySelector(".agent-draft-component-row");
218+
219+
expect(launcher).not.toBeNull();
220+
expect(carouselTrack).not.toHaveClass("agent-draft-component-row--file");
221+
222+
act(() => {
223+
resizeObserver.resize(launcher as Element, 360);
224+
});
225+
226+
await act(async () => {
227+
await vi.advanceTimersByTimeAsync(4000);
228+
});
229+
230+
expect(agentButton).toHaveAttribute("aria-pressed", "false");
231+
expect(fileButton).toHaveAttribute("aria-pressed", "true");
232+
expect(carouselTrack).toHaveClass("agent-draft-component-row--file");
233+
});
234+
235+
it("does not auto-rotate draft launcher carousel panels in wide layout", async () => {
236+
vi.useFakeTimers();
237+
const resizeObserver = installResizeObserverMock();
238+
const store = createDraftLauncherStore();
239+
240+
const { container } = render(
241+
<Provider store={store}>
242+
<DraftLauncher workspaceId="ws-123" />
243+
</Provider>
244+
);
245+
246+
const launcher = container.querySelector(".agent-draft-launcher");
247+
const agentButton = screen.getByRole("button", { name: "Agent" });
248+
const fileButton = screen.getByRole("button", { name: "File Editor" });
249+
const carouselTrack = container.querySelector(".agent-draft-component-row");
250+
251+
expect(launcher).not.toBeNull();
252+
253+
act(() => {
254+
resizeObserver.resize(launcher as Element, 640);
255+
});
256+
257+
await act(async () => {
258+
await vi.advanceTimersByTimeAsync(4000);
259+
});
260+
261+
expect(agentButton).toHaveAttribute("aria-pressed", "true");
262+
expect(fileButton).toHaveAttribute("aria-pressed", "false");
263+
expect(carouselTrack).not.toHaveClass("agent-draft-component-row--file");
264+
});
265+
158266
it("renders a draft drop label when pane drag hover is active", () => {
159267
const store = createStore();
160268

packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Session } from "@coder-studio/core";
22
import { useAtomValue, useSetAtom } from "jotai";
33
import { ArrowRight, FlipHorizontal, FlipVertical, X } from "lucide-react";
4-
import { type DragEvent, type FC, type PointerEvent, useRef, useState } from "react";
4+
import { type DragEvent, type FC, type PointerEvent, useEffect, useRef, useState } from "react";
55
import { dispatchCommandAtom } from "../../../../atoms/connection";
66
import { sessionsAtom } from "../../../../atoms/sessions";
77
import { Button, IconButton, StatusDot, Tag, ThemedIcon, Tooltip } from "../../../../components/ui";
@@ -14,6 +14,24 @@ import { buildDiagnosticsPath } from "../../../diagnostics";
1414
import type { PaneDropIntent } from "../../actions/pane-drag-types";
1515
import { type ProviderId, useProviderLauncher } from "../../actions/use-provider-launcher";
1616

17+
const COMPACT_CAROUSEL_MAX_WIDTH_REM = 28;
18+
const COMPACT_CAROUSEL_INTERVAL_MS = 4000;
19+
20+
function getCompactCarouselMaxWidthPx(): number {
21+
if (typeof window === "undefined" || typeof document === "undefined") {
22+
return COMPACT_CAROUSEL_MAX_WIDTH_REM * 16;
23+
}
24+
25+
const rootFontSize = window.getComputedStyle(document.documentElement).fontSize;
26+
const parsedRootFontSize = Number.parseFloat(rootFontSize);
27+
28+
if (!Number.isFinite(parsedRootFontSize) || parsedRootFontSize <= 0) {
29+
return COMPACT_CAROUSEL_MAX_WIDTH_REM * 16;
30+
}
31+
32+
return COMPACT_CAROUSEL_MAX_WIDTH_REM * parsedRootFontSize;
33+
}
34+
1735
interface DraftLauncherDragState {
1836
isDragging: boolean;
1937
isActiveDropTarget: boolean;
@@ -47,7 +65,9 @@ export const DraftLauncher: FC<DraftLauncherProps> = ({
4765
const dispatch = useAtomValue(dispatchCommandAtom);
4866
const setSessions = useSetAtom(sessionsAtom);
4967
const [activePanel, setActivePanel] = useState<"agent" | "file">("agent");
68+
const [isCompactCarousel, setIsCompactCarousel] = useState(false);
5069
const [isFileDropTarget, setIsFileDropTarget] = useState(false);
70+
const draftLauncherRef = useRef<HTMLDivElement | null>(null);
5171
const swipeStartXRef = useRef<number | null>(null);
5272
const { states, launch } = useProviderLauncher(
5373
dispatch,
@@ -219,6 +239,62 @@ export const DraftLauncher: FC<DraftLauncherProps> = ({
219239
swipeStartXRef.current = null;
220240
};
221241

242+
useEffect(() => {
243+
const element = draftLauncherRef.current;
244+
245+
if (!element) {
246+
return;
247+
}
248+
249+
const updateCompactState = (width: number) => {
250+
setIsCompactCarousel(width > 0 && width <= getCompactCarouselMaxWidthPx());
251+
};
252+
253+
updateCompactState(element.getBoundingClientRect().width);
254+
255+
if (typeof ResizeObserver === "function") {
256+
const observer = new ResizeObserver((entries) => {
257+
const entry = entries[0];
258+
259+
if (!entry) {
260+
return;
261+
}
262+
263+
updateCompactState(entry.contentRect.width || entry.target.getBoundingClientRect().width);
264+
});
265+
266+
observer.observe(element);
267+
268+
return () => {
269+
observer.disconnect();
270+
};
271+
}
272+
273+
const handleWindowResize = () => {
274+
updateCompactState(element.getBoundingClientRect().width);
275+
};
276+
277+
window.addEventListener("resize", handleWindowResize);
278+
279+
return () => {
280+
window.removeEventListener("resize", handleWindowResize);
281+
};
282+
}, []);
283+
284+
useEffect(() => {
285+
if (!isCompactCarousel) {
286+
return;
287+
}
288+
289+
const timer = window.setTimeout(() => {
290+
setActivePanel((currentPanel) => (currentPanel === "agent" ? "file" : "agent"));
291+
}, COMPACT_CAROUSEL_INTERVAL_MS);
292+
293+
return () => {
294+
window.clearTimeout(timer);
295+
};
296+
}, [activePanel, isCompactCarousel]);
297+
222298
return (
223299
<div
224300
className={`session-card agent-pane${dragState?.isDragging ? " draft-launcher--dragging" : ""}${dragState?.isActiveDropTarget || isFileDropTarget ? " draft-launcher--drop-target" : ""}`}
@@ -281,7 +357,7 @@ export const DraftLauncher: FC<DraftLauncherProps> = ({
281357
</div>
282358
</div>
283359

284-
<div className="agent-draft-launcher">
360+
<div className="agent-draft-launcher" ref={draftLauncherRef}>
285361
<div className="agent-draft-content">
286362
<div className="agent-draft-component">
287363
<div
@@ -435,7 +511,9 @@ export const DraftLauncher: FC<DraftLauncherProps> = ({
435511
))}
436512
</div>
437513

438-
<div className="agent-draft-footer">点击启动 Agent 或直接拖拽文件到右侧区域打开</div>
514+
<div className="agent-draft-footer">
515+
点击「启动 Agent」,或将文件拖到右侧区域直接打开。
516+
</div>
439517
</div>
440518
</div>
441519
</div>

0 commit comments

Comments
 (0)