From 2b6d5acbd4fa68750ab8ad4144cc9b545798c4d2 Mon Sep 17 00:00:00 2001 From: Yoonwoo-Ha <274636136+Yoonwoo-Ha@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:49:35 +0900 Subject: [PATCH 1/4] feat(split): two tabs side by side on top of herdr's own layout Ports the browser split from #602 onto PaneCanvas (#726, #747): each half draws its tab at herdr's layout, so a tab herdr already split shows all of its panes inside the half. One tab is never drawn in both halves. - A tab dragged down out of its strip, or a sidebar row dragged over the pane area, opens in the half it is dropped on. The strip keeps its pointer reorder; leaving the strip hands the drag to the split guide. - Only the active half's selected pane is active: the other half never takes the keyboard or reports the header's connection. - A pane closed in the other half hands over to another pane of its tab. - Drag guide from flam-ing's follow-up (Yoonwoo-Ha/herdr-web-ui#1): the guide shows as soon as a drag starts, one indicator slides between halves, a 12px band keeps the side steady at the centre, and Escape, blur or a drop elsewhere cancels. Co-authored-by: flam-ing <300052295+flam-ing@users.noreply.github.com> --- scripts/split-view-regression.ts | 290 +++++++++++++++++++++ scripts/ui-regression.ts | 34 +++ src/App.tsx | 384 ++++++++++++++++++++++++---- src/components/Sidebar.tsx | 7 +- src/components/SplitDropPreview.css | 71 +++++ src/components/SplitDropPreview.tsx | 15 ++ src/components/TabStrip.css | 3 +- src/components/TabStrip.tsx | 18 +- src/lib/i18n.ja.ts | 6 + src/lib/i18n.ko.ts | 6 + src/lib/i18n.tr.ts | 6 + src/lib/i18n.zh.ts | 6 + src/lib/paneDrag.ts | 52 ++++ src/lib/split.test.ts | 115 +++++++++ src/lib/split.ts | 135 ++++++++++ src/lib/useTabReorder.ts | 31 ++- src/styles.css | 76 ++++++ 17 files changed, 1189 insertions(+), 66 deletions(-) create mode 100644 scripts/split-view-regression.ts create mode 100644 src/components/SplitDropPreview.css create mode 100644 src/components/SplitDropPreview.tsx create mode 100644 src/lib/paneDrag.ts create mode 100644 src/lib/split.test.ts create mode 100644 src/lib/split.ts diff --git a/scripts/split-view-regression.ts b/scripts/split-view-regression.ts new file mode 100644 index 0000000000..da67719fe8 --- /dev/null +++ b/scripts/split-view-regression.ts @@ -0,0 +1,290 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Browser, BrowserContext, Page, Route, WebSocket } from "playwright-core"; +import { herdrRpc, workspaceClose, workspaceCreate } from "../server/herdr/client.ts"; + +/** + * The split view's edges (lib/split.ts), each on a browser context of its own: the other half's + * connection is a second socket, which the main browser run would take for the page's own. + * Every wait is on a condition: a check that something did not happen waits first for a later + * event that the same path would have shown after it. + */ +export async function checkSplitView(browser: Browser, origin: string): Promise { + const root = mkdtempSync(join(tmpdir(), "herdr-web-ui-split-")); + const workspaces: string[] = []; + const contexts: BrowserContext[] = []; + const panesFor = async (...suffixes: string[]): Promise => { + const ids: string[] = []; + for (const suffix of suffixes) { + const cwd = join(root, suffix); + mkdirSync(cwd); + const created = await workspaceCreate({ cwd, label: `herdr-web-ui-test-split-${suffix}` }); + workspaces.push(created.workspace.workspace_id); + ids.push(created.root_pane.pane_id); + await herdrRpc("pane.report_agent", { pane_id: created.root_pane.pane_id, source: "manual", agent: "claude", state: "idle" }); + } + return ids; + }; + const open = async (panes: string[], view: "chat" | "terminal", before?: (context: BrowserContext) => Promise): Promise<{ page: Page; errors: string[] }> => { + const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: "en-US" }); + contexts.push(context); + await context.addInitScript(([ids, lens]) => { + if (localStorage.getItem("herdr-web-ui:settings") === null) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", alertDone: "off" })); + for (const id of ids as string[]) localStorage.setItem(`herdr-web-ui:view:${id}`, lens as string); + }, [panes, view] as const); + await before?.(context); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(`${origin}/?pane=${encodeURIComponent(panes[0]!)}`); + await page.locator(".conn-live").waitFor(); + return { page, errors }; + }; + const half = (page: Page, side: string) => page.locator(`.pane-slot[data-side="${side}"]`); + const isActive = (page: Page, side: string) => page.waitForFunction((which) => document.querySelector(`.pane-slot[data-side="${which}"]`)?.classList.contains("is-active") === true, side); + /** the pane dropped on the right half, which is then the active one */ + const splitRight = async (page: Page, pane: string): Promise => { + const area = page.locator(".pane-split"); + const box = (await area.boundingBox())!; + await page.locator(`.pane-select[title^="${pane} —"]`).dragTo(area, { targetPosition: { x: box.width * 0.8, y: box.height / 2 } }); + await page.waitForFunction(() => document.querySelectorAll(".pane-slot").length === 2); + await isActive(page, "right"); + }; + /** two frames: what a render commits, and the effects after it, have run */ + const settled = (page: Page) => page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + /** polls a condition held outside the page, with a bound */ + const until = async (page: Page, check: () => boolean, label: string): Promise => { + const deadline = Date.now() + 10_000; + while (!check()) { + if (Date.now() > deadline) throw new Error(`Timed out: ${label}`); + await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 25))); + } + }; + + try { + // 1. An alert is skipped for the pane in the other half only while that half is drawn. + { + const [openPane, otherPane, probe] = await panesFor("open", "other", "probe") as [string, string, string]; + const { page, errors } = await open([openPane, otherPane, probe], "chat"); + const report = (pane: string, state: string) => herdrRpc("pane.report_agent", { pane_id: pane, source: "manual", agent: "claude", state }); + const row = (pane: string, status: string) => page.locator(`.pane-item:has(.pane-select[title^="${pane} —"]) [data-status="${status}"]`).first(); + const block = async (pane: string) => { + await report(pane, "working"); + await row(pane, "working").waitFor({ state: "attached" }); + await report(pane, "blocked"); + await row(pane, "blocked").waitFor({ state: "attached" }); + }; + const unblock = async (pane: string) => { + await report(pane, "idle"); + await row(pane, "blocked").waitFor({ state: "detached" }); + }; + // a later status the app shows only after it has handled the block: no card by then is none at all + const handled = async () => { + await report(probe, "working"); + await row(probe, "working").waitFor({ state: "attached" }); + await settled(page); + await report(probe, "idle"); + }; + const card = page.locator(".droplet-card"); + + await splitRight(page, otherPane); + await half(page, "left").locator(".terminal-host").click({ position: { x: 40, y: 40 } }); + await isActive(page, "left"); + await block(otherPane); + await handled(); + assert.equal(await card.count(), 0, "no in-app alert for the pane drawn in the other half"); + await unblock(otherPane); + + // a window too narrow for the split shows the active half alone: the other pane alerts again + await page.setViewportSize({ width: 700, height: 800 }); + await page.waitForFunction(() => document.querySelectorAll(".pane-slot").length === 1); + await block(otherPane); + await card.waitFor({ state: "visible" }); + assert.match((await card.getAttribute("aria-label")) ?? "", /Needs input/); + await unblock(otherPane); + console.log("PASS the other half's pane is spared alerts only while that half is drawn"); + + // 2. A Tab onto a control of the inactive half makes the half active and keeps the focus there. + await page.setViewportSize({ width: 1280, height: 800 }); + await page.waitForFunction(() => document.querySelector('.pane-slot[data-side="right"]')?.classList.contains("is-inactive") === true); + // a focus right after a key, as a Tab gives one: the key and the focus in one task, so the + // focus is the user's own however slow the run (KEY_FOCUS_MS) + const attach = half(page, "right").getByRole("button", { name: "Attach files", exact: true }); + await attach.evaluate((button) => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + (button as HTMLElement).focus(); + }); + await isActive(page, "right"); + await settled(page); + assert.equal(await attach.evaluate((button) => button === document.activeElement), true, "the focus stays on the control it went to"); + console.log("PASS a Tab into the inactive half makes it active and keeps the focus where it went"); + assert.deepEqual(errors, []); + } + + // 3. An upload that finishes in the half the user left does not take the keyboard back. + { + const [left, right] = await panesFor("upload-left", "upload-right") as [string, string]; + let held: Route | null = null; + const { page, errors } = await open([left, right], "terminal", (context) => context.route("**/api/pane/image**", (route) => { held = route; })); + await splitRight(page, right); + await half(page, "left").locator(".terminal-host").click({ position: { x: 40, y: 40 } }); + await isActive(page, "left"); + // a file dropped on the left terminal; its upload is held in flight + await half(page, "left").locator(".pane-terminal").evaluate((host) => { + const data = new DataTransfer(); + data.items.add(new File(["split view"], "split-note.txt", { type: "text/plain" })); + host.dispatchEvent(new DragEvent("drop", { dataTransfer: data, bubbles: true, cancelable: true })); + }); + await until(page, () => held !== null, "the upload starts"); + // the user moves to the right half, then the upload finishes + await half(page, "right").locator(".terminal-host").click({ position: { x: 40, y: 40 } }); + await isActive(page, "right"); + const uploaded = page.waitForResponse((response) => response.url().includes("/api/pane/image")); + await (held as unknown as Route).continue(); + await uploaded; + await settled(page); + assert.equal(await half(page, "right").evaluate((slot) => slot.classList.contains("is-active")), true, "the right half stays active"); + assert.equal(await page.evaluate(() => document.activeElement?.closest('.pane-slot[data-side="right"]') !== null), true, "the keyboard stays in the right half"); + console.log("PASS an upload finishing in the half the user left does not take the keyboard back"); + assert.deepEqual(errors, []); + } + + // 4. Closing the other half leaves the active pane on its own connection. + { + const [left, right] = await panesFor("keep-left", "keep-right") as [string, string]; + const sockets: WebSocket[] = []; + const { page, errors } = await open([left, right], "chat", async (context) => { + context.on("page", (opened) => opened.on("websocket", (socket) => sockets.push(socket))); + }); + await splitRight(page, right); + await half(page, "right").getByRole("log", { name: `conversation of ${right}`, exact: true }).waitFor(); + assert.equal(sockets.length, 2, "one socket per half"); + const [first, second] = sockets as [WebSocket, WebSocket]; + // the right half is active: closing the left one must not move its pane to the left's socket + await half(page, "left").getByRole("button", { name: "Close this half", exact: true }).click(); + await page.waitForFunction(() => document.querySelectorAll(".pane-slot").length === 1); + await until(page, () => first.isClosed() || second.isClosed(), "a half's socket closes"); + assert.equal(second.isClosed(), false, "the active half keeps its connection"); + assert.equal(first.isClosed(), true, "the closed half's connection goes"); + await page.getByRole("log", { name: `conversation of ${right}`, exact: true }).waitFor(); + console.log("PASS closing the other half leaves the active pane on its own connection"); + assert.deepEqual(errors, []); + } + + // 5. The guide starts with the pane drag, keeps a stable side around the centre, and leaves + // the pane alone when cancelled. Synthetic events let the check visit exact pixel edges. + { + const [opened, dragged] = await panesFor("preview-open", "preview-dragged") as [string, string]; + const { page, errors } = await open([opened, dragged], "chat"); + const area = page.locator(".pane-split"); + const guide = page.locator(".split-drop"); + const source = page.locator(`.pane-select[title^="${dragged} —"]`); + await source.waitFor(); + await guide.waitFor({ state: "attached" }); + const box = (await area.boundingBox())!; + const middle = box.x + box.width / 2; + const y = box.y + box.height / 2; + const data = await page.evaluateHandle(() => new DataTransfer()); + const preview = async (visible: boolean, side: "left" | "right" | null): Promise => { + await settled(page); + await page.waitForFunction(([shown, target]) => { + const element = document.querySelector(".split-drop"); + return element?.classList.contains("is-visible") === shown && element.getAttribute("data-side") === target; + }, [visible, side] as const); + }; + const start = async (): Promise => { + // The sidebar's own handler fills the transfer before App's window listener reads it. + await source.dispatchEvent("dragstart", { dataTransfer: data }); + await preview(true, null); + }; + const hover = async (x: number, side: "left" | "right"): Promise => { + await area.dispatchEvent("dragover", { dataTransfer: data, clientX: x, clientY: y }); + await preview(true, side); + }; + const unchanged = async (): Promise => { + await preview(false, null); + assert.equal(await page.locator(".pane-slot").count(), 1, "cancelling does not create a half"); + await page.getByRole("log", { name: `conversation of ${opened}`, exact: true }).waitFor(); + assert.equal(await page.evaluate(() => localStorage.getItem("herdr-web-ui:split")), null, "cancelling does not save a split"); + }; + + await preview(false, null); + assert.equal(await guide.locator(".split-drop-half").count(), 2, "both drop guides stay mounted"); + assert.equal(await guide.locator(".split-drop-target").count(), 1, "one indicator moves between the guides"); + const indicator = (await guide.locator(".split-drop-target").elementHandle())!; + await start(); + await hover(middle + 40, "right"); + await area.locator(".pane-slot").first().dispatchEvent("dragleave", { + dataTransfer: data, relatedTarget: null, clientX: middle + 40, clientY: y, + }); + await preview(true, "right"); + await area.dispatchEvent("dragleave", { dataTransfer: data, relatedTarget: null, clientX: box.x - 1, clientY: y }); + await preview(true, null); + await hover(middle - 40, "left"); + assert.equal(await indicator.evaluate((element) => element.isConnected), true, "the indicator is not replaced when the side changes"); + await source.dispatchEvent("dragend", { dataTransfer: data }); + await unchanged(); + + for (const cancel of ["outside-drop", "blur", "Escape"] as const) { + await start(); + await hover(middle + 40, "right"); + if (cancel === "outside-drop") await page.locator("body").dispatchEvent("drop", { dataTransfer: data }); + else if (cancel === "blur") await page.evaluate(() => window.dispatchEvent(new Event("blur"))); + else await page.keyboard.press("Escape"); + await unchanged(); + } + + // Text and files still belong to their existing input handlers, not the split guide. + for (const kind of ["text", "file"] as const) { + const otherData = await page.evaluateHandle((type) => { + const transfer = new DataTransfer(); + if (type === "text") transfer.setData("text/plain", "selected text"); + else transfer.items.add(new File(["preview"], "preview.txt", { type: "text/plain" })); + return transfer; + }, kind); + await page.locator("body").dispatchEvent("dragstart", { dataTransfer: otherData }); + await area.dispatchEvent("dragover", { dataTransfer: otherData, clientX: middle + 40, clientY: y }); + await unchanged(); + await page.locator("body").dispatchEvent("dragend", { dataTransfer: otherData }); + await otherData.dispose(); + } + + await page.setViewportSize({ width: 768, height: 800 }); + await source.dispatchEvent("dragstart", { dataTransfer: data }); + await area.dispatchEvent("dragover", { dataTransfer: data, clientX: 500, clientY: y }); + await unchanged(); + await source.dispatchEvent("dragend", { dataTransfer: data }); + await page.setViewportSize({ width: 1280, height: 800 }); + await page.emulateMedia({ reducedMotion: "reduce" }); + await start(); + await hover(middle - 40, "left"); + const durations = await guide.evaluate((element) => [element, element.querySelector(".split-drop-target")!] + .flatMap((node) => getComputedStyle(node).transitionDuration.split(",").map((value) => Number.parseFloat(value)))); + assert.equal(durations.every((duration) => duration === 0), true, "reduced motion removes guide and indicator transitions"); + await source.dispatchEvent("dragend", { dataTransfer: data }); + await unchanged(); + await page.emulateMedia({ reducedMotion: "no-preference" }); + + await start(); + await hover(middle - 40, "left"); + await hover(middle + 1, "left"); + await hover(middle + 40, "right"); + await hover(middle - 1, "right"); + // Drop just to the left of the centre while the stable preview still points right. + await area.dispatchEvent("drop", { dataTransfer: data, clientX: middle - 1, clientY: y }); + await preview(false, null); + await page.waitForFunction(() => document.querySelectorAll(".pane-slot").length === 2); + await half(page, "right").getByRole("log", { name: `conversation of ${dragged}`, exact: true }).waitFor(); + await isActive(page, "right"); + await data.dispose(); + console.log("PASS split preview starts early, survives child leaves, cancels cleanly and matches the drop side"); + assert.deepEqual(errors, []); + } + } finally { + for (const context of contexts) await context.close(); + for (const id of workspaces) await workspaceClose(id).catch(() => undefined); + rmSync(root, { recursive: true, force: true }); + } +} diff --git a/scripts/ui-regression.ts b/scripts/ui-regression.ts index 5eddbca5e9..c643a9f7a2 100644 --- a/scripts/ui-regression.ts +++ b/scripts/ui-regression.ts @@ -32,6 +32,7 @@ import { checkHeldDraftPaneSwitch } from "./terminal-draft-regression.ts"; import { checkDefaultView } from "./default-view-regression.ts"; import { checkComposerReconnect } from "./composer-reconnect-regression.ts"; import { checkDroplet } from "./droplet-regression.ts"; +import { checkSplitView } from "./split-view-regression.ts"; import { checkTakeOver } from "./take-over-regression.ts"; import { checkAlertSound } from "./alert-sound-regression.ts"; import { checkBackgroundTabKeepsTerminalSize, checkChatKeepsTerminalSize, checkFocusedWindowReclaimsTerminalSize, checkPaneSwitchKeepsTerminalSize } from "./chat-size-regression.ts"; @@ -854,6 +855,7 @@ try { await checkDefaultView(browser, origin); await checkComposerReconnect(browser, origin, paneB); await checkDroplet(browser, origin); + await checkSplitView(browser, origin); await checkTakeOver(browser, origin); await checkAlertSound(browser, origin); await checkChatKeepsTerminalSize(browser, origin); @@ -1142,6 +1144,7 @@ try { assert.equal(await composer.inputValue(), "draft for A"); console.log("PASS drafts stay with their panes"); + // A real successful send waits on its acknowledgement while its composer unmounts. for (const returnBeforeAck of [false, true]) { await composer.fill("# confirmed draft"); @@ -1780,6 +1783,37 @@ try { await selectPane(paneA); console.log("PASS Send now on a server-owned pending message drops Claude's suggestion"); + // Split view (lib/split.ts): a sidebar row dragged to the right half opens its pane there beside + // the one open, each half with its own conversation; a press in the other half makes it the + // active one without moving either pane, and closing a half leaves one pane + // (after the steps that take the newest socket for the page's own: the other half opens one) + { + await selectPane(paneA); + const area = page.locator(".pane-split"); + const box = (await area.boundingBox())!; + await page.locator(`.pane-select[title^="${paneB} —"]`).dragTo(area, { targetPosition: { x: box.width * 0.8, y: box.height / 2 } }); + await until(async () => await page.locator(".pane-slot").count() === 2, "two halves after the drop"); + const half = (side: string) => page.locator(`.pane-slot[data-side="${side}"]`); + await half("left").getByRole("log", { name: `conversation of ${paneA}`, exact: true }).waitFor(); + await half("right").getByRole("log", { name: `conversation of ${paneB}`, exact: true }).waitFor(); + assert.equal(await half("right").evaluate((slot) => slot.classList.contains("is-active")), true, "the dropped pane's half is active"); + assert.equal(await page.locator(".pane-select[aria-current=\"true\"]").getAttribute("title").then((title) => title?.startsWith(`${paneB} —`)), true, "and its pane is the selected one"); + const [left, right] = await Promise.all([half("left").boundingBox(), half("right").boundingBox()]); + assert.ok(left && right && Math.abs(left.width - right.width) <= 2 && left.x + left.width <= right.x + 1, "the halves share the pane area side by side"); + await half("left").locator(".terminal-host").click({ position: { x: 40, y: 40 } }); + await until(async () => await half("left").evaluate((slot) => slot.classList.contains("is-active")), "a press makes the left half active"); + // the header speaks for the active half's connection, handed over by a terminal that was already connected + await page.locator(".conn.conn-live").waitFor(); + await half("left").getByRole("log", { name: `conversation of ${paneA}`, exact: true }).waitFor(); + await half("right").getByRole("log", { name: `conversation of ${paneB}`, exact: true }).waitFor(); + assert.match(await page.evaluate(() => localStorage.getItem("herdr-web-ui:split") ?? ""), /"active":"a"/, "the split is kept for a reload"); + await half("right").getByRole("button", { name: "Close this half", exact: true }).click(); + await until(async () => await page.locator(".pane-slot").count() === 1, "one pane after closing a half"); + assert.equal(await page.evaluate(() => localStorage.getItem("herdr-web-ui:split")), null); + await page.getByRole("log", { name: `conversation of ${paneA}`, exact: true }).waitFor(); + } + console.log("PASS a dragged row opens its pane beside the open one; a press switches the active half; a half closes"); + const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); await mobile.addInitScript(() => { Storage.prototype.getItem = () => { throw new DOMException("Storage unavailable", "SecurityError"); }; diff --git a/src/App.tsx b/src/App.tsx index 28adfb33c6..dd0a4a6894 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { type CSSProperties, type DragEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { Bell, Ellipsis, FolderInput, FolderOpen, Link, Lock, Menu, MessageSquare, PanelLeft, Plus, Search, SquareTerminal, X } from "lucide-react"; import type { AgentStatus, ServerMessage, AccessRefusal, HealthAuth, HerdrPane } from "../shared/protocol.ts"; @@ -67,8 +67,13 @@ import { Droplet } from "./components/Droplet.tsx"; import { dropletAllows, endedTurn, seedStatuses, showDroplet, trackTurn, type DropletKind } from "./lib/droplet.ts"; import { canPlayAlertSound, playAlertSound, unlockAlertSound, type AlertSoundKind } from "./lib/alertSound.ts"; import { createAlertTurnPlayer } from "./lib/alertTurns.ts"; +import { dockPane, draggedPane, dropSide, otherSlot, PANE_DRAG_TYPE, sameTarget, slotSide, storedSplit, storeSplit, type SplitSide, type SplitSlot, type SplitState, type SplitTarget } from "./lib/split.ts"; +import { paneDrag } from "./lib/paneDrag.ts"; +import { SplitDropPreview } from "./components/SplitDropPreview.tsx"; const APP_TITLE = "herdr web ui"; +/** how soon after a key press a focus counts as the user's own (lastKeyAtRef) */ +const KEY_FOCUS_MS = 1000; const POLL_MS = 5000; /** @@ -261,6 +266,48 @@ export function App() { // the width the sidebar's edge was dragged to on this device; null is the density's own const [sidebarWidth, setSidebarWidth] = useState(storedSidebarWidth); const [lens, setLens] = useState<{ key: string; view: PaneView }>({ key: "", view: "terminal" }); + // two tabs side by side (lib/split.ts): the other half's pane, its side, and the active slot. + // Only drawn where the sidebar stands beside the pane; a narrower window shows the active half + const [split, setSplitState] = useState(storedSplit); + const setSplit = useCallback((next: SplitState | null) => { + setSplitState(next); + storeSplit(next); + }, []); + // the slot a single pane is drawn in: the half left when a split ends keeps its slot, and with it + // its terminals' connections and the messages those connections hold for the agent + const [soloSlot, setSoloSlot] = useState(() => storedSplit()?.active ?? "a"); + const endSplit = useCallback((remaining: SplitSlot) => { + setSplit(null); + setSoloSlot(remaining); + }, [setSplit]); + const splitRef = useRef(split); splitRef.current = split; + // whether the other half is drawn: a narrow window keeps the split but shows the active half alone + const splitShownRef = useRef(false); + // when a key was last pressed: a focus moving into the other half makes it active only right after + // one (a Tab), never when the app's own code moves the focus (an upload that finished) + const lastKeyAtRef = useRef(0); + useEffect(() => { + const onKey = (): void => { lastKeyAtRef.current = Date.now(); }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, []); + // a lens picked in the other half is stored per pane (storedView): this draws the half again + const [, setOtherLensSerial] = useState(0); + // the guide appears as soon as a pane is picked up; a target is chosen only over the area + const [paneDragging, setPaneDragging] = useState(false); + const [dropAt, setDropAt] = useState(null); + const dropAtRef = useRef(null); + const showDropTarget = useCallback((side: SplitSide | null): void => { + dropAtRef.current = side; + setDropAt(side); + }, []); + const clearDropPreview = useCallback((): void => { + setPaneDragging(false); + showDropTarget(null); + }, [showDropTarget]); + const paneAreaRef = useRef(null); + // a tab dragged out of its strip (lib/paneDrag.ts) + const tabDrag = useSyncExternalStore(paneDrag.subscribe, paneDrag.snapshot); const [paletteOpen, setPaletteOpen] = useState(false); const [findRequest, setFindRequest] = useState<{ paneId: string | null; serial: number }>({ paneId: null, serial: 0 }); // the header's More menu: its button, and whether it opened on a phone-width screen @@ -409,12 +456,29 @@ export function App() { if (refetchTimer.current !== null) window.clearTimeout(refetchTimer.current); }, []); + // The tab a pane is in, from the roster this client has: a pane of the tab a half shows is shown + // there, so one tab is never drawn twice (two terminals on one pane) + const tabOf = useCallback((target: SplitTarget): string | undefined => + machinesRef.current.find((m) => m.id === target.machineId)?.snapshot?.panes.find((pane) => pane.pane_id === target.paneId)?.tab_id, []); + const sameTab = useCallback((a: SplitTarget, b: SplitTarget): boolean => { + if (a.machineId !== b.machineId) return false; + if (a.paneId === b.paneId) return true; + const tab = tabOf(a); + return tab !== undefined && tab === tabOf(b); + }, [tabOf]); + /** the pane is in the tab the other half shows, while it is drawn */ + const inOtherHalf = useCallback((machineId: string, paneId: string): boolean => { + const open = splitRef.current; + return open !== null && splitShownRef.current && sameTab(open.other, { machineId, paneId }); + }, [sameTab]); + // In-app alerts (components/Droplet.tsx): only while the app is on screen - a hidden app has // its system notifications - and never for the pane already open in front of the user. const dropIn = useCallback((machine: Machine, pane: HerdrPane, kind: DropletKind) => { if (!alertsOnRef.current || !alertInAppRef.current || document.visibilityState !== "visible") return; const open = selectionRef.current; if (open.machineId === machine.id && open.paneId === pane.pane_id && !drawerOpenRef.current) return; + if (splitShownRef.current && sameTarget(splitRef.current?.other ?? null, { machineId: machine.id, paneId: pane.pane_id }) && !drawerOpenRef.current) return; showDroplet({ machineId: machine.id, paneId: pane.pane_id, @@ -439,6 +503,7 @@ export function App() { if (!alertsOnRef.current || !alertSoundRef.current || !canPlayAlertSound()) return; const open = selectionRef.current; if (document.visibilityState === "visible" && open.machineId === machine.id && open.paneId === pane.pane_id && !drawerOpenRef.current) return; + if (document.visibilityState === "visible" && splitShownRef.current && sameTarget(splitRef.current?.other ?? null, { machineId: machine.id, paneId: pane.pane_id }) && !drawerOpenRef.current) return; alertTurns.current?.chime(JSON.stringify([machine.id, pane.pane_id, kind]), kind); }, []); @@ -609,6 +674,12 @@ export function App() { // on the same PC keeps the connected socket, which never reports again: resetting here // left the header on "reconnecting" after every pane switch. if (machineId !== selectedMachineRef.current) setConnected(false); + // a pane of the tab open in the other half is picked by making that half active + const open = splitRef.current; + const current = selectionRef.current; + if (open && paneId !== null && current.paneId !== null && inOtherHalf(machineId, paneId)) { + setSplit({ other: { machineId: current.machineId, paneId: current.paneId }, bSide: open.bSide, active: otherSlot(open.active) }); + } setSelectedMachineId(machineId); setSelectedPaneId(paneId); setAutoSelected(false); setDrawerOpen(false); setOutputStopped(false); setNotificationViewTarget(view && paneId !== null ? { machine_id: machineId, pane_id: paneId, view } : null); @@ -616,7 +687,7 @@ export function App() { storeSelection(machineId, paneId); // a pane picked (a tapped notification, the sidebar, Back) before a link landed supersedes the link pendingLink.current = null; pendingFile.current = null; setLinkLanded(true); - }, []); + }, [inOtherHalf, setSplit]); const selectTargetRef = useRef(selectTarget); selectTargetRef.current = selectTarget; // the moves this client asked for and has no answer to yet (MovePaneMenu): the effect below // looks again when one ends, since a failed move leaves the selection to it after all @@ -688,11 +759,16 @@ export function App() { const selectPane = useCallback((paneId: string, focusInput = true) => { pendingLink.current = null; pendingFile.current = null; setLinkLanded(true); + const open = splitRef.current; + const current = selectionRef.current; + if (open && current.paneId !== null && inOtherHalf(current.machineId, paneId)) { + setSplit({ other: { machineId: current.machineId, paneId: current.paneId }, bSide: open.bSide, active: otherSlot(open.active) }); + } setSelectedPaneId(paneId); setNotificationViewTarget(null); setAutoSelected(!focusInput); setDrawerOpen(false); - }, []); + }, [inOtherHalf, setSplit]); // a layout call the palette made and the PC refused: a bridge from before the route says so // over the pane (the tab's own menu has a line of its own); anything else is logged, as @@ -762,6 +838,152 @@ export function App() { [selectedPaneId, selectedMachineId], ); + // The split view (lib/split.ts): drawn only where the sidebar stands beside the pane, and only + // while a pane is selected; a narrower window shows the active half alone and keeps the split + const splitOn = split !== null && wideScreen && selectedPaneId !== null; + splitShownRef.current = splitOn; + const otherMachine = split ? machines.find((m) => m.id === split.other.machineId) : undefined; + const otherPane = split ? otherMachine?.snapshot?.panes.find((pane) => pane.pane_id === split.other.paneId) ?? null : null; + const otherWorkspace = otherPane ? otherMachine?.snapshot?.workspaces.find((workspace) => workspace.workspace_id === otherPane.workspace_id) ?? null : null; + const otherHerdr = split?.other.machineId === "local" ? health?.herdr : otherMachine?.herdr; + const otherAttach = otherHerdr?.terminal_attach !== false || otherHerdr?.terminal_mirror === true; + const setOtherView = useCallback((machineId: string, paneId: string, next: PaneView) => { + try { + window.localStorage.setItem(`herdr-web-ui:view:${paneStorageId(machineId, paneId)}`, next); + } catch { + /* private mode: the lens just stops being remembered */ + } + setOtherLensSerial((serial) => serial + 1); + }, []); + + // the other half's pane closed: another pane of its tab takes its place, else back to one pane. + // Both halves on one tab (a pane moved in) is one tab too many. An offline PC's cached roster + // cannot say so, as for the selection itself + const otherTabRef = useRef(null); + if (otherPane) otherTabRef.current = otherPane.tab_id; + useEffect(() => { + if (!split) return; + const selected = selectedPaneId === null ? null : { machineId: selectedMachineId, paneId: selectedPaneId }; + if (selected && sameTab(split.other, selected)) { endSplit(split.active); return; } + if (!otherMachine?.snapshot || otherMachine.state !== "connected") return; + const panes = otherMachine.snapshot.panes; + if (panes.some((pane) => pane.pane_id === split.other.paneId)) return; + const tabId = otherTabRef.current; + const focused = otherMachine.snapshot.layouts.find((layout) => layout.tab_id === tabId)?.focused_pane_id; + const heir = panes.find((pane) => pane.tab_id === tabId && pane.pane_id === focused) ?? panes.find((pane) => pane.tab_id === tabId); + if (heir) setSplit({ ...split, other: { machineId: split.other.machineId, paneId: heir.pane_id } }); + else endSplit(split.active); + }, [split, otherMachine, selectedMachineId, selectedPaneId, endSplit, setSplit, sameTab]); + + /** A split change and the pane it selects. The half that becomes active keeps its terminals: only + * a half whose pane moves to another PC attaches again and reports its connection. */ + // `keepFocus`: the focus already went somewhere in the half made active (a Tab onto one of its + // buttons): the half's terminal and composer must not take the keyboard from it (autoSelected) + const applySplit = (next: SplitState | null, select: SplitTarget, keepFocus = false): void => { + const was = splitOn ? split : null; + const current: SplitTarget | null = selectedPaneId === null ? null : { machineId: selectedMachineId, paneId: selectedPaneId }; + const after: SplitSlot = next ? next.active : was && sameTarget(select, was.other) ? otherSlot(was.active) : was ? was.active : soloSlot; + const before = was ? (was.active === after ? current : was.other) : after === soloSlot ? current : null; + if (before === null || before.machineId !== select.machineId) setConnected(false); + if (next === null) endSplit(after); else setSplit(next); + setSelectedMachineId(select.machineId); setSelectedPaneId(select.paneId); setAutoSelected(keepFocus); setOutputStopped(false); + storeSelection(select.machineId, select.paneId); + }; + const activateSlot = (slot: SplitSlot, keepFocus = false): void => { + if (!splitOn || split.active === slot || selectedPaneId === null) return; + applySplit({ other: { machineId: selectedMachineId, paneId: selectedPaneId }, bSide: split.bSide, active: slot }, split.other, keepFocus); + }; + const closeSlot = (slot: SplitSlot): void => { + if (!splitOn) return; + if (slot !== split.active) { endSplit(split.active); return; } + applySplit(null, split.other); + }; + const selectInSlot = (slot: SplitSlot, machineId: string, paneId: string): void => { + const open = splitRef.current; + if (!open || !splitOn || open.active === slot) { selectPane(paneId); return; } + if (paneId === selectedPaneId && machineId === selectedMachineId) return; + setSplit({ ...open, other: { machineId, paneId } }); + }; + /** A pane dropped at a point: the half under it opens the pane. A pane of a tab a half already + * shows counts as that half's own pane, and is then picked in it. */ + const dockAt = (dragged: SplitTarget, x: number, y: number): void => { + const box = paneAreaRef.current?.getBoundingClientRect(); + const inside = !!box && x >= box.left && x < box.right && y >= box.top && y < box.bottom; + const side = box && inside ? dropSide(x, box.left, box.width, dropAtRef.current) : null; + clearDropPreview(); + if (side === null || !wideScreen || selectedPaneId === null) return; + const current = { machineId: selectedMachineId, paneId: selectedPaneId }; + const shown = sameTab(dragged, current) ? current : splitOn && sameTab(dragged, split.other) ? split.other : dragged; + const result = dockPane(splitOn ? split : null, current, shown, side, soloSlot); + if (!result) { + if (shown === current && !sameTarget(dragged, current)) selectPane(dragged.paneId); + return; + } + applySplit(result.split, sameTarget(result.select, shown) ? dragged : result.select); + }; + const dockAtRef = useRef(dockAt); dockAtRef.current = dockAt; + useEffect(() => { + paneDrag.onDrop((target, x, y) => dockAtRef.current(target, x, y)); + return () => paneDrag.onDrop(null); + }, []); + // a tab dragged out of its strip: the guide follows it as a sidebar row's drag does below + const splitDragOn = wideScreen && selectedPaneId !== null; + useEffect(() => { + if (!tabDrag || !splitDragOn) { if (!tabDrag) showDropTarget(null); return; } + const box = paneAreaRef.current?.getBoundingClientRect(); + const point = tabDrag.point; + const inside = !!box && !!point && point.x >= box.left && point.x < box.right && point.y >= box.top && point.y < box.bottom; + showDropTarget(box && point && inside ? dropSide(point.x, box.left, box.width, dropAtRef.current) : null); + }, [tabDrag, splitDragOn, showDropTarget]); + const paneDragOver = (event: DragEvent): void => { + if (!splitDragOn || !event.dataTransfer.types.includes(PANE_DRAG_TYPE)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setPaneDragging(true); + const box = paneAreaRef.current?.getBoundingClientRect(); + if (box) showDropTarget(dropSide(event.clientX, box.left, box.width, dropAtRef.current)); + }; + const paneDragLeave = (event: DragEvent): void => { + if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; + // Some browsers omit relatedTarget when crossing a child. The pointer still being inside + // the area must not clear the preview (or its stable side at the centre). + const box = event.currentTarget.getBoundingClientRect(); + if (event.relatedTarget === null && event.clientX >= box.left && event.clientX < box.right && event.clientY >= box.top && event.clientY < box.bottom) return; + showDropTarget(null); + }; + const paneDrop = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes(PANE_DRAG_TYPE)) return; + // the composer and the terminal take dropped files: a dropped pane is not one + event.preventDefault(); + event.stopPropagation(); + const dragged = draggedPane(event.dataTransfer.getData(PANE_DRAG_TYPE)); + if (dragged) dockAt(dragged, event.clientX, event.clientY); + else clearDropPreview(); + }; + // Bubble phase: the sidebar row has put its pane type into DataTransfer by then. + useEffect(() => { + if (!splitDragOn) { clearDropPreview(); return; } + const start = (event: globalThis.DragEvent): void => { + if (event.dataTransfer?.types.includes(PANE_DRAG_TYPE)) { + showDropTarget(null); + setPaneDragging(true); + } + }; + const cancel = (event: KeyboardEvent): void => { if (event.key === "Escape") clearDropPreview(); }; + window.addEventListener("dragstart", start); + window.addEventListener("dragend", clearDropPreview); + window.addEventListener("drop", clearDropPreview); + window.addEventListener("blur", clearDropPreview); + window.addEventListener("keydown", cancel); + return () => { + window.removeEventListener("dragstart", start); + window.removeEventListener("dragend", clearDropPreview); + window.removeEventListener("drop", clearDropPreview); + window.removeEventListener("blur", clearDropPreview); + window.removeEventListener("keydown", cancel); + }; + }, [splitDragOn, clearDropPreview, showDropTarget]); + // The address follows the app (lib/deepLink.ts): opening another pane or PC is an entry of the // history, so Back returns to the last one; a lens, a Settings page or a pane the app picked // itself replace the entry. Nothing is written while locked or before the roster is in, so a @@ -1212,56 +1434,112 @@ export function App() { { setSettingsSection("updates"); setSettingsOpen(true); }} /> { setDrawerOpen(false); setUpdateRemote(update); setMachineDialog(machine); }} /> {layoutNotice &&

{layoutNotice}

} - {snapshot && selectedPane && selectedWorkspace && ( - actions.openNewTab()} - paneActions={(paneId) => { - const target = snapshot.panes.find((item) => item.pane_id === paneId); - if (!target || !paneActionsRef.current) return []; - // the strip renders before the canvas, so its items come from the canvas's previous render: - // an action runs against the canvas's latest one, where a snapshot change has landed - return paneActionsRef.current(target).map((item) => ({ ...item, run: () => (paneActionsRef.current?.(target).find((fresh) => fresh.id === item.id) ?? item).run() })); - }} /> - )} - {/* the tab strip's panel: its id is what each tab's aria-controls points at. No tabIndex - - the terminal (PaneTerminal) and the composer are the focusable things inside it. */} -
- {/* the panel sits inside main, so the page keeps its main landmark; it draws no box */} -
- (item.pane_id === selectedPaneId ? view : storedView(lenses, item.pane_id, selectedMachineId, item.agent != null, terminalAttach)) === "terminal"} - renderPane={(item, active, split) => openFile({ path, paneId: item.pane_id, machineId: selectedMachineId }) : null}> - { setConnected(next); if (next) setOutputStopped(false); } : undefined} - onServerMessage={active ? handleServerMessage : undefined} - /> - } /> -
-
+ {/* the pane's tab, or two tabs side by side (lib/split.ts): a tab dragged down out of its strip, + or a sidebar row dragged over this area, opens its pane in the half it is dropped on */} +
+ {(["a", "b"] as const).map((slot) => { + // one pane: the solo slot, or a split's active half where the window is too narrow for both + if (!splitOn && slot !== (split ? split.active : soloSlot)) return null; + const active = !splitOn || split.active === slot; + const machineId = active ? selectedMachineId : split.other.machineId; + const paneId = active ? selectedPaneId : split.other.paneId; + const machine = active ? selectedMachine : otherMachine; + const pane = active ? selectedPane : otherPane; + const workspace = active ? selectedWorkspace : otherWorkspace; + const slotSnapshot = active ? snapshot : otherMachine?.snapshot ?? null; + const attach = active ? terminalAttach : otherAttach; + const lensOf = (item: HerdrPane): PaneView => active && item.pane_id === selectedPaneId ? view + : storedView(lenses, item.pane_id, machineId, item.agent != null, attach); + const slotView = active ? view : pane ? lensOf(pane) : "terminal"; + const onSelect = active ? selectPane : (id: string) => selectInSlot(slot, machineId, id); + // one pane's panel keeps the page's own id, whichever slot draws it + const slotPanelId = !splitOn || slot === "a" ? PANE_TABPANEL_ID : `${PANE_TABPANEL_ID}-${slot}`; + const slotPanelLabel = active ? tabPanelLabel : paneTabPanelLabel(slotSnapshot, pane, t); + const toggleSlotView = (): void => { + const next = slotView === "chat" ? "terminal" : "chat"; + if (active) setView(next); else if (pane) setOtherView(machineId, pane.pane_id, next); + }; + return ( + +
{ if (!(event.target as Element).closest(".pane-slot-action")) activateSlot(slot); }} + onFocusCapture={active ? undefined : (event) => { if (Date.now() - lastKeyAtRef.current < KEY_FOCUS_MS && !(event.target as Element).closest(".pane-slot-action")) activateSlot(slot, true); }} + > + {splitOn && ( +
+ {pane?.agent && } + {pane ? displayPaneTitle(pane) : t("Pane closed")} + {machines.length > 1 && {machine?.name ?? machineId}} + + +
+ )} + {slotSnapshot && pane && workspace && ( + actions.openNewTab(active ? undefined : { machineId, workspaceId: workspace.workspace_id })} + paneActions={active ? (paneId) => { + const target = snapshot?.panes.find((item) => item.pane_id === paneId); + if (!target || !paneActionsRef.current) return []; + // the strip renders before the canvas, so its items come from the canvas's previous render: + // an action runs against the canvas's latest one, where a snapshot change has landed + return paneActionsRef.current(target).map((item) => ({ ...item, run: () => (paneActionsRef.current?.(target).find((fresh) => fresh.id === item.id) ?? item).run() })); + } : undefined} /> + )} + {/* the tab strip's panel: its id is what each tab's aria-controls points at, one per half. + No tabIndex - the terminal (PaneTerminal) and the composer are the focusable things inside it. */} +
+
+ lensOf(item) === "terminal"} + renderPane={(item, itemActive, herdrSplit) => openFile({ path, paneId: item.pane_id, machineId }) : null}> + { setConnected(next); if (next) setOutputStopped(false); } : undefined} + onServerMessage={active && itemActive ? handleServerMessage : undefined} + /> + } /> +
+
+
+
+ ); + })} + +
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d5d7db9c20..fd38e29307 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -23,6 +23,7 @@ import { useWorktreeBranches } from "../lib/useWorktreeBranches.ts"; import { watchTouchReorder } from "../lib/touchReorder.ts"; import { worktreeLabel } from "../lib/worktreeName.ts"; import { paneMark, sidebarAgents, workspaceAgentLabels } from "../lib/sidebarAgents.ts"; +import { PANE_DRAG_TYPE, paneDragData } from "../lib/split.ts"; const ERROR_NOTE_MS = 5000; @@ -412,10 +413,12 @@ export function Sidebar({ snapshot, online, selectedPaneId, actions }: SidebarPr }); }; - const onDragStart = (event: DragEvent, workspaceId: string): void => { + const onDragStart = (event: DragEvent, workspaceId: string, paneId: string): void => { setDragWorkspaceId(workspaceId); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-herdr-workspace", JSON.stringify({ machine_id: machineId, workspace_id: workspaceId })); + // dropped on the pane area instead, the row opens its pane in half of it (lib/split.ts) + event.dataTransfer.setData(PANE_DRAG_TYPE, paneDragData({ machineId, paneId })); }; const onDrop = (event: DragEvent, targetWorkspaceId: string): void => { @@ -562,7 +565,7 @@ export function Sidebar({ snapshot, online, selectedPaneId, actions }: SidebarPr role="button" tabIndex={0} draggable={!coarsePointer && !editingWorkspace} - onDragStart={(event) => { if (!editingWorkspace) onDragStart(event, workspace.workspace_id); }} + onDragStart={(event) => { if (!editingWorkspace) onDragStart(event, workspace.workspace_id, pane.pane_id); }} onDragEnd={() => setDragWorkspaceId(null)} aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown" aria-description={`${t("Reorder workspace {name}", { name: workspace.label })} · ${t("Drag to reorder · Alt+↑/↓")}`} diff --git a/src/components/SplitDropPreview.css b/src/components/SplitDropPreview.css new file mode 100644 index 0000000000..2b7bb54d08 --- /dev/null +++ b/src/components/SplitDropPreview.css @@ -0,0 +1,71 @@ +.split-drop { + position: absolute; + inset: 0; + z-index: var(--z-popover); + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity var(--dur-fast) var(--ease-out), visibility 0s var(--dur-fast); +} + +.split-drop.is-visible { + opacity: 1; + visibility: visible; + transition-delay: 0s; +} + +.split-drop-track { + position: absolute; + inset: var(--space-2); + display: flex; + gap: var(--space-2); +} + +.split-drop-half { + position: relative; + display: flex; + flex: 1 1 0; + justify-content: center; + align-items: flex-start; + min-width: 0; + padding: var(--space-3); + border: var(--hairline) dashed var(--border-strong); + border-radius: var(--radius-md); +} + +.split-drop-label { + padding: var(--space-1) var(--space-2); + border: var(--hairline) solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-panel); + color: var(--text-dim); + font-size: var(--fs-xs); +} + +/* Move one highlight instead of flashing between two newly mounted halves. */ +.split-drop-target { + position: absolute; + inset: 0 auto 0 0; + width: calc((100% - var(--space-2)) / 2); + border: 2px solid var(--accent); + border-radius: var(--radius-md); + background: var(--accent-tint); + opacity: 0; + transform: translateX(0); + transition: transform var(--dur-fast) var(--ease-out), opacity var(--dur-fast) var(--ease-out); +} + +.split-drop[data-side] .split-drop-target { + opacity: 1; +} + +.split-drop[data-side="right"] .split-drop-target { + transform: translateX(calc(100% + var(--space-2))); +} + +@media (prefers-reduced-motion: reduce) { + .split-drop, + .split-drop-target { + transition: none; + } +} diff --git a/src/components/SplitDropPreview.tsx b/src/components/SplitDropPreview.tsx new file mode 100644 index 0000000000..842c6c8d38 --- /dev/null +++ b/src/components/SplitDropPreview.tsx @@ -0,0 +1,15 @@ +import { useT } from "../lib/i18n.ts"; +import type { SplitSide } from "../lib/split.ts"; +import "./SplitDropPreview.css"; + +/** A guide over the pane area; it never takes a drag, focus or space from the terminals. */ +export function SplitDropPreview({ visible, side }: { visible: boolean; side: SplitSide | null }) { + const t = useT(); + return