From 5830deb0a6463095d8caf87f201ed1ace141bf2f Mon Sep 17 00:00:00 2001 From: mp Date: Sat, 24 Oct 2026 23:27:18 +0900 Subject: [PATCH 1/6] feat(tabs): rename the current tab from a shortcut and the palette --- changelog.d/rename-tab-shortcut.md | 4 +++ docs/guide.md | 2 +- scripts/session-history-fixture.tsx | 2 +- ...hortcut-exact-modifiers-demo-regression.ts | 30 +++++++++++++++++++ src/App.tsx | 8 +++++ src/components/CommandPalette.tsx | 3 +- src/components/TabStrip.tsx | 21 +++++++++---- src/lib/actions.ts | 2 ++ src/lib/i18n.ja.ts | 1 + src/lib/i18n.ko.ts | 1 + src/lib/i18n.zh.ts | 1 + src/lib/i18n/settings.ja.ts | 1 + src/lib/i18n/settings.ko.ts | 1 + src/lib/i18n/settings.zh.ts | 1 + src/lib/i18n/terminal.ja.ts | 1 - src/lib/i18n/terminal.ko.ts | 1 - src/lib/i18n/terminal.zh.ts | 1 - src/lib/paletteSettings.ts | 1 + src/lib/shortcutBindings.ts | 2 +- src/lib/shortcuts.test.ts | 10 ++++++- src/lib/shortcuts.ts | 6 +++- 21 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 changelog.d/rename-tab-shortcut.md diff --git a/changelog.d/rename-tab-shortcut.md b/changelog.d/rename-tab-shortcut.md new file mode 100644 index 0000000000..957dfa91b7 --- /dev/null +++ b/changelog.d/rename-tab-shortcut.md @@ -0,0 +1,4 @@ +### Added +- **Rename tab** is a command palette action and a shortcut with no default key (bind one in + Settings → Shortcuts): it opens the inline name field of the open tab, as the tab menu's Rename + does. diff --git a/docs/guide.md b/docs/guide.md index 9aae35449b..1bdd13b51b 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -596,7 +596,7 @@ focus where it was. **Mod+Shift+E** focuses the selected pane's input in any mod terminal's input line and direct input; it prefers masked input while a secret prompt is open. The shortcut can be reassigned or disabled in Settings → Shortcuts. -**New tab**, **Split pane right** and **Split pane down** have no default key: pick one in +**New tab**, **Rename tab**, **Split pane right** and **Split pane down** have no default key: pick one in **Settings → Shortcuts**, and the command palette shows it beside the action. **Settings → Shortcuts** changes each app binding or turns it off: press **Record** on a row, diff --git a/scripts/session-history-fixture.tsx b/scripts/session-history-fixture.tsx index ff140e4a8a..af8b27abef 100644 --- a/scripts/session-history-fixture.tsx +++ b/scripts/session-history-fixture.tsx @@ -75,7 +75,7 @@ function Root() { const join = (panes: HerdrPane[]) => setSnapshot((current) => ({ ...current, panes: [...current.panes, ...panes] })); window.qa.rosterCatchUp = () => join(held.current.splice(0)); const actions: AppActions = { - selectPane: setSelected, selectAdjacentPane: noop, setView: noop, toggleView: noop, focusInput: noop, scrollPage: noop, openNewSession: noop, openNewTab: noop, + selectPane: setSelected, selectAdjacentPane: noop, setView: noop, toggleView: noop, focusInput: noop, scrollPage: noop, openNewSession: noop, openNewTab: noop, renameTab: noop, openTerminalTab: async () => {}, openSessionHistory: (target) => { // as App does: prefer Claude for its store, keeping the original request for picker changes if (target) setHistory({ workspaceId: target.workspaceId, agent: target.agent ?? null, askingPaneId: target.paneId ?? null, paneId: sessionHistorySourcePane(sessionHistoryAgent(target.agent) ?? "codex", snapshot.panes, { workspaceId: target.workspaceId, askingPaneId: target.paneId, selectedPaneId: selected }) }); diff --git a/scripts/shortcut-exact-modifiers-demo-regression.ts b/scripts/shortcut-exact-modifiers-demo-regression.ts index 023b8dbb2b..37eab6c650 100644 --- a/scripts/shortcut-exact-modifiers-demo-regression.ts +++ b/scripts/shortcut-exact-modifiers-demo-regression.ts @@ -122,6 +122,36 @@ try { await page.locator(".settings-dialog").waitFor(); await page.getByRole("button", { name: "Close settings", exact: true }).click(); + // Rename tab has no default key: a recorded chord opens the open tab's name field, Escape cancels, Enter renames + await hungarianE(); + await page.locator(".settings-dialog").waitFor(); + await record(page, "Rename tab", "Meta+Alt+KeyR"); + assert.equal((await stored(page))["rename-tab"], "alt+meta+r"); + await page.getByRole("button", { name: "Close settings", exact: true }).click(); + const tabName = page.locator(".tab-strip-item.is-active .tab-strip-rename"); + const tabLabel = (await page.locator('.tab-strip-item.is-active [role="tab"]').textContent()) ?? ""; + await page.keyboard.press("Meta+Alt+KeyR"); + await tabName.waitFor(); + assert.equal(await tabName.evaluate((input) => input === document.activeElement), true, "the name field has the focus"); + if (screenshotDir) await page.screenshot({ path: join(screenshotDir, "rename-tab-shortcut.png") }); + await page.keyboard.press("Escape"); + await tabName.waitFor({ state: "detached" }); + assert.equal(await page.locator('.tab-strip-item.is-active [role="tab"]').textContent(), tabLabel, "Escape keeps the name"); + await page.keyboard.press("Meta+Alt+KeyR"); + await tabName.waitFor(); + await page.keyboard.type("Renamed by shortcut"); + await page.keyboard.press("Enter"); + await tabName.waitFor({ state: "detached" }); + assert.match((await page.locator('.tab-strip-item.is-active [role="tab"]').textContent()) ?? "", /Renamed by shortcut/, "Enter renames the tab"); + // the palette offers the same action + await page.keyboard.press("Control+KeyK"); + await palette.waitFor(); + await page.keyboard.type("rename tab"); + await page.getByRole("option", { name: /Rename tab/ }).first().click(); + await tabName.waitFor(); + assert.equal(await tabName.evaluate((input) => input === document.activeElement), true, "the palette action focuses the name field"); + await page.keyboard.press("Escape"); + // a binding on End gives way to a message box that has text, as the arrows do; an empty box lets it run const composer = page.locator(".composer-text").first(); await composer.fill("draft"); diff --git a/src/App.tsx b/src/App.tsx index 56fa5e7f3c..a2d6dc1b26 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -187,6 +187,8 @@ export function App() { const { paletteOpen, paletteQuery, openPalette, setPaletteOpen } = useCommandPalette(); const paneControls = useRef(null); const [findRequest, setFindRequest] = useState<{ paneId: string | null; serial: number }>({ paneId: null, serial: 0 }); + // the Rename tab shortcut and palette action: the tab strip of the selected pane opens its name field + const [renameRequest, setRenameRequest] = useState<{ paneId: string | null; serial: number }>({ paneId: null, serial: 0 }); // the header's More menu: its button const [more, setMore] = useState<{ anchor: HTMLElement } | null>(null); const closeMore = useCallback(() => setMore(null), []); @@ -385,6 +387,11 @@ export function App() { setNewTab({ workspaceId: workspace.workspace_id, workspaceLabel: workspace.label, cwd, number: workspace.tab_count + 1 }); setNewSessionOpen(true); }, + renameTab: () => { + if (selectedPaneId === null) return; + setDrawerOpen(false); + setRenameRequest((request) => ({ paneId: selectedPaneId, serial: request.serial + 1 })); + }, openTerminalTab: async (target) => { const found = newTabTarget(target); if (!found) return; @@ -790,6 +797,7 @@ export function App() { {!(slotSnapshot && pane && workspace) && slotBar} {slotSnapshot && pane && workspace && ( actions.openNewTab(active ? undefined : { machineId, workspaceId: workspace.workspace_id })} onNewTerminal={() => actions.openTerminalTab(active ? undefined : { machineId, workspaceId: workspace.workspace_id })} onMoveToWorkspace={(tabId, workspaceId) => actions.moveTabToWorkspace({ machineId, tabId, workspaceId })} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index ef6be32910..7f4d580e90 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -1,6 +1,6 @@ import { MachineContext, useMachineApi, useMachineId } from "../lib/machineContext.tsx"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentType, type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react"; -import { Bell, Columns2, CornerLeftUp, Folder, FolderInput, FolderOpen, FolderSearch, History, LoaderCircle, Maximize2, MessageSquarePlus, Minimize2, Monitor, PanelLeft, Pin, Plus, Puzzle, RefreshCw, Rows2, Settings, SunMoon, SwitchCamera, X } from "lucide-react"; +import { Bell, Columns2, CornerLeftUp, Folder, FolderInput, FolderOpen, FolderSearch, History, LoaderCircle, Maximize2, MessageSquarePlus, Minimize2, Monitor, PanelLeft, Pencil, Pin, Plus, Puzzle, RefreshCw, Rows2, Settings, SunMoon, SwitchCamera, X } from "lucide-react"; import "./CommandPalette.css"; @@ -302,6 +302,7 @@ export function CommandPalette({ open, initialQuery = "", onClose, machines, onS { id: "new", label: t("New workspace"), icon: MessageSquarePlus, shortcut: "new-session", run: actions.openNewSession }, // in the selected pane's workspace: nothing to add a tab to without one ...(selectedPaneId !== null ? [{ id: "new-tab", label: t("New tab"), icon: Plus, shortcut: "new-tab", run: () => actions.openNewTab() }] satisfies PaletteAction[] : []), + ...(selectedPaneId !== null ? [{ id: "rename-tab", label: t("Rename tab"), icon: Pencil, shortcut: "rename-tab", run: actions.renameTab }] satisfies PaletteAction[] : []), // herdr's prefix+v and prefix+-: the new pane is opened here only when asked, as herdr's --focus ...(splitPane ? [ { id: "split-right", label: t("Split pane right"), icon: Columns2, shortcut: "split-right", run: () => splitPane("right") }, diff --git a/src/components/TabStrip.tsx b/src/components/TabStrip.tsx index f2428d16b3..c31aaefc0d 100644 --- a/src/components/TabStrip.tsx +++ b/src/components/TabStrip.tsx @@ -55,11 +55,13 @@ export interface TabStripProps { panelId?: string; /** a tab dragged out of the strip may open its pane in half of the pane area (lib/paneDrag.ts) */ splitDrag?: boolean; + /** a counter the app raises to open the open tab's name field, as the menu's Rename does (0: none asked) */ + renameRequest?: number; /** drawn at the row's far end, outside the scrolling tabs: a split view half's close (App) */ end?: ReactNode; } -export function TabStrip({ snapshot, workspace, selectedPane, onSelectPane, onNewTab, onNewTerminal, onMoveToWorkspace, paneActions, panelId = PANE_TABPANEL_ID, splitDrag = false, end }: TabStripProps) { +export function TabStrip({ snapshot, workspace, selectedPane, onSelectPane, onNewTab, onNewTerminal, onMoveToWorkspace, paneActions, panelId = PANE_TABPANEL_ID, splitDrag = false, renameRequest = 0, end }: TabStripProps) { const t = useT(); const { settings } = useSettings(); const machineId = useMachineId(); @@ -211,6 +213,19 @@ export function TabStrip({ snapshot, workspace, selectedPane, onSelectPane, onNe if (row) scroll.current = stripScrolled(scroll.current, row.scrollLeft, row.scrollWidth - row.clientWidth); }; + const beginRename = (tab: HerdrTab): void => { + setError(null); + setEditing({ tabId: tab.tab_id, value: sent?.tabId === tab.tab_id ? sent.label : customTabLabel(tab, tabs.findIndex((candidate) => candidate.tab_id === tab.tab_id) + 1) ?? "" }); + }; + // a request from the app (shortcut, palette) opens the open tab's field once; a strip that is not drawn ignores it + const lastRenameRequest = useRef(renameRequest); + useLayoutEffect(() => { + if (renameRequest === lastRenameRequest.current) return; + lastRenameRequest.current = renameRequest; + const tab = tabs.find((candidate) => candidate.tab_id === selectedPane.tab_id); + if (renameRequest > 0 && tab && shown && !(cramped && tabs.length === 1)) beginRename(tab); + }, [renameRequest]); + if (!shown || (cramped && tabs.length === 1)) return null; const panesOf = (tab: HerdrTab): PaneInfo[] => panes.filter((pane) => pane.tab_id === tab.tab_id); @@ -227,10 +242,6 @@ export function TabStrip({ snapshot, workspace, selectedPane, onSelectPane, onNe window.requestAnimationFrame(() => strip.current?.querySelector(`[role="tab"][data-tab-id="${CSS.escape(tabId)}"]`)?.focus()); }; - const beginRename = (tab: HerdrTab): void => { - setError(null); - setEditing({ tabId: tab.tab_id, value: sent?.tabId === tab.tab_id ? sent.label : customTabLabel(tab, tabs.findIndex((candidate) => candidate.tab_id === tab.tab_id) + 1) ?? "" }); - }; // an empty name is not sent: herdr would keep it, and its own tab row would show nothing const saveRename = (tab: HerdrTab): void => { const label = editing?.value.trim() ?? ""; diff --git a/src/lib/actions.ts b/src/lib/actions.ts index a23cda7cc4..40fa5518db 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -30,6 +30,8 @@ export interface AppActions { * else the selected pane's. Nothing happens without a workspace to add the tab to. */ openNewTab: (target?: { machineId: string; workspaceId: string }) => void; + /** The inline name field of the selected pane's tab, as the tab menu's Rename opens it; nothing while the tab strip is not drawn. */ + renameTab: () => void; /** * A shell tab in the same workspace, at once and with no dialog, as herdr's own new tab: the * new pane opens unless another one was opened meanwhile. Rejects with herdr's refusal. diff --git a/src/lib/i18n.ja.ts b/src/lib/i18n.ja.ts index 30ecca132e..92874d4879 100644 --- a/src/lib/i18n.ja.ts +++ b/src/lib/i18n.ja.ts @@ -242,6 +242,7 @@ const BASE: Record = { "Try again": "再試行", // tabs: the strip over the pane, and the New tab dialog "New tab": "新しいタブ", + "Rename tab": "タブの名前を変更", "Zoom pane": "ペインをズーム", "Unzoom pane": "ズームを解除", "This PC's bridge does not offer this yet": "このPCのブリッジはまだこの機能に対応していません。", diff --git a/src/lib/i18n.ko.ts b/src/lib/i18n.ko.ts index 3a12467c32..cf7b7cf340 100644 --- a/src/lib/i18n.ko.ts +++ b/src/lib/i18n.ko.ts @@ -240,6 +240,7 @@ const BASE: Record = { "Try again": "다시 시도", // tabs: the strip over the pane, and the New tab dialog "New tab": "새 탭", + "Rename tab": "탭 이름 바꾸기", "Zoom pane": "패널 확대", "Unzoom pane": "패널 확대 해제", "This PC's bridge does not offer this yet": "이 PC의 브리지는 아직 이 기능을 제공하지 않습니다.", diff --git a/src/lib/i18n.zh.ts b/src/lib/i18n.zh.ts index 6da151ea6d..9b604b4521 100644 --- a/src/lib/i18n.zh.ts +++ b/src/lib/i18n.zh.ts @@ -244,6 +244,7 @@ const BASE: Record = { "Try again": "重试", // tabs: the strip over the pane, and the New tab dialog "New tab": "新建标签页", + "Rename tab": "重命名标签页", "Zoom pane": "放大窗格", "Unzoom pane": "取消放大", "This PC's bridge does not offer this yet": "此电脑的桥接尚未提供此功能。", diff --git a/src/lib/i18n/settings.ja.ts b/src/lib/i18n/settings.ja.ts index 891f061fa0..ecd655670a 100644 --- a/src/lib/i18n/settings.ja.ts +++ b/src/lib/i18n/settings.ja.ts @@ -253,6 +253,7 @@ export const SETTINGS_JA: Record = { "version update release herdr": "バージョン アップデート リリース herdr", "create workspace session start": "作成 ワークスペース セッション 開始", "create tab add": "作成 タブ 追加", + "edit tab name title": "編集 タブ 名前 タイトル", "divide pane layout side": "分割 ペイン レイアウト 横", "divide pane layout below": "分割 ペイン レイアウト 下", "maximize fullscreen full screen focus": "最大化 フルスクリーン 全画面 フォーカス", diff --git a/src/lib/i18n/settings.ko.ts b/src/lib/i18n/settings.ko.ts index da2ba05e5d..f95120900b 100644 --- a/src/lib/i18n/settings.ko.ts +++ b/src/lib/i18n/settings.ko.ts @@ -253,6 +253,7 @@ export const SETTINGS_KO: Record = { "version update release herdr": "버전 업데이트 릴리스 herdr", "create workspace session start": "만들기 워크스페이스 세션 시작", "create tab add": "만들기 탭 추가", + "edit tab name title": "편집 탭 이름 제목", "divide pane layout side": "나누기 패널 레이아웃 옆", "divide pane layout below": "나누기 패널 레이아웃 아래", "maximize fullscreen full screen focus": "최대화 전체 화면 집중", diff --git a/src/lib/i18n/settings.zh.ts b/src/lib/i18n/settings.zh.ts index 368e527ba3..4d9bba4b46 100644 --- a/src/lib/i18n/settings.zh.ts +++ b/src/lib/i18n/settings.zh.ts @@ -253,6 +253,7 @@ export const SETTINGS_ZH: Record = { "version update release herdr": "版本 更新 发布 herdr", "create workspace session start": "创建 工作区 会话 开始", "create tab add": "创建 标签页 添加", + "edit tab name title": "编辑 标签页 名称 标题", "divide pane layout side": "拆分 窗格 布局 侧边", "divide pane layout below": "拆分 窗格 布局 下方", "maximize fullscreen full screen focus": "最大化 全屏 专注", diff --git a/src/lib/i18n/terminal.ja.ts b/src/lib/i18n/terminal.ja.ts index 9945187b6b..dabd22ca5c 100644 --- a/src/lib/i18n/terminal.ja.ts +++ b/src/lib/i18n/terminal.ja.ts @@ -84,7 +84,6 @@ export const TERMINAL_JA: Record = { "New terminal": "新しいターミナル", "New terminal failed: {reason}": "新しいターミナルを開けませんでした: {reason}", "Tab {n}": "タブ {n}", - "Rename tab": "タブの名前を変更", "Close tab": "タブを閉じる", "Tab name": "タブ名", "Actions for {tab}": "{tab} の操作", diff --git a/src/lib/i18n/terminal.ko.ts b/src/lib/i18n/terminal.ko.ts index 19942e7ff0..a6cd015036 100644 --- a/src/lib/i18n/terminal.ko.ts +++ b/src/lib/i18n/terminal.ko.ts @@ -84,7 +84,6 @@ export const TERMINAL_KO: Record = { "New terminal": "새 터미널", "New terminal failed: {reason}": "새 터미널 열기 실패: {reason}", "Tab {n}": "탭 {n}", - "Rename tab": "탭 이름 바꾸기", "Close tab": "탭 닫기", "Tab name": "탭 이름", "Actions for {tab}": "{tab} 작업", diff --git a/src/lib/i18n/terminal.zh.ts b/src/lib/i18n/terminal.zh.ts index 6682dc7580..c8c3fa2b99 100644 --- a/src/lib/i18n/terminal.zh.ts +++ b/src/lib/i18n/terminal.zh.ts @@ -84,7 +84,6 @@ export const TERMINAL_ZH: Record = { "New terminal": "新建终端", "New terminal failed: {reason}": "新建终端失败:{reason}", "Tab {n}": "标签页 {n}", - "Rename tab": "重命名标签页", "Close tab": "关闭标签页", "Tab name": "标签页名称", "Actions for {tab}": "{tab} 的操作", diff --git a/src/lib/paletteSettings.ts b/src/lib/paletteSettings.ts index 29d3a03021..842617594a 100644 --- a/src/lib/paletteSettings.ts +++ b/src/lib/paletteSettings.ts @@ -50,6 +50,7 @@ export function actionAliases(t: Translate): Readonly> { return { new: t("create workspace session start"), "new-tab": t("create tab add"), + "rename-tab": t("edit tab name title"), "split-right": t("divide pane layout side"), "split-down": t("divide pane layout below"), "split-right-open": t("divide pane layout side"), diff --git a/src/lib/shortcutBindings.ts b/src/lib/shortcutBindings.ts index 7053a1a867..ed14050c39 100644 --- a/src/lib/shortcutBindings.ts +++ b/src/lib/shortcutBindings.ts @@ -4,7 +4,7 @@ * null returns a key to the terminal. */ export type ShortcutOverrides = Record; -export const CUSTOM_SHORTCUT_IDS = ["palette", "find", "toggle-view", "focus-input", "scroll-up", "scroll-down", "toggle-sidebar", "new-session", "new-tab", "split-right", "split-down", "previous-pane", "next-pane", "settings"]; +export const CUSTOM_SHORTCUT_IDS = ["palette", "find", "toggle-view", "focus-input", "scroll-up", "scroll-down", "toggle-sidebar", "new-session", "new-tab", "rename-tab", "split-right", "split-down", "previous-pane", "next-pane", "settings"]; /** A chord with its exact modifiers; key as overrides store it (letters lowercase). */ export interface ExactChord { diff --git a/src/lib/shortcuts.test.ts b/src/lib/shortcuts.test.ts index f9bfdf0b1d..2280c1591c 100644 --- a/src/lib/shortcuts.test.ts +++ b/src/lib/shortcuts.test.ts @@ -211,7 +211,7 @@ it("pages the selected pane with fixed Mod+Shift bindings, remapping and disabli describe("new tab and split pane", () => { it("have no key until one is chosen in Settings, and then work like any other binding", () => { - for (const id of ["new-tab", "split-right", "split-down"] as const) { + for (const id of ["new-tab", "rename-tab", "split-right", "split-down"] as const) { expect(shortcutKeys(id, {})).toEqual([]); expect(shortcutDisplayKeys(id, {})).toEqual([]); } @@ -225,6 +225,14 @@ describe("new tab and split pane", () => { expect(shortcutConflict("split-down", ["y"], overrides)).toBe(true); expect(shortcutConflict("new-tab", ["k"], {})).toBe(true); }); + + it("binds Rename tab to a chosen key", () => { + const overrides = sanitizeShortcutOverrides({ "rename-tab": "r" }); + expect(overrides).toEqual({ "rename-tab": "r" }); + expect(matchShortcut(keyEvent("R", { ctrlKey: true }), false, overrides)).toBe("rename-tab"); + expect(shortcutDisplayKeys("rename-tab", overrides)).toEqual(["Mod", "Shift", "R"]); + expect(shortcutConflict("rename-tab", ["r"], { "new-tab": "r" })).toBe(true); + }); }); describe("exact-modifier bindings", () => { diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index d9cd52d07f..499434d1d6 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -18,6 +18,7 @@ export const SHORTCUTS = [ { id: "new-session", label: "New workspace", keys: ["Mod", "Shift", "O"] }, // no default key: the free Mod+Shift letters are few, so each person picks one in Settings { id: "new-tab", label: "New tab", keys: [] }, + { id: "rename-tab", label: "Rename tab", keys: [] }, { id: "split-right", label: "Split pane right", keys: [] }, { id: "split-down", label: "Split pane down", keys: [] }, { id: "previous-pane", label: "Previous pane", keys: ["Mod", "Shift", "ArrowUp"] }, @@ -230,7 +231,7 @@ export function useShortcuts(actions: AppActions, enabled: boolean): void { if (keepsKeyForText(event)) return; event.preventDefault(); // a held chord repeats: a tab or a split is made once per press, not once per key repeat - if (event.repeat && (shortcut === "new-tab" || shortcut === "split-right" || shortcut === "split-down")) return; + if (event.repeat && (shortcut === "new-tab" || shortcut === "rename-tab" || shortcut === "split-right" || shortcut === "split-down")) return; switch (shortcut) { case "palette": actions.openPalette(); @@ -259,6 +260,9 @@ export function useShortcuts(actions: AppActions, enabled: boolean): void { case "new-tab": actions.openNewTab(); break; + case "rename-tab": + actions.renameTab(); + break; case "split-right": actions.splitPane?.("right"); break; From e6bf4b75759fd4b5f6f406b820c868ec994539fb Mon Sep 17 00:00:00 2001 From: mp Date: Sat, 24 Oct 2026 23:56:31 +0900 Subject: [PATCH 2/6] fix(tabs): make the rename-tab request one-shot and keep it off a hidden strip --- docs/guide.md | 4 ++-- ...hortcut-exact-modifiers-demo-regression.ts | 12 +++++++++- src/App.tsx | 8 ++++--- src/components/CommandPalette.tsx | 9 +++++-- src/components/TabStrip.tsx | 24 ++++++++++--------- src/lib/paneCanvas.ts | 5 ++++ src/lib/shortcuts.test.ts | 14 ++++------- src/lib/useRenameRequest.ts | 16 +++++++++++++ 8 files changed, 63 insertions(+), 29 deletions(-) create mode 100644 src/lib/useRenameRequest.ts diff --git a/docs/guide.md b/docs/guide.md index 1bdd13b51b..8dcb999e74 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -596,8 +596,8 @@ focus where it was. **Mod+Shift+E** focuses the selected pane's input in any mod terminal's input line and direct input; it prefers masked input while a secret prompt is open. The shortcut can be reassigned or disabled in Settings → Shortcuts. -**New tab**, **Rename tab**, **Split pane right** and **Split pane down** have no default key: pick one in -**Settings → Shortcuts**, and the command palette shows it beside the action. +**New tab**, **Rename tab**, **Split pane right** and **Split pane down** have no default key: +pick one in **Settings → Shortcuts**, and the command palette shows it beside the action. **Settings → Shortcuts** changes each app binding or turns it off: press **Record** on a row, then the new chord (Escape cancels). A Mod+Shift chord is always safe for the terminal. Other diff --git a/scripts/shortcut-exact-modifiers-demo-regression.ts b/scripts/shortcut-exact-modifiers-demo-regression.ts index 37eab6c650..26fe66a46a 100644 --- a/scripts/shortcut-exact-modifiers-demo-regression.ts +++ b/scripts/shortcut-exact-modifiers-demo-regression.ts @@ -124,7 +124,7 @@ try { // Rename tab has no default key: a recorded chord opens the open tab's name field, Escape cancels, Enter renames await hungarianE(); - await page.locator(".settings-dialog").waitFor(); + await openSettingsPage(page, "Shortcuts"); await record(page, "Rename tab", "Meta+Alt+KeyR"); assert.equal((await stored(page))["rename-tab"], "alt+meta+r"); await page.getByRole("button", { name: "Close settings", exact: true }).click(); @@ -137,12 +137,22 @@ try { await page.keyboard.press("Escape"); await tabName.waitFor({ state: "detached" }); assert.equal(await page.locator('.tab-strip-item.is-active [role="tab"]').textContent(), tabLabel, "Escape keeps the name"); + // the request is one-shot: leaving the pane and coming back does not open the field again + const openTab = () => document.querySelector('.tab-strip-item.is-active [role="tab"]')?.dataset["tabId"] ?? null; + const firstTab = await page.evaluate(openTab); + await page.keyboard.press("Meta+Shift+End"); + await page.waitForFunction((before) => (document.querySelector('.tab-strip-item.is-active [role="tab"]')?.dataset["tabId"] ?? null) !== before, firstTab); + await page.keyboard.press("Meta+Shift+ArrowUp"); + await page.waitForFunction((before) => (document.querySelector('.tab-strip-item.is-active [role="tab"]')?.dataset["tabId"] ?? null) === before, firstTab); + assert.equal(await page.locator(".tab-strip-rename").count(), 0, "coming back to the pane does not reopen the name field"); await page.keyboard.press("Meta+Alt+KeyR"); await tabName.waitFor(); await page.keyboard.type("Renamed by shortcut"); await page.keyboard.press("Enter"); await tabName.waitFor({ state: "detached" }); assert.match((await page.locator('.tab-strip-item.is-active [role="tab"]').textContent()) ?? "", /Renamed by shortcut/, "Enter renames the tab"); + // the demo's authoritative snapshot carries the name, so herdr was sent the rename + await page.waitForFunction(async () => (await (await fetch("/api/session")).json()).snapshot.tabs.some((tab: { label: string }) => tab.label === "Renamed by shortcut")); // the palette offers the same action await page.keyboard.press("Control+KeyK"); await palette.waitFor(); diff --git a/src/App.tsx b/src/App.tsx index a2d6dc1b26..d664a41e39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,6 +64,7 @@ import { useMachinesRef, useRecentFolderMemory, useRefetchTimer, useRosterError, import { useMachineDialog } from "./lib/useMachineDialog.ts"; import { useNewSessionMachine, useNewSessionState, useSessionHistoryList } from "./lib/useNewSessionDialog.ts"; import { useCommandPalette } from "./lib/useCommandPalette.ts"; +import { useRenameRequest } from "./lib/useRenameRequest.ts"; import { useFilePanels } from "./lib/useFilePanels.ts"; import { usePaneCoverage } from "./lib/usePaneCoverage.ts"; import { useT } from "./lib/i18n.ts"; @@ -188,7 +189,7 @@ export function App() { const paneControls = useRef(null); const [findRequest, setFindRequest] = useState<{ paneId: string | null; serial: number }>({ paneId: null, serial: 0 }); // the Rename tab shortcut and palette action: the tab strip of the selected pane opens its name field - const [renameRequest, setRenameRequest] = useState<{ paneId: string | null; serial: number }>({ paneId: null, serial: 0 }); + const { renameRequest, requestRename, clearRenameRequest } = useRenameRequest(); // the header's More menu: its button const [more, setMore] = useState<{ anchor: HTMLElement } | null>(null); const closeMore = useCallback(() => setMore(null), []); @@ -390,7 +391,7 @@ export function App() { renameTab: () => { if (selectedPaneId === null) return; setDrawerOpen(false); - setRenameRequest((request) => ({ paneId: selectedPaneId, serial: request.serial + 1 })); + requestRename(selectedMachineId, selectedPaneId); }, openTerminalTab: async (target) => { const found = newTabTarget(target); @@ -797,7 +798,8 @@ export function App() { {!(slotSnapshot && pane && workspace) && slotBar} {slotSnapshot && pane && workspace && ( actions.openNewTab(active ? undefined : { machineId, workspaceId: workspace.workspace_id })} onNewTerminal={() => actions.openTerminalTab(active ? undefined : { machineId, workspaceId: workspace.workspace_id })} onMoveToWorkspace={(tabId, workspaceId) => actions.moveTabToWorkspace({ machineId, tabId, workspaceId })} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 7f4d580e90..8c13e65514 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -25,6 +25,7 @@ import { useT } from "../lib/i18n.ts"; import { useFocusTrap } from "../lib/useFocusTrap.ts"; import { useSheetSwipe } from "../lib/sheets.ts"; import { usePhoneSheetBack } from "../lib/sheetHistory.ts"; +import { CRAMPED_QUERY, loneTabHidden } from "../lib/paneCanvas.ts"; import { useMediaQuery } from "../lib/useMediaQuery.ts"; import { useWorktreeBranches } from "../lib/useWorktreeBranches.ts"; import { worktreeLabel } from "../lib/worktreeName.ts"; @@ -153,6 +154,7 @@ export function CommandPalette({ open, initialQuery = "", onClose, machines, onS const machineId = useMachineId(); const api = useMachineApi(); const touch = useMediaQuery("(pointer: coarse)"); + const cramped = useMediaQuery(CRAMPED_QUERY); const [query, setQuery] = useState(""); const [filter, setFilter] = useState("all"); const [selection, setSelection] = useState(NO_SELECTION); @@ -297,12 +299,15 @@ export function CommandPalette({ open, initialQuery = "", onClose, machines, onS // browsing starts in the selected pane's folder, home without one const selectedPane = snapshot?.panes.find((pane) => pane.pane_id === selectedPaneId); const browseStart = selectedPane && panePath(selectedPane) ? panePath(selectedPane) : null; + // Rename tab opens the tab strip's name field: not offered where the strip is left out (a lone tab on a cramped screen) + const tabCount = selectedPane ? snapshot?.tabs.filter((tab) => tab.workspace_id === selectedPane.workspace_id).length ?? 0 : 0; + const stripDrawn = !loneTabHidden(cramped, tabCount); const paletteActions = useMemo(() => [ { id: "new", label: t("New workspace"), icon: MessageSquarePlus, shortcut: "new-session", run: actions.openNewSession }, // in the selected pane's workspace: nothing to add a tab to without one ...(selectedPaneId !== null ? [{ id: "new-tab", label: t("New tab"), icon: Plus, shortcut: "new-tab", run: () => actions.openNewTab() }] satisfies PaletteAction[] : []), - ...(selectedPaneId !== null ? [{ id: "rename-tab", label: t("Rename tab"), icon: Pencil, shortcut: "rename-tab", run: actions.renameTab }] satisfies PaletteAction[] : []), + ...(selectedPaneId !== null && stripDrawn ? [{ id: "rename-tab", label: t("Rename tab"), icon: Pencil, shortcut: "rename-tab", run: actions.renameTab }] satisfies PaletteAction[] : []), // herdr's prefix+v and prefix+-: the new pane is opened here only when asked, as herdr's --focus ...(splitPane ? [ { id: "split-right", label: t("Split pane right"), icon: Columns2, shortcut: "split-right", run: () => splitPane("right") }, @@ -320,7 +325,7 @@ export function CommandPalette({ open, initialQuery = "", onClose, machines, onS ...(actions.openFiles ? [{ id: "files", label: t("Browse files"), icon: FolderOpen, run: actions.openFiles }] : []), ...(online ? [{ id: "browse-folders", label: t("Browse folders"), icon: FolderSearch, keepOpen: true, run: () => browseTo(browseStart ?? home ?? "~") }] : []), { id: "refresh", label: t("Refresh"), icon: RefreshCw, run: actions.refresh }, - ], [actions, splitPane, zoomPane, tabPanes, zoom, view, t, selectedPaneId, online, browseTo, browseStart, home]); + ], [actions, splitPane, zoomPane, tabPanes, zoom, view, t, selectedPaneId, stripDrawn, online, browseTo, browseStart, home]); // Settings pages and groups are found by a query only, and open on the page itself, a group's // scrolled to and focused: the dialog reads the request as it mounts diff --git a/src/components/TabStrip.tsx b/src/components/TabStrip.tsx index c31aaefc0d..cdf1b83f12 100644 --- a/src/components/TabStrip.tsx +++ b/src/components/TabStrip.tsx @@ -19,10 +19,11 @@ import { ApiError, routeMissing } from "../lib/api.ts"; import { useFacesArrived } from "../lib/fontFaces.ts"; import { focusWorkspaceListToggle } from "../lib/focus.ts"; import { useT } from "../lib/i18n.ts"; +import type { RenameRequest } from "../lib/useRenameRequest.ts"; import { customTabLabel, tabLabel } from "../lib/tabName.ts"; import { STRIP_AT_REST, stripPlaced, stripScrolled, stripSelected, type StripScroll } from "../lib/tabStripScroll.ts"; import { PANE_TABPANEL_ID, paneTabPanelLabel } from "../lib/paneRegion.ts"; -import { SOLO_PANE_QUERY } from "../lib/paneCanvas.ts"; +import { CRAMPED_QUERY, loneTabHidden, SOLO_PANE_QUERY } from "../lib/paneCanvas.ts"; import { rosterPanes } from "../lib/dagPane.ts"; import { useMachineApi, useMachineId } from "../lib/machineContext.tsx"; import { useMediaQuery } from "../lib/useMediaQuery.ts"; @@ -55,13 +56,14 @@ export interface TabStripProps { panelId?: string; /** a tab dragged out of the strip may open its pane in half of the pane area (lib/paneDrag.ts) */ splitDrag?: boolean; - /** a counter the app raises to open the open tab's name field, as the menu's Rename does (0: none asked) */ - renameRequest?: number; + /** the app asks this strip (shortcut, palette) to open the open tab's name field, as the menu's Rename does; `onRenameHandled` clears it */ + renameRequest?: RenameRequest | null; + onRenameHandled?: () => void; /** drawn at the row's far end, outside the scrolling tabs: a split view half's close (App) */ end?: ReactNode; } -export function TabStrip({ snapshot, workspace, selectedPane, onSelectPane, onNewTab, onNewTerminal, onMoveToWorkspace, paneActions, panelId = PANE_TABPANEL_ID, splitDrag = false, renameRequest = 0, end }: TabStripProps) { +export function TabStrip({ snapshot, workspace, selectedPane, onSelectPane, onNewTab, onNewTerminal, onMoveToWorkspace, paneActions, panelId = PANE_TABPANEL_ID, splitDrag = false, renameRequest = null, onRenameHandled, end }: TabStripProps) { const t = useT(); const { settings } = useSettings(); const machineId = useMachineId(); @@ -74,7 +76,7 @@ export function TabStrip({ snapshot, workspace, selectedPane, onSelectPane, onNe const soloPanes = useMediaQuery(SOLO_PANE_QUERY); // a short phone screen (landscape, a small phone) has no height to spare: a lone tab is not // drawn there, so an approval card keeps its options in reach; its actions stay in the header - const cramped = useMediaQuery("(max-width: 768px) and (max-height: 600px)"); + const cramped = useMediaQuery(CRAMPED_QUERY); const strip = useRef(null); const [picker, setPicker] = useState<{ anchor: HTMLElement; tab: HerdrTab; point?: { x: number; y: number }; previousFocus: HTMLElement | null; touch: boolean; workspaces?: true } | null>(null); const [editing, setEditing] = useState<{ tabId: string; value: string } | null>(null); @@ -217,16 +219,16 @@ export function TabStrip({ snapshot, workspace, selectedPane, onSelectPane, onNe setError(null); setEditing({ tabId: tab.tab_id, value: sent?.tabId === tab.tab_id ? sent.label : customTabLabel(tab, tabs.findIndex((candidate) => candidate.tab_id === tab.tab_id) + 1) ?? "" }); }; - // a request from the app (shortcut, palette) opens the open tab's field once; a strip that is not drawn ignores it - const lastRenameRequest = useRef(renameRequest); + // The request is one-shot (the app clears it once handled), so the effect depends on it alone: a + // strip that is not drawn drops it, and a later selection of this pane never replays it. useLayoutEffect(() => { - if (renameRequest === lastRenameRequest.current) return; - lastRenameRequest.current = renameRequest; + if (renameRequest === null) return; const tab = tabs.find((candidate) => candidate.tab_id === selectedPane.tab_id); - if (renameRequest > 0 && tab && shown && !(cramped && tabs.length === 1)) beginRename(tab); + if (tab && shown && !loneTabHidden(cramped, tabs.length)) beginRename(tab); + onRenameHandled?.(); }, [renameRequest]); - if (!shown || (cramped && tabs.length === 1)) return null; + if (!shown || loneTabHidden(cramped, tabs.length)) return null; const panesOf = (tab: HerdrTab): PaneInfo[] => panes.filter((pane) => pane.tab_id === tab.tab_id); const paneFor = (tab: HerdrTab): PaneInfo | undefined => { diff --git a/src/lib/paneCanvas.ts b/src/lib/paneCanvas.ts index 0fc93c831a..1280569127 100644 --- a/src/lib/paneCanvas.ts +++ b/src/lib/paneCanvas.ts @@ -4,6 +4,11 @@ type PaneLayoutSplit = PaneLayoutSnapshot["splits"][number]; /** Where a split has no room: a tab's panes are shown one at a time (PaneCanvas) and picked from the tab's menu (TabStrip). */ export const SOLO_PANE_QUERY = "(max-width: 768px)"; +/** A short phone screen (landscape, a small phone): a lone tab is not drawn there (TabStrip). */ +export const CRAMPED_QUERY = "(max-width: 768px) and (max-height: 600px)"; +/** Whether the tab strip is left out of a workspace's view: a lone tab on a cramped screen. */ +export const loneTabHidden = (cramped: boolean, tabCount: number): boolean => cramped && tabCount === 1; + /** * The pane a tab shows alone, or null when every pane is drawn: herdr's zoomed pane, else on a * narrow screen the selected pane (herdr's focused one, then the first, while the selection is diff --git a/src/lib/shortcuts.test.ts b/src/lib/shortcuts.test.ts index 2280c1591c..d8d12db633 100644 --- a/src/lib/shortcuts.test.ts +++ b/src/lib/shortcuts.test.ts @@ -216,8 +216,10 @@ describe("new tab and split pane", () => { expect(shortcutDisplayKeys(id, {})).toEqual([]); } expect(matchShortcut(keyEvent("t", { ctrlKey: true }), false)).toBeNull(); - const overrides = sanitizeShortcutOverrides({ "new-tab": "y", "split-right": "l", "split-down": "h" }); - expect(overrides).toEqual({ "new-tab": "y", "split-right": "l", "split-down": "h" }); + const overrides = sanitizeShortcutOverrides({ "new-tab": "y", "rename-tab": "r", "split-right": "l", "split-down": "h" }); + expect(overrides).toEqual({ "new-tab": "y", "rename-tab": "r", "split-right": "l", "split-down": "h" }); + expect(matchShortcut(keyEvent("R", { ctrlKey: true }), false, overrides)).toBe("rename-tab"); + expect(shortcutDisplayKeys("rename-tab", overrides)).toEqual(["Mod", "Shift", "R"]); expect(matchShortcut(keyEvent("Y", { ctrlKey: true }), false, overrides)).toBe("new-tab"); expect(matchShortcut(keyEvent("l", { metaKey: true }), true, overrides)).toBe("split-right"); expect(matchShortcut(keyEvent("h", { ctrlKey: true }), false, overrides)).toBe("split-down"); @@ -225,14 +227,6 @@ describe("new tab and split pane", () => { expect(shortcutConflict("split-down", ["y"], overrides)).toBe(true); expect(shortcutConflict("new-tab", ["k"], {})).toBe(true); }); - - it("binds Rename tab to a chosen key", () => { - const overrides = sanitizeShortcutOverrides({ "rename-tab": "r" }); - expect(overrides).toEqual({ "rename-tab": "r" }); - expect(matchShortcut(keyEvent("R", { ctrlKey: true }), false, overrides)).toBe("rename-tab"); - expect(shortcutDisplayKeys("rename-tab", overrides)).toEqual(["Mod", "Shift", "R"]); - expect(shortcutConflict("rename-tab", ["r"], { "new-tab": "r" })).toBe(true); - }); }); describe("exact-modifier bindings", () => { diff --git a/src/lib/useRenameRequest.ts b/src/lib/useRenameRequest.ts new file mode 100644 index 0000000000..3978deacf1 --- /dev/null +++ b/src/lib/useRenameRequest.ts @@ -0,0 +1,16 @@ +import { useCallback, useState } from "react"; + +/** The pane whose tab should open its name field: one press of the Rename tab shortcut or palette action. */ +export interface RenameRequest { machineId: string; paneId: string } + +/** + * A one-shot request from the app to the tab strip: the strip acts on it and calls `clear`, so it + * never outlives the press that made it (a strip that stays mounted across selections would + * otherwise open the field again when its pane is selected later). + */ +export function useRenameRequest() { + const [renameRequest, setRenameRequest] = useState(null); + const requestRename = useCallback((machineId: string, paneId: string) => setRenameRequest({ machineId, paneId }), []); + const clearRenameRequest = useCallback(() => setRenameRequest(null), []); + return { renameRequest, requestRename, clearRenameRequest }; +} From ee43c902551692a2033247990caab591f7163692 Mon Sep 17 00:00:00 2001 From: mp Date: Sun, 25 Oct 2026 00:10:33 +0900 Subject: [PATCH 3/6] test(tabs): record the rename-tab chord on a key the browser leaves alone --- scripts/shortcut-exact-modifiers-demo-regression.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/shortcut-exact-modifiers-demo-regression.ts b/scripts/shortcut-exact-modifiers-demo-regression.ts index 26fe66a46a..ff7b7cecc8 100644 --- a/scripts/shortcut-exact-modifiers-demo-regression.ts +++ b/scripts/shortcut-exact-modifiers-demo-regression.ts @@ -125,12 +125,12 @@ try { // Rename tab has no default key: a recorded chord opens the open tab's name field, Escape cancels, Enter renames await hungarianE(); await openSettingsPage(page, "Shortcuts"); - await record(page, "Rename tab", "Meta+Alt+KeyR"); - assert.equal((await stored(page))["rename-tab"], "alt+meta+r"); + const recorded = await record(page, "Rename tab", "Meta+Alt+KeyJ"); + assert.equal((await stored(page))["rename-tab"], "alt+meta+j", recorded); await page.getByRole("button", { name: "Close settings", exact: true }).click(); const tabName = page.locator(".tab-strip-item.is-active .tab-strip-rename"); const tabLabel = (await page.locator('.tab-strip-item.is-active [role="tab"]').textContent()) ?? ""; - await page.keyboard.press("Meta+Alt+KeyR"); + await page.keyboard.press("Meta+Alt+KeyJ"); await tabName.waitFor(); assert.equal(await tabName.evaluate((input) => input === document.activeElement), true, "the name field has the focus"); if (screenshotDir) await page.screenshot({ path: join(screenshotDir, "rename-tab-shortcut.png") }); @@ -145,7 +145,7 @@ try { await page.keyboard.press("Meta+Shift+ArrowUp"); await page.waitForFunction((before) => (document.querySelector('.tab-strip-item.is-active [role="tab"]')?.dataset["tabId"] ?? null) === before, firstTab); assert.equal(await page.locator(".tab-strip-rename").count(), 0, "coming back to the pane does not reopen the name field"); - await page.keyboard.press("Meta+Alt+KeyR"); + await page.keyboard.press("Meta+Alt+KeyJ"); await tabName.waitFor(); await page.keyboard.type("Renamed by shortcut"); await page.keyboard.press("Enter"); From 5966e702d00a1c5e8fa49d0c9ebbf0cd34aac18c Mon Sep 17 00:00:00 2001 From: mp Date: Sun, 25 Oct 2026 00:30:30 +0900 Subject: [PATCH 4/6] test(tabs): bind the rename-tab chord as Mod+Shift+Y --- scripts/shortcut-exact-modifiers-demo-regression.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/shortcut-exact-modifiers-demo-regression.ts b/scripts/shortcut-exact-modifiers-demo-regression.ts index ff7b7cecc8..08c948674c 100644 --- a/scripts/shortcut-exact-modifiers-demo-regression.ts +++ b/scripts/shortcut-exact-modifiers-demo-regression.ts @@ -125,12 +125,12 @@ try { // Rename tab has no default key: a recorded chord opens the open tab's name field, Escape cancels, Enter renames await hungarianE(); await openSettingsPage(page, "Shortcuts"); - const recorded = await record(page, "Rename tab", "Meta+Alt+KeyJ"); - assert.equal((await stored(page))["rename-tab"], "alt+meta+j", recorded); + const recorded = await record(page, "Rename tab", "Meta+Shift+KeyY"); + assert.equal((await stored(page))["rename-tab"], "y", recorded); await page.getByRole("button", { name: "Close settings", exact: true }).click(); const tabName = page.locator(".tab-strip-item.is-active .tab-strip-rename"); const tabLabel = (await page.locator('.tab-strip-item.is-active [role="tab"]').textContent()) ?? ""; - await page.keyboard.press("Meta+Alt+KeyJ"); + await page.keyboard.press("Meta+Shift+KeyY"); await tabName.waitFor(); assert.equal(await tabName.evaluate((input) => input === document.activeElement), true, "the name field has the focus"); if (screenshotDir) await page.screenshot({ path: join(screenshotDir, "rename-tab-shortcut.png") }); @@ -145,7 +145,7 @@ try { await page.keyboard.press("Meta+Shift+ArrowUp"); await page.waitForFunction((before) => (document.querySelector('.tab-strip-item.is-active [role="tab"]')?.dataset["tabId"] ?? null) === before, firstTab); assert.equal(await page.locator(".tab-strip-rename").count(), 0, "coming back to the pane does not reopen the name field"); - await page.keyboard.press("Meta+Alt+KeyJ"); + await page.keyboard.press("Meta+Shift+KeyY"); await tabName.waitFor(); await page.keyboard.type("Renamed by shortcut"); await page.keyboard.press("Enter"); From 30705c3e5ddb0cc6f4c5ddab1253fee0e6a4c4a6 Mon Sep 17 00:00:00 2001 From: mp Date: Mon, 26 Oct 2026 17:49:48 +0900 Subject: [PATCH 5/6] fix(tabs): drop a rename request no tab strip took up, and keep typed text on a second press --- scripts/shortcut-exact-modifiers-demo-regression.ts | 2 ++ src/App.tsx | 8 +++++--- src/components/CommandPalette.tsx | 2 +- src/components/TabStrip.tsx | 3 ++- src/lib/useRenameRequest.ts | 8 +++++--- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/scripts/shortcut-exact-modifiers-demo-regression.ts b/scripts/shortcut-exact-modifiers-demo-regression.ts index 08c948674c..8d315bd1e1 100644 --- a/scripts/shortcut-exact-modifiers-demo-regression.ts +++ b/scripts/shortcut-exact-modifiers-demo-regression.ts @@ -148,6 +148,8 @@ try { await page.keyboard.press("Meta+Shift+KeyY"); await tabName.waitFor(); await page.keyboard.type("Renamed by shortcut"); + // a second press while the field is open keeps what was typed + await page.keyboard.press("Meta+Shift+KeyY"); await page.keyboard.press("Enter"); await tabName.waitFor({ state: "detached" }); assert.match((await page.locator('.tab-strip-item.is-active [role="tab"]').textContent()) ?? "", /Renamed by shortcut/, "Enter renames the tab"); diff --git a/src/App.tsx b/src/App.tsx index d664a41e39..1adb018c68 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -189,7 +189,7 @@ export function App() { const paneControls = useRef(null); const [findRequest, setFindRequest] = useState<{ paneId: string | null; serial: number }>({ paneId: null, serial: 0 }); // the Rename tab shortcut and palette action: the tab strip of the selected pane opens its name field - const { renameRequest, requestRename, clearRenameRequest } = useRenameRequest(); + const { renameRequest, requestRename, clearRenameRequest } = useRenameRequest(selectedMachineId, selectedPaneId); // the header's More menu: its button const [more, setMore] = useState<{ anchor: HTMLElement } | null>(null); const closeMore = useCallback(() => setMore(null), []); @@ -292,6 +292,8 @@ export function App() { const { selectedPane } = useSelectedPane({ snapshot, selectedPaneId }); useScreenWakeLock(settings.keepScreenOn && locked === false && selectedPane !== null); const { selectedWorkspace, presentedPaneId } = useSelectedLayout({ selectedPane, snapshot, drawerOpen, selectedMachineId, selectedPaneId, setSelectedPaneId, setAutoSelected }); + // the selected pane has a tab strip to rename in: its pane and workspace are in the roster + const tabStripMounted = selectedPane !== null && selectedWorkspace !== null; const targetHerdr = selectedMachineId === "local" ? health?.herdr : selectedMachine?.herdr; // the herdr the header names as current: this PC's last answer only while it is still shown (through one // failed read, not once unavailable), though its capabilities stay in targetHerdr @@ -389,7 +391,7 @@ export function App() { setNewSessionOpen(true); }, renameTab: () => { - if (selectedPaneId === null) return; + if (selectedPaneId === null || !tabStripMounted) return; setDrawerOpen(false); requestRename(selectedMachineId, selectedPaneId); }, @@ -493,7 +495,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, terminalPeek, endPeek, updateSettings, resolvedTheme, canSignOut, lock, bellVisible, bell.run, enableNotifications, load, layoutRefused, moveRefused, lenses], + [selectPane, selectedPaneId, selectedMachineId, tabStripMounted, setView, view, terminalPeek, endPeek, updateSettings, resolvedTheme, canSignOut, lock, bellVisible, bell.run, enableNotifications, load, layoutRefused, moveRefused, lenses], ); useShortcuts(actions, locked === false); diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 8c13e65514..8b9eb35e57 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -301,7 +301,7 @@ export function CommandPalette({ open, initialQuery = "", onClose, machines, onS const browseStart = selectedPane && panePath(selectedPane) ? panePath(selectedPane) : null; // Rename tab opens the tab strip's name field: not offered where the strip is left out (a lone tab on a cramped screen) const tabCount = selectedPane ? snapshot?.tabs.filter((tab) => tab.workspace_id === selectedPane.workspace_id).length ?? 0 : 0; - const stripDrawn = !loneTabHidden(cramped, tabCount); + const stripDrawn = selectedPane !== undefined && !loneTabHidden(cramped, tabCount); const paletteActions = useMemo(() => [ { id: "new", label: t("New workspace"), icon: MessageSquarePlus, shortcut: "new-session", run: actions.openNewSession }, diff --git a/src/components/TabStrip.tsx b/src/components/TabStrip.tsx index cdf1b83f12..fbb43b1c53 100644 --- a/src/components/TabStrip.tsx +++ b/src/components/TabStrip.tsx @@ -224,7 +224,8 @@ export function TabStrip({ snapshot, workspace, selectedPane, onSelectPane, onNe useLayoutEffect(() => { if (renameRequest === null) return; const tab = tabs.find((candidate) => candidate.tab_id === selectedPane.tab_id); - if (tab && shown && !loneTabHidden(cramped, tabs.length)) beginRename(tab); + // a second press while the field is open keeps what was typed in it + if (tab && shown && !loneTabHidden(cramped, tabs.length) && editing?.tabId !== tab.tab_id) beginRename(tab); onRenameHandled?.(); }, [renameRequest]); diff --git a/src/lib/useRenameRequest.ts b/src/lib/useRenameRequest.ts index 3978deacf1..07ec39b5c3 100644 --- a/src/lib/useRenameRequest.ts +++ b/src/lib/useRenameRequest.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; /** The pane whose tab should open its name field: one press of the Rename tab shortcut or palette action. */ export interface RenameRequest { machineId: string; paneId: string } @@ -6,10 +6,12 @@ export interface RenameRequest { machineId: string; paneId: string } /** * A one-shot request from the app to the tab strip: the strip acts on it and calls `clear`, so it * never outlives the press that made it (a strip that stays mounted across selections would - * otherwise open the field again when its pane is selected later). + * otherwise open the field again when its pane is selected later). A request no strip took up (its + * half crashed) is dropped once another pane is selected, so coming back does not replay it. */ -export function useRenameRequest() { +export function useRenameRequest(selectedMachineId: string, selectedPaneId: string | null) { const [renameRequest, setRenameRequest] = useState(null); + useEffect(() => setRenameRequest(null), [selectedMachineId, selectedPaneId]); const requestRename = useCallback((machineId: string, paneId: string) => setRenameRequest({ machineId, paneId }), []); const clearRenameRequest = useCallback(() => setRenameRequest(null), []); return { renameRequest, requestRename, clearRenameRequest }; From be1f8c8253f3c58907d14cff97aaadce1149bd11 Mon Sep 17 00:00:00 2001 From: mp Date: Mon, 26 Oct 2026 17:49:49 +0900 Subject: [PATCH 6/6] docs(design): note Rename tab's palette action and shortcut --- DESIGN.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DESIGN.md b/DESIGN.md index c931591c84..37e7811fa1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -936,7 +936,9 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`. split tab is not offered it. The view follows the pane there, in whichever half of a split shows it, and this browser's unsent draft for it goes along (herdr gives the pane a new ID). The palette has the same move as one Move tab to {workspace} row per workspace, found by a query - only, as Settings pages are. + only, as Settings pages are. Rename tab is also a palette action and a rebindable shortcut with + no default key, as herdr's own `rename_tab` binding: it opens the same name field on the selected + pane's tab, and is not offered where the strip is left out (a lone tab on a cramped screen). The `⋯` beside the selected tab holds the same tab actions (the strip's `+` opens a shell tab, so New tab with agent… stays here) and also carries the open pane's actions, less Split and Zoom where a phone shows one pane at a time. Touch and narrow layouts keep that `⋯` in view; a desktop with a mouse (`hover: hover`,