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: 4 additions & 2 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ Enter to switch, Esc to cancel. Press K to switch to search mode.
### Pane Search

`Alt+K` searches **every** live pane by workspace, tab, cwd, or agent
name/status. All terms must match, in any order. Enter switches; `Alt+K` or Esc
closes.
name/status. Agents on the other ready servers follow, attention first, and
every row shows its server, which is searchable too. All terms must match, in
any order. Enter switches, opening another server's agent on its server;
`Alt+K` or Esc closes.

## Full Browser Terminal

Expand Down
104 changes: 76 additions & 28 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
MessagesSquare,
Minimize2,
PanelTop,
Search,
SquareTerminal,
} from "lucide-react";
import {
Expand Down Expand Up @@ -133,9 +134,16 @@ import {
activePaneIdForSnapshot,
type PaneJumpEntry,
paneJumpEntries,
paneJumpEntryKey,
paneJumpTargetId,
paneSearchEntries,
serverPaneSearchEntries,
} from "./paneJump";
import { sortAgentPanes } from "./agentOrder";
import {
openServerSession,
useAllServerAgentSessions,
} from "./useAllServerAgentSessions";
import {
type Notice,
noticeAutoDismissDelay,
Expand Down Expand Up @@ -707,7 +715,10 @@ function PaneJumpOverlay({
const selectedItemRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const searching = search !== null;
const selectedPaneId = entries[selectedIndex]?.paneId;
const selectedEntry = entries[selectedIndex];
const selectedPaneId = selectedEntry
? paneJumpEntryKey(selectedEntry)
: undefined;

useEffect(() => {
selectedItemRef.current?.scrollIntoView({ block: "nearest" });
Expand All @@ -730,27 +741,30 @@ function PaneJumpOverlay({
</span>
</div>
{searching ? (
<input
ref={searchInputRef}
className="pane-jump-search"
type="text"
value={search}
spellCheck={false}
autoComplete="off"
placeholder="Workspace, tab, directory, or agent"
role="combobox"
aria-label="Search panes"
aria-expanded={true}
aria-autocomplete="list"
aria-controls={listId}
aria-activedescendant={
selectedPaneId
? `${listId}-${encodeURIComponent(selectedPaneId)}`
: undefined
}
onChange={(event) => onSearchChange(event.target.value)}
onBlur={() => onClose()}
/>
<div className="pane-jump-search-field">
<Search size={15} aria-hidden="true" />
<input
ref={searchInputRef}
className="pane-jump-search"
type="text"
value={search}
spellCheck={false}
autoComplete="off"
placeholder="Workspace, tab, server, directory, or agent"
role="combobox"
aria-label="Search panes"
aria-expanded={true}
aria-autocomplete="list"
aria-controls={listId}
aria-activedescendant={
selectedPaneId
? `${listId}-${encodeURIComponent(selectedPaneId)}`
: undefined
}
onChange={(event) => onSearchChange(event.target.value)}
onBlur={() => onClose()}
/>
</div>
) : null}
{entries.length === 0 ? (
<p className="pane-jump-empty" role="status">
Expand All @@ -765,8 +779,8 @@ function PaneJumpOverlay({
>
{entries.map((entry, index) => (
<button
key={entry.paneId}
id={`${listId}-${encodeURIComponent(entry.paneId)}`}
key={paneJumpEntryKey(entry)}
id={`${listId}-${encodeURIComponent(paneJumpEntryKey(entry))}`}
ref={index === selectedIndex ? selectedItemRef : undefined}
type="button"
tabIndex={-1}
Expand All @@ -790,6 +804,11 @@ function PaneJumpOverlay({
<span className="pane-jump-text">
<span className="pane-jump-title-line">
<strong>{entry.title}</strong>
{entry.serverLabel ? (
<span className="pane-jump-server-badge">
{entry.serverLabel}
</span>
) : null}
{entry.current ? (
<span className="pane-jump-current-badge">Current</span>
) : null}
Expand Down Expand Up @@ -1371,6 +1390,10 @@ export default function App() {
const [paneJumpIndex, setPaneJumpIndex] = useState(0);
// null keeps the most-recently-used list; a string switches to typed search.
const [paneJumpSearch, setPaneJumpSearch] = useState<string | null>(null);
// Pane search also lists agents on the other ready servers.
const searchServerPanes = useAllServerAgentSessions(
paneJumpSearch !== null,
).panes;
const paneJumpModifierRef = useRef<"ctrlKey" | "altKey" | "metaKey" | null>(
null,
);
Expand Down Expand Up @@ -1525,12 +1548,34 @@ export default function App() {
tabs: s.tabs,
workspaces: s.workspaces,
};
return paneJumpSearch === null
? paneJumpEntries(snapshot, activePaneId)
: paneSearchEntries(snapshot, paneJumpSearch, activePaneId);
if (paneJumpSearch === null) return paneJumpEntries(snapshot, activePaneId);
const multiServer = searchServerPanes.some(
(pane) => pane.connectionId !== s.activeConnectionId,
);
const activeServerLabel = multiServer
? s.connections.find(
(connection) => connection.id === s.activeConnectionId,
)?.label
: undefined;
return [
...paneSearchEntries(
snapshot,
paneJumpSearch,
activePaneId,
activeServerLabel,
),
...serverPaneSearchEntries(
sortAgentPanes(searchServerPanes, [], "attention"),
paneJumpSearch,
s.activeConnectionId,
),
];
}, [
activePaneId,
paneJumpSearch,
searchServerPanes,
s.activeConnectionId,
s.connections,
s.layout,
s.panes,
s.recentPaneIds,
Expand Down Expand Up @@ -2754,10 +2799,13 @@ export default function App() {
const commitPaneJump = useCallback(
(index = paneJumpIndexRef.current) => {
const targetPaneId = paneJumpTargetId(paneJumpOptions, index);
const source = paneJumpOptions[index]?.source;
closePaneJump(!targetPaneId);
if (!targetPaneId) return;
if (!inspectorStateRef.current?.open) setMobileView("session");
void store.focusPane(targetPaneId);
if (source)
void openServerSession(source, source.workspaceId, targetPaneId);
else void store.focusPane(targetPaneId);
},
[closePaneJump, paneJumpOptions],
);
Expand Down
68 changes: 68 additions & 0 deletions web/src/paneJump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import {
activePaneIdForSnapshot,
paneCanClose,
paneJumpEntries,
paneJumpEntryKey,
paneJumpTargetId,
paneSearchEntries,
serverPaneSearchEntries,
} from "./paneJump";
import type { Pane, Tab, Workspace } from "./types";
import type { ServerAgentPane } from "./useAllServerAgentSessions";

function workspace(workspaceId: string, label: string): Workspace {
return {
Expand Down Expand Up @@ -268,3 +271,68 @@ describe("pane search projection", () => {
expect(entries[1].current).toBe(false);
});
});

describe("cross-server pane search", () => {
function serverPane(
connectionId: string,
serverLabel: string,
paneId: string,
workspaceLabel: string,
): ServerAgentPane {
return {
...pane(paneId, "w1", "t1", "claude"),
connectionId,
runtimeGeneration: 3,
serverLabel,
workspaceKey: JSON.stringify([connectionId, 3, "w1"]),
workspaceLabel,
tabLabel: "2026-10-27",
};
}
const panes = [
serverPane("local", "Local", "p1", "rozkminy"),
serverPane("box", "agentbox", "p1", "linkediner"),
serverPane("box", "agentbox", "p2", "ranger"),
];

test("skips the active server and keeps the given order", () => {
const entries = serverPaneSearchEntries(panes, "", "local");

expect(entries.map((entry) => entry.title)).toEqual([
"linkediner",
"ranger",
]);
expect(entries[0]).toMatchObject({
serverLabel: "agentbox",
tabLabel: "2026-10-27",
current: false,
source: { connectionId: "box", runtimeGeneration: 3, workspaceId: "w1" },
});
});

test("matches server, workspace and tab terms", () => {
const titles = (query: string) =>
serverPaneSearchEntries(panes, query, "local").map(
(entry) => entry.title,
);

expect(titles("agentbox rang")).toEqual(["ranger"]);
expect(titles("2026-10-27 linked")).toEqual(["linkediner"]);
expect(titles("rozkminy")).toEqual([]);
});

test("labels active-server entries and keeps keys unique across servers", () => {
const snapshot = {
panes: [pane("p1", "w1", "t1", "claude")],
recentPaneIds: [],
tabs: [tab("t1", "w1")],
workspaces: [workspace("w1", "rozkminy")],
};
const local = paneSearchEntries(snapshot, "local", null, "Local");
const remote = serverPaneSearchEntries(panes, "", "local");

expect(local.map((entry) => entry.serverLabel)).toEqual(["Local"]);
const keys = [...local, ...remote].map(paneJumpEntryKey);
expect(new Set(keys).size).toBe(keys.length);
});
});
61 changes: 61 additions & 0 deletions web/src/paneJump.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { normalizeSearchText } from "./searchText";
import { shortId } from "./utils";
import type { Pane, PaneLayout, Tab, Workspace } from "./types";
import type {
ServerAgentPane,
ServerSessionSource,
} from "./useAllServerAgentSessions";

export type PaneJumpEntry = {
paneId: string;
Expand All @@ -11,8 +15,19 @@ export type PaneJumpEntry = {
agent?: string;
agentStatus?: string;
current: boolean;
/** Set when results span several servers. */
serverLabel?: string;
/** Set for panes that live on a server other than the active one. */
source?: ServerSessionSource & { workspaceId: string };
};

/** Pane IDs are only unique per server, so remote entries add the server. */
export function paneJumpEntryKey(entry: PaneJumpEntry): string {
return entry.source
? JSON.stringify([entry.source.connectionId, entry.paneId])
: entry.paneId;
}

type ActivePaneSnapshot = {
selectedPaneId?: string | null;
layout?: Pick<PaneLayout, "panes" | "focused_pane_id"> | null;
Expand Down Expand Up @@ -70,8 +85,53 @@ export function paneSearchEntries(
snapshot: PaneJumpSnapshot,
query: string,
activePaneId?: string | null,
serverLabel?: string,
): PaneJumpEntry[] {
const entries = orderedPaneJumpEntries(snapshot, activePaneId, true);
return filterPaneSearchEntries(
serverLabel ? entries.map((entry) => ({ ...entry, serverLabel })) : entries,
query,
);
}

/**
* Agent panes from the other ready servers, for the same search. The caller
* passes them already ordered; panes of the active server are skipped because
* the live snapshot covers them.
*/
export function serverPaneSearchEntries(
panes: readonly ServerAgentPane[],
query: string,
activeConnectionId: string | null | undefined,
): PaneJumpEntry[] {
return filterPaneSearchEntries(
panes
.filter((pane) => pane.connectionId !== activeConnectionId)
.map((pane) => ({
paneId: pane.pane_id,
paneLabel: `Pane ${shortId(pane.pane_id)}`,
title: pane.workspaceLabel,
tabLabel: pane.tabLabel || pane.tab_id,
cwd: pane.foreground_cwd ?? pane.cwd ?? "",
agent: pane.agent,
agentStatus: pane.agent ? pane.agent_status : undefined,
current: false,
serverLabel: pane.serverLabel,
source: {
connectionId: pane.connectionId,
runtimeGeneration: pane.runtimeGeneration,
serverLabel: pane.serverLabel,
workspaceId: pane.workspace_id,
},
})),
query,
);
}

function filterPaneSearchEntries(
entries: PaneJumpEntry[],
query: string,
): PaneJumpEntry[] {
const tokens = normalizeSearchText(query).split(" ").filter(Boolean);
if (tokens.length === 0) return entries;
return entries.filter((entry) => {
Expand Down Expand Up @@ -118,6 +178,7 @@ function orderedPaneJumpEntries(
function paneSearchValue(entry: PaneJumpEntry): string {
return [
entry.title,
entry.serverLabel,
entry.tabLabel,
entry.cwd,
entry.paneLabel,
Expand Down
Loading
Loading