Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa

## [Unreleased]

### Fixed
- A pane in use keeps the lens it shows: with **Panes open in → Chat**, a shell split beside an
agent no longer turns into its chat when an agent starts in it, and an agent pane no longer
turns into its terminal when the agent exits. A lens picked by hand still wins, and a new
Settings choice applies again to every pane.
([#755](https://github.com/devswha/herdr-web-ui/pull/755))

## [0.4.6] - 2026-10-11

### Added
Expand Down
18 changes: 17 additions & 1 deletion scripts/default-view-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Browser } from "playwright-core";
import { herdrRpc, workspaceClose, workspaceCreate } from "../server/herdr/client.ts";
import { herdrRpc, paneSplit, workspaceClose, workspaceCreate } from "../server/herdr/client.ts";
import { openSettingsPage } from "./settings-page.ts";

/** Settings > Chat > Panes open in: one choice puts every agent pane on that lens, the ones that remembered another included. */
Expand Down Expand Up @@ -50,6 +50,22 @@ export async function checkDefaultView(browser: Browser, origin: string): Promis
await page.waitForFunction(() => document.querySelector(".view-switch button[aria-pressed='true']")?.getAttribute("title")?.startsWith("Live terminal") === true);
assert.deepEqual(errors, []);
console.log("PASS one choice in Settings opens every agent pane in the chat");

// a shell split beside an agent opens its terminal under the chat default, and keeps it when
// an agent starts in it: the lens of a pane in use is never switched under the user
const shell = (await paneSplit(one, "right", false)).pane_id;
// the split opens beside the agent: a click in its frame selects it, as in herdr
await page.locator(`.pane-select[title^="${one} —"]`).click();
const frame = page.locator(`.pane-frame[data-layout-pane="${shell}"]`);
await frame.locator(".pane-frame-body").click({ position: { x: 40, y: 40 } });
await page.locator(`.pane-frame.is-current[data-layout-pane="${shell}"]`).waitFor();
await page.waitForFunction(() => document.querySelector(".view-switch button[aria-pressed='true']")?.getAttribute("title")?.startsWith("Live terminal") === true);
await herdrRpc("pane.report_agent", { pane_id: shell, source: "manual", agent: "claude", state: "working" });
// the header names the pane's agent once the app has seen it start
await page.locator(".context-title .agent-mark.claude").waitFor({ timeout: 10_000 });
assert.match((await lens()) ?? "", /^Live terminal/, "an agent starting in the open shell leaves it on its terminal");
assert.deepEqual(errors, []);
console.log("PASS a shell in use keeps its terminal when an agent starts in it");
} finally {
await context.close();
for (const workspace of workspaces) await workspaceClose(workspace).catch(() => undefined);
Expand Down
39 changes: 20 additions & 19 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ import { applyPaneStatus } from "./lib/snapshot.ts";
import { rosterPanes } from "./lib/dagPane.ts";
import { carryPaneRecords, paneMovePending, paneMovesVersion, subscribePaneMoves } from "./lib/paneMove.ts";
import { SnapshotRequests } from "./lib/snapshotRequests.ts";
import { alertPrefs, useSettings, type DefaultView } from "./lib/settings.ts";
import { alertPrefs, useSettings } from "./lib/settings.ts";
import { useShortcuts } from "./lib/shortcuts.ts";
import { useMediaQuery } from "./lib/useMediaQuery.ts";
import { PaneLenses } from "./lib/paneLens.ts";
import type { AppActions, PaneView } from "./lib/actions.ts";
import {
notificationState,
Expand Down Expand Up @@ -107,27 +108,23 @@ function storeSelection(machineId: string, paneId: string | null): void {
}

/**
* The lens a pane opens in: remembered per pane. A pane seen for the first time opens its
* terminal, except an agent pane on a touch screen, which opens its chat: a phone reads a
* conversation better than a TUI sized for a desktop. Until the snapshot says whether the
* pane has an agent (null), a touch screen guesses chat: most panes opened there are agents,
* and guessing terminal flashed it for the seconds before the snapshot arrived. A PC whose
* herdr has no terminal attach and no mirror either (an older Windows bridge) always opens
* its chat: its terminal lens is only a notice, so a remembered choice there is not worth
* keeping. A mirrored PC counts as having a terminal.
* The lens a pane opens in: the one picked for it by hand, remembered per pane, else its default
* (lib/paneLens.ts), kept for the page once the pane is known so an agent starting or exiting in
* it does not switch the lens under the user. A PC whose herdr has no terminal attach and no
* mirror either (an older Windows bridge) always opens its chat: its terminal lens is only a
* notice, so a remembered choice there is not worth keeping. A mirrored PC counts as having a
* terminal.
*/
function storedView(paneId: string, machineId: string, hasAgent: boolean | null, terminalAttach: boolean, defaultView: DefaultView): PaneView {
/** `hasAgent`: herdr leaves `agent` out for a shell, so callers compare with `!= null` */
function storedView(lenses: PaneLenses, paneId: string, machineId: string, hasAgent: boolean | null, terminalAttach: boolean): PaneView {
if (!terminalAttach) return "chat";
try {
const stored = window.localStorage.getItem(`herdr-web-ui:view:${paneStorageId(machineId, paneId)}`);
if (stored === "chat" || stored === "terminal") return stored;
} catch {
/* private mode */
}
// Settings' choice for every pane: chat needs an agent, a shell has no conversation to show
if (defaultView === "chat") return hasAgent !== false ? "chat" : "terminal";
if (defaultView === "terminal") return "terminal";
return hasAgent !== false && window.matchMedia?.("(pointer: coarse)").matches === true ? "chat" : "terminal";
return lenses.of(paneStorageId(machineId, paneId), hasAgent, window.matchMedia?.("(pointer: coarse)").matches === true);
}

function Brand() {
Expand Down Expand Up @@ -636,11 +633,13 @@ export function App() {
// the lens follows the selected pane: each pane remembers its own. It is settled in the render
// that selects the pane, not in an effect after it: the pane's terminal attaches in that render's
// layout effect, and an attach in the previous pane's lens resized a pane whose lens is chat
// a new Settings default is a new start for every pane's lens
const lenses = useMemo(() => new PaneLenses(settings.defaultView), [settings.defaultView]);
const lensKey = JSON.stringify([selectedPaneId, selectedMachineId, selectedPane !== null, selectedAgent !== null, terminalAttach, settings.defaultView]);
const notificationView = notificationViewForPane(notificationViewTarget, selectedMachineId, selectedPaneId);
let view = lens.view;
if (lens.key !== lensKey) {
if (selectedPaneId !== null) view = storedView(selectedPaneId, selectedMachineId, selectedPane ? selectedAgent !== null : null, terminalAttach, settings.defaultView);
if (selectedPaneId !== null) view = storedView(lenses, selectedPaneId, selectedMachineId, selectedPane ? selectedAgent !== null : null, terminalAttach);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
setLens({ key: lensKey, view });
}
// a tapped alert's lens is drawn over the pane's own, never stored: once it is gone (the pane or
Expand Down Expand Up @@ -741,6 +740,7 @@ export function App() {
paneMoved: (machineId, previousPaneId, paneId) => {
if (previousPaneId === paneId) return;
carryPaneRecords(machineId, previousPaneId, paneId);
lenses.move(paneStorageId(machineId, previousPaneId), paneStorageId(machineId, paneId));
// the new id is selected before a snapshot without the old one can fall the selection
// back to herdr's focus; a pane moved from the sidebar while another is open stays unselected
const current = selectionRef.current;
Expand Down Expand Up @@ -793,7 +793,7 @@ export function App() {
void zoomPane(paneId, mode, machineId).then(() => void load()).catch((err) => layoutRefused("pane zoom failed", err));
} : null,
}),
[selectPane, selectedPaneId, selectedMachineId, setView, view, updateSettings, resolvedTheme, canSignOut, lock, bellVisible, bell.run, enableNotifications, load, layoutRefused],
[selectPane, selectedPaneId, selectedMachineId, setView, view, updateSettings, resolvedTheme, canSignOut, lock, bellVisible, bell.run, enableNotifications, load, layoutRefused, lenses],
);

useShortcuts(actions, locked === false);
Expand Down Expand Up @@ -989,7 +989,7 @@ export function App() {
{/* the panel sits inside main, so the page keeps its main landmark; it draws no box */}
<div id={PANE_TABPANEL_ID} className="terminal-tabpanel" role={tabPanelLabel === null ? undefined : "tabpanel"} aria-label={tabPanelLabel ?? undefined}>
<PaneCanvas key={selectedMachineId} snapshot={snapshot} pane={selectedPane} selectedPaneId={selectedPaneId} onSelect={selectPane} onChanged={actions.refresh}
isTerminal={(item) => (item.pane_id === selectedPaneId ? view : storedView(item.pane_id, selectedMachineId, item.agent !== null, terminalAttach, settings.defaultView)) === "terminal"}
isTerminal={(item) => (item.pane_id === selectedPaneId ? view : storedView(lenses, item.pane_id, selectedMachineId, item.agent != null, terminalAttach)) === "terminal"}
renderPane={(item, active) => <OpenFileContext.Provider value={item ? (path) => openFile({ path, paneId: item.pane_id, machineId: selectedMachineId }) : null}>
<PaneTerminal
title={item ? displayPaneTitle(item) : selectedTitle}
Expand All @@ -1003,7 +1003,7 @@ export function App() {
backgroundWait={item?.background_wait === true}
cwd={item?.cwd ?? null}
machineName={selectedMachine?.name ?? selectedMachineId}
view={active ? view : storedView(item!.pane_id, selectedMachineId, item!.agent !== null, terminalAttach, settings.defaultView)}
view={active ? view : storedView(lenses, item!.pane_id, selectedMachineId, item!.agent != null, terminalAttach)}
active={active}
findRequest={findRequest.paneId === item?.pane_id ? findRequest.serial : 0}
autoSelected={autoSelected}
Expand All @@ -1029,8 +1029,9 @@ export function App() {
tab={newTab}
defaultCwd={newSessionMachineId === selectedMachineId ? selectedPane?.cwd ?? null : null}
onClose={() => setNewSessionOpen(false)}
onCreated={(paneId) => {
onCreated={(paneId, agentStarted) => {
setNewSessionOpen(false);
if (agentStarted) lenses.startedAgent(paneStorageId(newSessionMachineId, paneId), window.matchMedia?.("(pointer: coarse)").matches === true);
selectTarget(newSessionMachineId, paneId);
void load();
}}
Expand Down
7 changes: 4 additions & 3 deletions src/components/NewSessionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export interface NewSessionDialogProps {
/** set, the dialog makes a tab in that workspace instead of a workspace */
tab?: NewTabTarget | null;
onClose: () => void;
onCreated: (paneId: string) => void;
/** `agentStarted`: the pane runs the agent asked for, so it opens in an agent's lens */
onCreated: (paneId: string, agentStarted: boolean) => void;
}

function directoryBasename(value: string): string {
Expand Down Expand Up @@ -97,7 +98,7 @@ export function NewSessionDialog({ open, defaultCwd, tab = null, onClose, onCrea
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
if (pending) return;
if (createdPaneId !== null) { onCreated(createdPaneId); return; }
if (createdPaneId !== null) { onCreated(createdPaneId, false); return; }
setPending(true);
setError(null);
try {
Expand All @@ -116,7 +117,7 @@ export function NewSessionDialog({ open, defaultCwd, tab = null, onClose, onCrea
// the dialog closes on this: left pending, its next opening would start a frame with the
// fields disabled and Escape ignored, until the open effect's reset has rendered
setPending(false);
onCreated(result.pane_id);
onCreated(result.pane_id, agentKind !== "" && result.agent_started !== false);
} catch (reason: unknown) {
setPending(false);
if (reason instanceof ApiError && reason.code === "invalid_cwd") setError(t("Directory not found"));
Expand Down
56 changes: 56 additions & 0 deletions src/lib/paneLens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "bun:test";

import { defaultLens, PaneLenses } from "./paneLens.ts";

describe("defaultLens", () => {
it("follows Settings: chat for an agent (or one not known yet), the terminal for a shell", () => {
expect(defaultLens(true, "chat", false)).toBe("chat");
expect(defaultLens(null, "chat", false)).toBe("chat");
expect(defaultLens(false, "chat", false)).toBe("terminal");
expect(defaultLens(true, "terminal", true)).toBe("terminal");
expect(defaultLens(true, "auto", false)).toBe("terminal");
expect(defaultLens(true, "auto", true)).toBe("chat");
expect(defaultLens(false, "auto", true)).toBe("terminal");
});
});

describe("PaneLenses", () => {
it("keeps a shell's terminal when an agent starts in it, with the default view on chat", () => {
const lenses = new PaneLenses("chat");
expect(lenses.of("local/w1:p2", false, false)).toBe("terminal");
// claude started in the split's shell: the pane in use stays on its terminal
expect(lenses.of("local/w1:p2", true, false)).toBe("terminal");
});

it("keeps an agent pane's chat when its agent exits", () => {
const lenses = new PaneLenses("chat");
expect(lenses.of("local/w1:p1", true, false)).toBe("chat");
expect(lenses.of("local/w1:p1", false, false)).toBe("chat");
});

it("settles nothing while the snapshot does not know the pane yet", () => {
const lenses = new PaneLenses("chat");
expect(lenses.of("local/w1:p3", null, false)).toBe("chat");
expect(lenses.of("local/w1:p3", false, false)).toBe("terminal");
expect(lenses.of("local/w1:p3", true, false)).toBe("terminal");
});

it("gives a pane this page started an agent in an agent's lens, even if a snapshot first shows its shell", () => {
const lenses = new PaneLenses("chat");
lenses.startedAgent("local/w2:p1", false);
expect(lenses.of("local/w2:p1", false, false)).toBe("chat");
});

it("keeps a moved pane's lens under the new id herdr gives it", () => {
const lenses = new PaneLenses("chat");
expect(lenses.of("local/w1:p2", false, false)).toBe("terminal");
lenses.move("local/w1:p2", "local/w2:p1");
expect(lenses.of("local/w2:p1", true, false)).toBe("terminal");
});

it("keeps each pane apart", () => {
const lenses = new PaneLenses("chat");
expect(lenses.of("local/w1:p1", true, false)).toBe("chat");
expect(lenses.of("remote/w1:p1", false, false)).toBe("terminal");
});
});
47 changes: 47 additions & 0 deletions src/lib/paneLens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { PaneView } from "./actions.ts";
import type { DefaultView } from "./settings.ts";

/**
* The lens a pane opens in when nobody picked one for it: Settings' default for every pane
* (chat needs an agent, a shell has no conversation to show), or, on Auto, the chat for an
* agent pane on a touch screen. Until the snapshot says whether the pane has an agent (null),
* it counts as one.
*/
export function defaultLens(hasAgent: boolean | null, defaultView: DefaultView, coarse: boolean): PaneView {
if (defaultView === "chat") return hasAgent !== false ? "chat" : "terminal";
if (defaultView === "terminal") return "terminal";
return hasAgent !== false && coarse ? "chat" : "terminal";
}

/**
* The default lens of each pane this page has shown, kept once the snapshot said whether the
* pane has an agent. An agent that starts in a shell, or one that exits, leaves the pane on the
* lens it was showing: re-deriving it turned a split's terminal into a chat while it was in use.
* A new Settings default starts a new map (App), and a lens picked by hand is stored apart and
* always wins.
*/
export class PaneLenses {
private readonly settled = new Map<string, PaneView>();
constructor(private readonly defaultView: DefaultView) {}

of(key: string, hasAgent: boolean | null, coarse: boolean): PaneView {
const settled = this.settled.get(key);
if (settled !== undefined) return settled;
const lens = defaultLens(hasAgent, this.defaultView, coarse);
if (hasAgent !== null) this.settled.set(key, lens);
return lens;
}

/** A pane herdr gave a new id (a move to another workspace) keeps the lens it was showing. */
move(from: string, to: string): void {
const settled = this.settled.get(from);
if (settled === undefined) return;
this.settled.delete(from);
this.settled.set(to, settled);
}

/** A pane this page started an agent in: its lens is an agent's, whatever the first snapshot says. */
startedAgent(key: string, coarse: boolean): void {
this.settled.set(key, defaultLens(true, this.defaultView, coarse));
}
}
Loading