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
6 changes: 5 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1355,7 +1355,11 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`.
default): when herdr's own focus moves (a click in its window, `herdr tab focus`, a script's
`herdr tab create --focus`), the server's `pane-focused` frame opens that pane here too, only
on the PC open here and never for a pane already in front (the selected one or the other split
half). It is an automatic pick: no keyboard is raised and no Back entry is added. Off, a browser
half). It is an automatic pick: no keyboard is raised and no Back entry is added. A split this
device asked to open (Split pane right and open it) stays its own pick, keyboard and Back entry
included, even when herdr's frame comes before the split's answer: until that request is
answered, for 10 s at most, that PC's frames are not followed. A PC on Ovis older than 0.4.13 sends no such frame,
which the setting's description says. Off, a browser
is a tmux client of a grouped session (its own current window); on, it shares the session's
current window as herdr's own window does. The coding-agent apps have no outside focus to follow.
- Drag the actual split boundary to resize, with an 8px hit region and native 0.1–0.9 ratio
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/follow-focus-own-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Fixed
- With Settings → Terminal → Follow herdr's focus on, a split this device opens itself (Split pane
right and open it) keeps the keyboard in the new pane and adds a Back entry, even when herdr's
focus moving there reaches this device before the split's answer. The setting's description now
says that a PC on Ovis older than 0.4.13 does not report herdr's focus.
41 changes: 39 additions & 2 deletions scripts/follow-focus-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* brings to the front by itself (`herdr tab create --focus`, as a script does) is opened here only
* once this device asked for it, and herdr's focus moving back brings this device back too. It
* never takes the view while this window is in front and someone types into it or has a dialog
* open (#1470). Imported by ui-regression.ts, with Settings closed; it ends on the pane selected
* open, and a split this device opens itself stays its own pick when herdr's frame for it comes
* first (#1470). Imported by ui-regression.ts, with Settings closed; it ends on the pane selected
* when it began and with the setting off, also when it fails.
*/
import assert from "node:assert/strict";
Expand Down Expand Up @@ -49,6 +50,7 @@ export async function checkFollowHerdrFocus(page: Page, fixture: FollowFocusFixt
assert.ok(before, "a pane is selected before the check");
const tabs: string[] = [];
let following = false;
let releaseSplit = (): void => undefined;
try {
// off, as it starts: herdr's focus is herdr's, and this device stays where it is
const ignored = await focusedTab("follow focus off");
Expand Down Expand Up @@ -90,13 +92,48 @@ export async function checkFollowHerdrFocus(page: Page, fixture: FollowFocusFixt
await page.getByRole("button", { name: "Close settings", exact: true }).click();
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());

// a split this device opens itself: herdr's frame for the new pane comes before the split's
// answer, held here until that frame has been handled. The pane is still this device's own pick,
// with the keyboard in it and a Back entry to the pane it was split from
const splitFrom = followed.root_pane.pane_id;
let made: string | undefined;
const answerHeld = new Promise<void>((resolve) => { releaseSplit = () => resolve(); });
await page.route("**/api/pane/split", async (route) => {
// nobody types here and no dialog is open when herdr's frame comes
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
const response = await route.fetch();
made = ((await response.json()) as { pane: { pane_id: string } }).pane.pane_id;
await answerHeld;
await route.fulfill({ response });
});
await page.keyboard.press("ControlOrMeta+Shift+k");
const palette = page.getByRole("dialog", { name: "Command palette", exact: true });
await palette.getByRole("searchbox", { name: "Search panes and actions", exact: true }).fill("Split pane right and open it");
await palette.getByRole("option", { name: "Split pane right and open it", exact: true }).click();
await until(async () => made !== undefined, "herdr made the split");
// a tab made after the split is listed here: herdr's frame for the split, sent before it, has been handled
const marker = await herdrRpc<{ tab: { tab_id: string } }>("tab.create", { workspace_id: fixture.workspaceId, label: "follow focus split marker", focus: false });
tabs.push(marker.tab.tab_id);
await listed("follow focus split marker");
assert.equal(await selectedPaneId(), splitFrom, "herdr's frame for this device's own split is left to the split's answer");
releaseSplit();
await until(async () => await selectedPaneId() === made, "the split's answer opens its new pane");
const keyboardIn = (paneId: string) => page.evaluate((paneId) => document.activeElement?.closest("[data-layout-pane]")?.getAttribute("data-layout-pane") === paneId, paneId);
await until(() => keyboardIn(made!), "the keyboard is in the pane this device split off");
await page.unroute("**/api/pane/split");
await page.goBack();
await until(async () => await selectedPaneId() === splitFrom && await keyboardIn(splitFrom), "Back opens the pane it was split from");
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());

// herdr's focus goes back where this device was: so does this device
await herdrRpc("pane.focus", { pane_id: before });
await until(async () => await selectedPaneId() === before, "herdr's focus moving back brings this device back");
await follow(false);
following = false;
console.log("PASS herdr's focus is followed only once this device asks for it, to a new tab and back, and never while this device types or has a dialog open");
console.log("PASS herdr's focus is followed only once this device asks for it, to a new tab and back, never while this device types or has a dialog open, and a split this device opens stays its own pick");
} finally {
releaseSplit();
await page.unroute("**/api/pane/split").catch(() => undefined);
for (const tab of tabs) await tabClose(tab).catch(() => undefined);
// a check that stopped partway leaves the setting as it found it, for the checks after it
if (following) await follow(false).catch(() => undefined);
Expand Down
2 changes: 1 addition & 1 deletion src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ function TerminalPage({ keyBarButtonRef, onEditKeyBar }: { keyBarButtonRef: RefO
<SettingsRow label={t("Use alongside herdr's own window")} description={t("Turn this on if this PC also shows herdr in a terminal window. A second after you switch to another window, this tab only watches the pane, so herdr's window keeps the pane at its own size instead of this window's. Click the tab to type again. While it watches, the pane is drawn at herdr's size and may not fill this window.")}>
<Toggle label={t("Use alongside herdr's own window")} checked={settings.releasePaneAway} onChange={(releasePaneAway) => update({ releasePaneAway })} />
</SettingsRow>
<SettingsRow label={t("Follow herdr's focus")} description={t("When herdr moves to another pane, by a click in its window, a herdr command or a script, this device opens that pane too. Only on the PC open here, and only on this device.")}>
<SettingsRow label={t("Follow herdr's focus")} description={t("When herdr moves to another pane, by a click in its window, a herdr command or a script, this device opens that pane too. Only on the PC open here, and only on this device. A PC on Ovis older than 0.4.13 does not report herdr's focus: update it first.")}>
<Toggle label={t("Follow herdr's focus")} checked={settings.followHerdrFocus} onChange={(followHerdrFocus) => update({ followHerdrFocus })} />
</SettingsRow>
</SettingsGroup>
Expand Down
11 changes: 7 additions & 4 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import type { VoiceConfigUpdate, VoiceStatus } from "../../shared/voice.ts";
import { MAX_ATTACHMENT_BYTES } from "../../shared/attachments.ts";
import { t } from "./i18n.ts";
import { chatReadDeadlineMs, withChatReadDeadline } from "./chatReadDeadline.ts";
import { movingHerdrFocus } from "./followFocus.ts";
import type { UsageLoginAccounts, UsageLoginProvider, UsageLoginRequest, UsageLoginStarted } from "../../shared/usage-login.ts";

export function fetchUsageLoginProviders(signal: AbortSignal): Promise<{ providers: readonly UsageLoginProvider[] }> {
Expand Down Expand Up @@ -559,12 +560,14 @@ export async function renameAgent(paneId: string, name: string | null, machineId

/**
* POST /api/pane/split: a new pane beside this one in herdr (its prefix+v / prefix+-), answered as
* herdr lists it. herdr's focus stays put unless `focus` asks for the new pane; the sidebar and the
* tab strip learn of it from the server's session-changed broadcast.
* herdr lists it. herdr's focus stays put unless `focus` asks for the new pane, which the caller then
* opens itself (movingHerdrFocus); the sidebar and the tab strip learn of it from the server's
* session-changed broadcast.
*/
export async function splitPane(paneId: string, direction: SplitPaneDirection, focus = false, machineId = "local"): Promise<PaneInfo> {
const response = await sendJson(machinePath(machineId, "pane/split"), "POST", { pane_id: paneId, direction, ...(focus ? { focus: true } : {}) } satisfies SplitPaneRequest);
return ((await response.json()) as PaneSplit).pane;
const request = sendJson(machinePath(machineId, "pane/split"), "POST", { pane_id: paneId, direction, ...(focus ? { focus: true } : {}) } satisfies SplitPaneRequest)
.then(async (response) => ((await response.json()) as PaneSplit).pane);
return focus ? movingHerdrFocus(machineId, request) : request;
}

/** POST /api/pane/zoom: herdr's prefix+z on that pane; `zoomed` is the tab's state after it. */
Expand Down
60 changes: 58 additions & 2 deletions src/lib/followFocus.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, jest } from "bun:test";
import { paneStorageId } from "../../shared/machines.ts";
import { deviceBusy, deviceInUse, followsHerdrFocus, unsentDraft, type PaneDrafts } from "./followFocus.ts";
import { deviceBusy, deviceInUse, followsHerdrFocus, movingHerdrFocus, movingHerdrFocusOn, OWN_MOVE_HOLD_MS, unsentDraft, type PaneDrafts } from "./followFocus.ts";
import { writeTerminalDraft } from "./terminalDraft.ts";

const base = {
Expand All @@ -11,6 +11,7 @@ const base = {
splitOther: null,
linkPending: false,
busy: false,
ownMove: false,
};

describe("following herdr's focus", () => {
Expand Down Expand Up @@ -40,6 +41,61 @@ describe("following herdr's focus", () => {
it("keeps the pane of someone using this device (#1470)", () => {
expect(followsHerdrFocus({ ...base, busy: true })).toBe(false);
});

it("leaves a move this device asked for to its own answer (#1470)", () => {
expect(followsHerdrFocus({ ...base, ownMove: true })).toBe(false);
});
});

describe("a focus move this device asked for", () => {
const deferred = () => {
let resolve!: (pane: string) => void, reject!: (cause: Error) => void;
const promise = new Promise<string>((ok, fail) => { resolve = ok; reject = fail; });
return { promise, resolve, reject };
};

it("holds the PC's frames until its answer, which the caller sees after the hold ends", async () => {
const split = deferred();
const answered = movingHerdrFocus("local", split.promise).then((pane) => ({ pane, held: movingHerdrFocusOn("local") }));
expect(movingHerdrFocusOn("local")).toBe(true);
expect(movingHerdrFocusOn("office")).toBe(false);
split.resolve("w1:p2");
expect(await answered).toEqual({ pane: "w1:p2", held: false });
});

it("ends the hold when herdr refuses, and only once every move on that PC is answered", async () => {
const first = deferred(), second = deferred();
const refused = movingHerdrFocus("local", first.promise).catch(() => movingHerdrFocusOn("local"));
const made = movingHerdrFocus("local", second.promise);
first.reject(new Error("pane split failed"));
expect(await refused).toBe(true);
second.resolve("w1:p3");
await made;
expect(movingHerdrFocusOn("local")).toBe(false);
});

it("ends the hold of a request the PC never answers once its bound runs out, and only that one", async () => {
jest.useFakeTimers();
try {
const stalled = deferred();
const stalledHold = movingHerdrFocus("local", stalled.promise);
jest.advanceTimersByTime(OWN_MOVE_HOLD_MS - 1);
expect(movingHerdrFocusOn("local")).toBe(true);
jest.advanceTimersByTime(1);
expect(movingHerdrFocusOn("local")).toBe(false);
// a late answer to the stalled request does not end the hold of a request made since
const later = deferred();
const answered = movingHerdrFocus("local", later.promise);
stalled.resolve("w1:p5");
await stalledHold;
expect(movingHerdrFocusOn("local")).toBe(true);
later.resolve("w1:p4");
await answered;
expect(movingHerdrFocusOn("local")).toBe(false);
} finally {
jest.useRealTimers();
}
});
});

describe("a device in use", () => {
Expand Down
41 changes: 39 additions & 2 deletions src/lib/followFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { sameTarget, type SplitTarget } from "./split.ts";
* `pane-focused` frame says so. This device opens that pane too, only when it asked to and only on
* the PC it has open: another PC's focus is that PC's business, and a pane already in front (the
* selected one, or the other half of a split) stays where it is. A link still landing goes first,
* and someone using this device (`busy`, deviceInUse) keeps the pane they are on.
* someone using this device (`busy`, deviceInUse) keeps the pane they are on, and a move this device
* asked for itself (`ownMove`, movingHerdrFocus) is opened by its own answer.
*/
export function followsHerdrFocus(input: {
enabled: boolean;
Expand All @@ -23,12 +24,48 @@ export function followsHerdrFocus(input: {
linkPending: boolean;
/** this device is in use: following would drop the keyboard, close what is open or leave an unsent message (#1470) */
busy: boolean;
/** this device's own request is moving herdr's focus on that PC (movingHerdrFocus) */
ownMove: boolean;
}): boolean {
if (!input.enabled || input.linkPending || input.busy) return false;
if (!input.enabled || input.linkPending || input.busy || input.ownMove) return false;
if (input.machineId !== input.selection.machineId || input.paneId === input.selection.paneId) return false;
return !sameTarget(input.splitOther, { machineId: input.machineId, paneId: input.paneId });
}

/** This device's requests that move herdr's focus, still unanswered, by PC. */
const ownMoves = new Map<string, number>();
/**
* How long a request this device made leaves herdr's focus to it at most: a PC that never answers
* (a stalled connection, neither refused nor closed) must not turn following off until a reload.
*/
export const OWN_MOVE_HOLD_MS = 10_000;

/**
* A request from this device that moves herdr's focus (a split that opens its new pane): herdr's
* pane-focused frame may come before the answer, and following it would open the pane as an
* automatic pick, with no keyboard and no Back entry, before the caller opens it as its own (#1470).
* Until the request settles, or OWN_MOVE_HOLD_MS passes, frames from that PC are left to the
* caller; its own `then` runs after.
*/
export function movingHerdrFocus<T>(machineId: string, request: Promise<T>): Promise<T> {
ownMoves.set(machineId, (ownMoves.get(machineId) ?? 0) + 1);
let held = true;
const release = (): void => {
if (!held) return;
held = false;
const left = (ownMoves.get(machineId) ?? 1) - 1;
if (left > 0) ownMoves.set(machineId, left);
else ownMoves.delete(machineId);
};
const bound = setTimeout(release, OWN_MOVE_HOLD_MS);
return request.finally(() => { clearTimeout(bound); release(); });
}

/** A request from this device is moving herdr's focus on that PC (movingHerdrFocus). */
export function movingHerdrFocusOn(machineId: string): boolean {
return ownMoves.has(machineId);
}

/**
* A dialog is open over the pane, or the keyboard is in a text field (the composer, the input line,
* the terminal's own): following herdr's focus would close the dialog or drop the keyboard
Expand Down
2 changes: 1 addition & 1 deletion src/lib/i18n/settings.ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export const SETTINGS_JA: Record<string, string> = {
"Use alongside herdr's own window": "ターミナルの herdr と併用",
"Turn this on if this PC also shows herdr in a terminal window. A second after you switch to another window, this tab only watches the pane, so herdr's window keeps the pane at its own size instead of this window's. Click the tab to type again. While it watches, the pane is drawn at herdr's size and may not fill this window.": "この PC のターミナルウィンドウでも herdr を使うならオンにします。別のウィンドウに切り替えて 1 秒たつと、このタブはペインを表示するだけになり、herdr のウィンドウはペインをこのウィンドウではなく自分のサイズのまま保ちます。入力するにはタブをクリックしてください。表示中のペインは herdr のサイズで描かれるため、このウィンドウを埋めないことがあります。",
"Follow herdr's focus": "herdr のフォーカスに追従",
"When herdr moves to another pane, by a click in its window, a herdr command or a script, this device opens that pane too. Only on the PC open here, and only on this device.": "herdr が別のペインに移ると(herdr のウィンドウでのクリック、herdr コマンド、スクリプト)、このデバイスもそのペインを開きます。ここで開いている PC だけで、このデバイスだけに適用されます。",
"When herdr moves to another pane, by a click in its window, a herdr command or a script, this device opens that pane too. Only on the PC open here, and only on this device. A PC on Ovis older than 0.4.13 does not report herdr's focus: update it first.": "herdr が別のペインに移ると(herdr のウィンドウでのクリック、herdr コマンド、スクリプト)、このデバイスもそのペインを開きます。ここで開いている PC だけで、このデバイスだけに適用されます。Ovis 0.4.13 より古い PC は herdr のフォーカスを知らせないため、先に更新してください。",
"Without a key the browser recognizes the speech: Chrome and Edge send the audio to Google or Microsoft. Nothing is recorded until you press the mic.": "キーがない場合はブラウザが音声を認識します。Chrome と Edge は音声を Google または Microsoft に送信します。マイクを押すまで録音されません。",
"Drops fillers and fixes spacing; code and paths stay as spoken": "言いよどみを除き、スペースを整えます。コードとパスはそのまま残します",
"In chat": "チャット",
Expand Down
Loading