Add Cmd-J tab session search (#5775)

* Add Cmd-J tab session search

References docs/cmd-j-tab-session-search.md for the design.

* Support searching and activating all editor-family tab types

- Index and search editor, diff, conflict-review, and check-details tabs when their backing files are open.
- Deduplicate tab activation logic to ensure exact unified tab IDs are targeted correctly.
- Update palette placeholder texts and localized labels to use "tab title" and "agent prompt" instead of "page title" and "emulator".
- Localize missing tab error strings in Spanish, Japanese, Korean, and Chinese locales.
- Ensure cross-platform path compatibility in search tests using path.join.
This commit is contained in:
Jinjing 2026-06-19 00:27:05 -07:00 committed by GitHub
parent 238476f441
commit b5b018cfd1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1906 additions and 17 deletions

View File

@ -0,0 +1,141 @@
# Cmd-J Tab and Agent Session Search
## Problem
Cmd-J can already search worktrees, settings, actions, browser pages, and simulator tabs, but not ordinary open terminal/editor tabs or their agent-session context. The existing open-tab list only combines browser and simulator matches in `WorktreeJumpPalette.tsx` (`browserItems`, `simulatorItems`, `openTabItems`). Terminal/editor activation exists elsewhere, notably the shortcut activation path in `src/renderer/src/lib/tab-number-shortcuts.ts`, but Cmd-J has no searchable row for those tab kinds. Agent prompt/session metadata is structured and bounded in `AgentStatusEntry`, `RetainedAgentEntry`, and `SleepingAgentSessionRecord`, yet Cmd-J does not index it.
## Goal
Let users open the existing Worktree Palette shortcut (`Cmd+J` on macOS, `Ctrl+Shift+J` on Windows/Linux by default) and type keywords from:
1. Open terminal and editor tab titles.
2. Live or retained agent prompts/session ids associated with terminal panes.
3. Sleeping agent session prompt/title/session-id metadata only when it can be attributed to an existing terminal tab.
4. Worktree and repo metadata, matching the current browser/simulator tab behavior.
Selecting a matched tab should activate the owning worktree, split group, and tab, then focus the right surface for terminal/editor tabs.
## Non-goals
- Do not index terminal scrollback, file contents, or full assistant messages.
- Do not add a new global search backend, IPC channel, persistence field, or database.
- Do not change Cmd-J shortcut registration or platform key labels.
- Do not alter worktree/settings/action ranking semantics outside combining the new tab result list.
- Do not support launching/resuming a sleeping agent session from the result row; this feature only navigates to existing open tabs.
## Design
1. Add a focused tab-search helper, likely `src/renderer/src/lib/workspace-tab-palette-search.ts`, modeled after `simulator-palette-search.ts`. It should build searchable entries from `unifiedTabsByWorktree` for terminal tabs and editor-family tabs (`editor`, `diff`, `conflict-review`, `check-details`; markdown preview is an `editor` tab whose `OpenFile.mode` is `markdown-preview`). Do not fold browser/simulator into this helper unless tests prove their current scoring, empty-query comparators, rendering data, and activation behavior are unchanged.
2. Resolve displayed titles through existing sources:
- Terminal: map the unified terminal tab's `entityId` to `tabsByWorktree[worktreeId]`, then use `resolveTerminalTabTitle` from `src/shared/tab-title-resolution.ts:3` with `settings.tabAutoGenerateTitle`.
- Editor-family tabs: map the unified tab's `entityId` to `openFiles`, then use `getEditorDisplayLabel`, with relative path/full path as secondary searchable text. If the backing `OpenFile` is missing, do not create a searchable editor row; `setActiveFile` cannot safely restore it.
- Terminal fallback: use `resolveUnifiedTabLabel` from `src/shared/tab-title-resolution.ts:17` only when a terminal's legacy `TerminalTab` record is missing but the unified terminal tab still exists.
3. Add agent-session keywords only from bounded structured state:
- `agentStatusByPaneKey`: `prompt`, `agentType`, `state`, `providerSession.key/id`, `terminalTitle`, and capped `stateHistory[].prompt`.
- `retainedAgentsByPaneKey`: the retained `entry` fields above plus the retained terminal tab title snapshot.
- `sleepingAgentSessionsByPaneKey`: `prompt`, `agent`, `providerSession.key/id`, `state`, and `terminalTitle`.
Attach metadata to a terminal row only when it matches that terminal by explicit `tabId`, by retained `tab.id`, or by pane-key prefix `${terminalTabId}:`, where `terminalTabId` is the legacy terminal id (`unifiedTab.entityId`). The worktree must also match when the record carries one. Do not include terminal scrollback, full assistant messages, `lastAssistantMessage`, `toolName`, or `toolInput`. Keep rendered snippets trimmed/capped even though hook payloads and history length are already bounded.
4. Rank with predictable field weights:
- Displayed tab title: highest priority. Terminal title precedence must match `resolveTerminalTabTitle`: custom title, quick-command label, generated title only when enabled, raw title, then fallback. Editor title precedence must match `getEditorDisplayLabel`.
- Agent prompt/session metadata: next, shown as supporting text when it caused the match.
- Worktree name and repo name: lower priority, matching browser/simulator ordering.
- Empty query: preserve current behavior by showing open tab rows. Existing browser/simulator helpers compute context-first scores, but `WorktreeJumpPalette` currently merges all open-tab item types by `result.score` and then item id; do not replace that final merge comparator unless tests deliberately cover the browser/simulator ordering change. New terminal/editor rows should encode deterministic context-first ordering in their scores: current tab, current worktree, worktree order, then group/tab order. Browser/simulator already index across all worktrees, including archived/default-hidden worktrees; terminal/editor rows may follow that open-tab behavior, but only for rows backed by existing unified tabs.
5. Integrate in `WorktreeJumpPalette.tsx` with a small new `WorkspaceTabPaletteItem` type and helper import. Add explicit store selectors for the new inputs (`openFiles`, `retainedAgentsByPaneKey`, `sleepingAgentSessionsByPaneKey`, `activeTabId`, `activeTabIdByWorktree`, `activeFileId`, `activeFileIdByWorktree`, and `activeTabTypeByWorktree` for current-row detection). Replace `openTabItems` with browser + simulator + workspace tab items sorted by the existing combined open-tab ordering, keeping the existing `OPEN TABS` section and caps.
6. Add a generic tab activation helper, likely `src/renderer/src/lib/workspace-tab-palette-activation.ts`, that mirrors `activateTabNumberShortcut` (`src/renderer/src/lib/tab-number-shortcuts.ts:57`) and existing simulator selection (`src/renderer/src/components/WorktreeJumpPalette.tsx:1090`). Re-resolve the target from `useAppStore.getState()` at selection time before mutating state:
- `activateAndRevealWorktree(worktreeId)`.
- Verify the target worktree, group, and unified tab still exist; the tab must still have the expected content type and still belong to the target worktree/group. Return with the same toast pattern before mutating state if any of those checks fail.
- Terminal: activate web runtime session when needed using `getRuntimeEnvironmentIdForWorktree`, `isWebRuntimeSessionActive`, and `activateWebRuntimeSessionTab`; set `activeTab` to terminal `entityId`; set active type to `terminal`; then `focusTerminalTabSurface(entityId)`.
- Editor-family tabs: verify `openFiles` still contains `entityId`; focus the target group, set active file to `entityId`, activate the unified tab id, then set active type to `editor`. Activating after `setActiveFile` preserves the specific split tab and is required for `check-details`, which `setActiveFile` does not implicitly re-find.
- Simulator/browser behavior should remain unchanged unless the shared helper explicitly preserves existing semantics.
7. Render terminal/editor rows with the existing open-tab row density and tokens near `src/renderer/src/components/WorktreeJumpPalette.tsx:1697`: icon, highlighted title, current-tab/current-worktree chip, supporting text, worktree, host badge, and repo badge. Use existing icons (`SquareTerminal`, `FileText` or file-type icon if cheap and already available). Update the no-results subtitle at `src/renderer/src/components/WorktreeJumpPalette.tsx:1385` to include tab title/agent prompt without making it verbose.
8. Keep generated lists computed in `useMemo`; all required source data already exists in the renderer store, but `WorktreeJumpPalette` must subscribe to the slices it does not currently read. No new IPC, polling, filesystem reads, persistence fields, or shared mutable cache are needed. Search work should stay proportional to open unified tabs plus the small in-memory agent maps.
## Data flow
- Store slices expose `unifiedTabsByWorktree`, `tabsByWorktree`, `openFiles`, worktrees/repos, live agent statuses, retained agents, sleeping sessions, active group ids, and active terminal/editor ids.
- `buildSearchableWorkspaceTabs(...)` produces one entry per open terminal/editor tab with resolved labels and bounded agent keywords.
- `searchWorkspaceTabs(entries, query)` returns scored/highlighted results.
- `WorktreeJumpPalette` maps results to open-tab rows.
- User selects a row.
- Selection re-resolves the target from the live store, activates the owning worktree/group/tab, and focuses terminal/editor as appropriate.
## Edge cases
- Custom title, quick-command label, generated title, and raw title should follow the same precedence as the tab bar.
- Generated titles disabled: do not make generated terminal/unified labels the displayed title or a title-weighted match. It is acceptable to search bounded agent prompt/session metadata regardless of this setting.
- Split groups: activate the result's `groupId`, not just the worktree's last active group.
- Terminal unified tab missing its legacy terminal record: still show a fallback title from the unified tab and navigate if the unified tab exists.
- Editor-family unified tab missing its `OpenFile`: omit the row and treat selection as stale if it disappears after search.
- Markdown preview tabs: include them through `contentType: 'editor'` and `getEditorDisplayLabel`; do not look for a separate unified content type.
- Current-row detection is type-specific: terminal active ids are legacy terminal ids, editor active ids are file ids, while unified group state stores unified tab ids.
- Agent pane keys are composite `${tabId}:${leafId}`; only attach agent metadata when it is attributed to the result tab by explicit `tabId`, retained `tab.id`, or pane-key prefix and does not conflict with record `worktreeId`.
- Multiple agent panes in one terminal tab: include all bounded agent prompts/metadata, but show only the best matching supporting snippet.
- Sleeping sessions: search metadata only when tied to an existing terminal tab; selecting the row must navigate to that tab and must not call resume/launch logic, regardless of `origin`.
- Archived/default-hidden worktrees: preserve current open-tab search behavior by indexing open tabs across all worktrees.
- SSH/web runtime tabs: activation must call the existing runtime activation path where terminal/browser tab shortcuts already do.
- One-character query: keep the existing two-character minimum only for settings/actions; open-tab search follows current browser/simulator tab search behavior and may match one character.
- Stale records: ignore agent records whose tab id cannot be tied to an existing open tab; do not create standalone agent-session rows.
- Duplicate metadata from live + retained + sleeping records: de-duplicate by pane key and prefer live, then retained, then sleeping for keywords/supporting snippets.
- External mutations during selection: if the target tab, backing `OpenFile`, group, or worktree disappeared after search, close nothing and show the same toast-and-return pattern used for missing browser/simulator rows. Validate all of these before calling `focusGroup`, because `focusGroup` itself will stamp the requested group id even if the group was removed.
## Test plan
- Unit: add `workspace-tab-palette-search.test.ts` covering terminal title precedence, generated-title disabled behavior, editor label/path search for every editor-family content type and markdown preview mode, agent prompt/session search, retained/sleeping metadata attribution by legacy terminal id, stale/orphan metadata exclusion, split-group current-tab detection, and deterministic ordering.
- Unit: update/add `WorktreeJumpPalette` tests only if there is an existing lightweight component harness; otherwise cover integration behavior through pure helper tests and targeted activation helper tests.
- Unit: add activation tests for a helper if extracted from `WorktreeJumpPalette`, including terminal web-runtime activation, editor-family activation for `editor`/`diff`/`conflict-review`/`check-details`, missing tab/group/backing-file/worktree failures, and split group focus.
- Regression: ensure existing `simulator-palette-search.test.ts` and `palette-results.test.ts` still pass.
- Validation: Electron golden path for searching a terminal tab title/agent prompt and selecting it, plus an editor-family tab selection; adjacent smoke for existing browser/simulator open-tab rows.
## UI Quality Bar
User-visible. The new rows must look like the existing Open Tabs rows: same spacing, typography, selection state, highlight weight, badges, truncation, host/repo badges, and row density. Long tab titles, prompts, repo names, and worktree names must truncate without overlap or layout jitter in the 736px palette and under the existing `max-w-[94vw]` mobile/narrow constraint. Use documented tokens and existing shadcn/cmdk row primitives; no new color values, font sizes, shadows, or card styling.
## Review Screenshots
1. Empty Cmd-J palette with Open Tabs showing a terminal/editor tab alongside existing tab rows.
2. Typed query matching a terminal tab title.
3. Typed query matching an agent prompt/session keyword with supporting text visible.
4. Typed query matching an editor tab title or path.
5. Adjacent smoke: existing browser or simulator tab search result still appears and keeps its styling.
## Rollout
1. Add the pure tab-search helper and unit tests.
2. Add/extract a small tab-activation helper if needed and unit-test it.
3. Wire `WorktreeJumpPalette` to build/search/render/select workspace tab rows.
4. Update no-results copy and imports.
5. Run targeted tests, typecheck, lint.
6. Validate in Electron and capture required screenshots.
## Lightweight Eng Review
- Scope: reduced to open terminal/editor tab navigation plus bounded agent prompt/session-id/title metadata. No terminal scrollback, file-content search, standalone sleeping-session rows, or resume/launch actions.
- Architecture/data flow: renderer-only helper fed by explicit `WorktreeJumpPalette` store snapshots; selection re-resolves live state and delegates to a small activation helper that preserves current worktree/group/tab and web-runtime activation boundaries.
- Failure modes covered:
- stale tab/group/backing file/worktree between search and select -> toast and no state mutation beyond current palette behavior
- duplicated live/retained/sleeping metadata -> de-dupe by pane key with live records preferred
- orphan or cross-worktree agent records -> ignored unless tied to an existing terminal tab
- split groups -> activate result `groupId`
- generated titles disabled or overridden -> displayed title follows tab-bar precedence while agent prompt remains searchable
- SSH/web runtime -> reuse existing runtime activation call shape
- multi-window renderer state -> no shared cache or persisted search index
- Test coverage required:
- `src/renderer/src/lib/workspace-tab-palette-search.test.ts`: title precedence, editor path/title across editor-family types including markdown preview, agent prompt/session id, retained/sleeping attribution by legacy terminal id, duplicate metadata, orphan exclusion, current-tab/current-worktree ordering
- `src/renderer/src/lib/workspace-tab-palette-activation.test.ts`: terminal/editor-family activation, split group focus, web-runtime terminal activation, missing tab/group/backing-file/worktree failures
- existing `src/renderer/src/lib/simulator-palette-search.test.ts` and `src/renderer/src/components/cmd-j/palette-results.test.ts` unchanged/pass
- Performance/blast radius: no new IPC, persistence, polling, filesystem search, or terminal output indexing. Work is proportional to current open tabs and small bounded agent maps already in memory.
- UI quality bar: Electron validation must compare new rows with existing Open Tabs rows against `docs/STYLEGUIDE.md`, with no new colors/shadows/font sizes and no overflow/overlap under narrow palette width.
- Required review screenshots:
1. Empty Cmd-J palette with terminal/editor rows in Open Tabs.
2. Typed query matching a terminal title.
3. Typed query matching an agent prompt/session keyword.
4. Typed query matching an editor tab title/path.
5. Existing browser or simulator tab search still styled correctly.
- Residual risks: Electron validation may need to seed a live agent prompt; if a real prompt cannot be created safely, use an existing local non-mutating agent tab or halt before PR creation with nearest screenshots.

View File

@ -2,7 +2,7 @@
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Globe, Plus, Server, ServerOff, Smartphone } from 'lucide-react'
import { FileText, Globe, Plus, Server, ServerOff, Smartphone, SquareTerminal } from 'lucide-react'
import { useAppStore } from '@/store'
import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors'
import {
@ -54,6 +54,13 @@ import {
type SearchableSimulatorTab,
type SimulatorPaletteSearchResult
} from '@/lib/simulator-palette-search'
import {
buildSearchableWorkspaceTabs,
searchWorkspaceTabs,
type SearchableWorkspaceTab,
type WorkspaceTabPaletteSearchResult
} from '@/lib/workspace-tab-palette-search'
import { activateWorkspaceTabPaletteResult } from '@/lib/workspace-tab-palette-activation'
import {
ORCA_BROWSER_FOCUS_REQUEST_EVENT,
queueBrowserFocusRequest
@ -114,6 +121,12 @@ type SimulatorPaletteItem = {
result: SimulatorPaletteSearchResult
}
type WorkspaceTabPaletteItem = {
id: string
type: 'workspace-tab'
result: WorkspaceTabPaletteSearchResult
}
type SettingsPaletteItem = {
id: string
type: 'settings'
@ -151,6 +164,7 @@ type PaletteItem =
| QuickActionPaletteItem
| BrowserPaletteItem
| SimulatorPaletteItem
| WorkspaceTabPaletteItem
type PaletteListEntry = PaletteItem | CreateWorktreePaletteItem | SectionHeader | HintRow
@ -312,12 +326,20 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeTabType = useAppStore((s) => s.activeTabType)
const activeTabId = useAppStore((s) => s.activeTabId)
const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
const activeFileId = useAppStore((s) => s.activeFileId)
const activeFileIdByWorktree = useAppStore((s) => s.activeFileIdByWorktree)
const activeTabTypeByWorktree = useAppStore((s) => s.activeTabTypeByWorktree)
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
const openFiles = useAppStore((s) => s.openFiles)
const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree)
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
const sleepingAgentSessionsByPaneKey = useAppStore((s) => s.sleepingAgentSessionsByPaneKey)
const settings = useAppStore((s) => s.settings)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
@ -548,7 +570,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
repoName,
worktreeSortIndex,
isCurrentPage:
workspace.id === activeBrowserTabId && workspace.activePageId === page.id,
activeTabType === 'browser' &&
workspace.id === activeBrowserTabId &&
workspace.activePageId === page.id,
isCurrentWorktree: activeWorktreeId === worktree.id
})
}
@ -557,6 +581,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
return entries
}, [
activeBrowserTabId,
activeTabType,
activeWorktreeId,
browserPagesByWorkspace,
browserTabsByWorktree,
@ -597,6 +622,55 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
[simulatorTabEntries, deferredQuery]
)
const workspaceTabEntries = useMemo<SearchableWorkspaceTab[]>(() => {
return buildSearchableWorkspaceTabs({
worktrees: browserSortedWorktrees,
repoMap,
worktreeOrder,
unifiedTabsByWorktree,
tabsByWorktree,
openFiles,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
sleepingAgentSessionsByPaneKey,
activeGroupIdByWorktree,
groupsByWorktree,
activeWorktreeId,
activeTabType,
activeTabId,
activeTabIdByWorktree,
activeFileId,
activeFileIdByWorktree,
activeTabTypeByWorktree,
generatedTitlesEnabled: settings?.tabAutoGenerateTitle === true
})
}, [
activeFileId,
activeFileIdByWorktree,
activeGroupIdByWorktree,
activeTabId,
activeTabIdByWorktree,
activeTabType,
activeTabTypeByWorktree,
activeWorktreeId,
agentStatusByPaneKey,
browserSortedWorktrees,
groupsByWorktree,
openFiles,
repoMap,
retainedAgentsByPaneKey,
settings?.tabAutoGenerateTitle,
sleepingAgentSessionsByPaneKey,
tabsByWorktree,
unifiedTabsByWorktree,
worktreeOrder
])
const workspaceTabMatches = useMemo(
() => searchWorkspaceTabs(workspaceTabEntries, deferredQuery.trim()),
[workspaceTabEntries, deferredQuery]
)
const worktreeItems = useMemo<WorktreePaletteItem[]>(
() =>
worktreeMatches
@ -636,15 +710,29 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
[simulatorMatches]
)
const openTabItems = useMemo<(BrowserPaletteItem | SimulatorPaletteItem)[]>(
const workspaceTabItems = useMemo<WorkspaceTabPaletteItem[]>(
() =>
[...browserItems, ...simulatorItems].sort((a, b) => {
workspaceTabMatches.map((result) => ({
id: `workspace-tab:${result.tabId}`,
type: 'workspace-tab' as const,
result
})),
[workspaceTabMatches]
)
const openTabItems = useMemo<
(BrowserPaletteItem | SimulatorPaletteItem | WorkspaceTabPaletteItem)[]
>(
() =>
// Why: these result builders emit comparable ascending scores, so one sort
// keeps cross-source ranking consistent within the OPEN TABS section.
[...browserItems, ...simulatorItems, ...workspaceTabItems].sort((a, b) => {
if (a.result.score !== b.result.score) {
return a.result.score - b.result.score
}
return a.id.localeCompare(b.id)
}),
[browserItems, simulatorItems]
[browserItems, simulatorItems, workspaceTabItems]
)
const settingsResults = useMemo(
@ -862,7 +950,10 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
// See docs/cmd-j-empty-query-ordering.md.
const hasAnyWorktrees = visibleWorktreesForState.length > 0
const hasAnySearchableWorktrees = hasQuery ? searchScopeWorktrees.length > 0 : hasAnyWorktrees
const hasAnyOpenTabs = browserPageEntries.length > 0 || simulatorTabEntries.length > 0
const hasAnyOpenTabs =
browserPageEntries.length > 0 ||
simulatorTabEntries.length > 0 ||
workspaceTabEntries.length > 0
const hasAnyMiddleResults = middleItems.length > 0
useEffect(() => {
@ -1107,6 +1198,31 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
[closeModal]
)
const handleSelectWorkspaceTab = useCallback(
(result: WorkspaceTabPaletteSearchResult) => {
const activation = activateWorkspaceTabPaletteResult(result)
if (activation.status === 'failed') {
toast.error(
activation.reason === 'missing-worktree'
? translate(
'auto.components.WorktreeJumpPalette.2c38630a01',
'Workspace no longer exists'
)
: translate(
'auto.components.WorktreeJumpPalette.workspaceTabMissing',
'Tab no longer exists'
)
)
return
}
skipRestoreFocusRef.current = true
closeModal()
setSelectedItemId('')
},
[closeModal]
)
const handleSelectSettings = useCallback(
(result: CmdJSettingsResult) => {
const target = getSettingsTargetFromSectionId(result.sectionId)
@ -1152,6 +1268,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
handleSelectBrowserPage(item.result)
} else if (item.type === 'simulator-tab') {
handleSelectSimulatorTab(item.result)
} else if (item.type === 'workspace-tab') {
handleSelectWorkspaceTab(item.result)
} else if (item.type === 'settings') {
handleSelectSettings(item.result)
} else {
@ -1163,6 +1281,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
handleSelectQuickAction,
handleSelectSettings,
handleSelectSimulatorTab,
handleSelectWorkspaceTab,
handleSelectWorktree
]
)
@ -1384,7 +1503,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
),
subtitle: translate(
'auto.components.WorktreeJumpPalette.c4afa68159',
'Try a worktree, setting, action, page title, emulator, URL, PR, or port.'
'Try a worktree, setting, action, tab title, agent prompt, URL, PR, or port.'
)
}
}
@ -1694,6 +1813,89 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
)
}
if (entry.type === 'workspace-tab') {
const result = entry.result
const workspaceTabWorktree = worktreeMap.get(result.worktreeId)
const workspaceTabRepo = workspaceTabWorktree
? repoMap.get(workspaceTabWorktree.repoId)
: undefined
const workspaceTabRepoName = workspaceTabRepo?.displayName ?? result.repoName
const workspaceTabHostBadge = getPaletteHostBadge(workspaceTabRepo, hostOptions)
const WorkspaceTabIcon =
result.contentType === 'terminal' ? SquareTerminal : FileText
return (
<CommandItem
key={entry.id}
value={entry.id}
onSelect={() => handleSelectItem(entry)}
className={cn(
'group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]',
'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground'
)}
>
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5 text-muted-foreground/85">
<WorkspaceTabIcon className="size-3.5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="max-w-[40%] shrink-0 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
<HighlightedText text={result.title} matchRange={result.titleRange} />
</span>
{result.isCurrentTab && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.52404f8096',
'Current Tab'
)}
</span>
)}
{!result.isCurrentTab && result.isCurrentWorktree && (
<span className="shrink-0 self-center rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{translate(
'auto.components.WorktreeJumpPalette.c5081f2814',
'Current Worktree'
)}
</span>
)}
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.secondaryText}
matchRange={result.secondaryRange}
/>
</span>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="shrink-0 text-[12px] font-medium text-muted-foreground/92">
<HighlightedText
text={result.worktreeName}
matchRange={result.worktreeRange}
/>
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<PaletteHostBadgeChip badge={workspaceTabHostBadge} />
{workspaceTabRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={workspaceTabRepo?.badgeColor} />
<span className="truncate">
<HighlightedText
text={workspaceTabRepoName}
matchRange={result.repoRange}
/>
</span>
</span>
)}
</div>
</div>
</div>
</CommandItem>
)
}
if (entry.type === 'simulator-tab') {
const result = entry.result
const simulatorWorktree = worktreeMap.get(result.worktreeId)

View File

@ -1657,7 +1657,7 @@
"1628fd7dfa": "No active worktrees, settings, actions, or open tabs",
"b781ae05e3": "Type to search worktrees, settings, tabs, and actions.",
"f60f8730be": "No other worktrees to switch to",
"c4afa68159": "Try a worktree, setting, action, page title, emulator, URL, PR, or port.",
"c4afa68159": "Try a worktree, setting, action, tab title, agent prompt, URL, PR, or port.",
"dbd9d87eec": "No results match your search",
"2c38630a01": "Workspace no longer exists",
"7726ce9970": "Mobile emulator tab no longer exists",
@ -1674,7 +1674,8 @@
"recentWorktreesHeader": "Recent Worktrees",
"settingsBadge": "Settings",
"actionBadge": "Action",
"paletteHostBadge": "Host: {{value0}}"
"paletteHostBadge": "Host: {{value0}}",
"workspaceTabMissing": "Tab no longer exists"
},
"github": {
"pr": {

View File

@ -1657,7 +1657,7 @@
"1628fd7dfa": "No hay árboles de trabajo, configuraciones, acciones ni pestañas abiertas activas",
"b781ae05e3": "Escriba para buscar árboles de trabajo, configuraciones, pestañas y acciones.",
"f60f8730be": "No hay otros árboles de trabajo a los que cambiar",
"c4afa68159": "Pruebe con un árbol de trabajo, configuración, acción, título de página, emulador, URL, PR o puerto.",
"c4afa68159": "Pruebe con un árbol de trabajo, ajuste, acción, título de pestaña, prompt de agente, URL, PR o puerto.",
"dbd9d87eec": "Ningún resultado coincide con tu búsqueda",
"2c38630a01": "El espacio de trabajo ya no existe",
"7726ce9970": "La pestaña del emulador móvil ya no existe",
@ -1674,7 +1674,8 @@
"recentWorktreesHeader": "Árboles de trabajo recientes",
"settingsBadge": "Ajustes",
"actionBadge": "Acción",
"paletteHostBadge": "Host: {{value0}}"
"paletteHostBadge": "Host: {{value0}}",
"workspaceTabMissing": "La pestaña ya no existe"
},
"github": {
"pr": {

View File

@ -1657,7 +1657,7 @@
"1628fd7dfa": "アクティブなワークツリー、設定、操作、または開いているタブがありません",
"b781ae05e3": "入力してワークツリー、設定、タブ、操作を検索します。",
"f60f8730be": "他に切り替えるワークツリーがない",
"c4afa68159": "ワークツリー、設定、操作、ページ タイトル、エミュレータ、URL、PR、またはポートを試す。",
"c4afa68159": "ワークツリー、設定、操作、タブタイトル、エージェントプロンプト、URL、PR、またはポートを試してください。",
"dbd9d87eec": "検索に一致する結果はありません",
"2c38630a01": "ワークスペースはもう存在しません",
"7726ce9970": "「モバイルエミュレータ」タブは存在しません",
@ -1674,7 +1674,8 @@
"recentWorktreesHeader": "最近のワークツリー",
"settingsBadge": "設定",
"actionBadge": "操作",
"paletteHostBadge": "Host: {{value0}}"
"paletteHostBadge": "Host: {{value0}}",
"workspaceTabMissing": "タブはもう存在しません"
},
"github": {
"pr": {

View File

@ -1657,7 +1657,7 @@
"1628fd7dfa": "활성 작업 트리, 설정, 작업 또는 열린 탭이 없습니다.",
"b781ae05e3": "작업 트리, 설정, 탭 및 작업을 검색하려면 입력하세요.",
"f60f8730be": "전환할 다른 작업 트리가 없습니다.",
"c4afa68159": "작업 트리, 설정, 작업, 페이지 제목, 에뮬레이터, URL, PR 또는 포트를 사용해 보세요.",
"c4afa68159": "작업 트리, 설정, 작업, 탭 제목, 에이전트 프롬프트, URL, PR 또는 포트를 입력해 보세요.",
"dbd9d87eec": "검색어와 일치하는 결과가 없습니다.",
"2c38630a01": "워크스페이스가 더 이상 존재하지 않습니다.",
"7726ce9970": "모바일 에뮬레이터 탭이 더 이상 존재하지 않습니다.",
@ -1674,7 +1674,8 @@
"recentWorktreesHeader": "최근 작업 트리",
"settingsBadge": "설정",
"actionBadge": "행동",
"paletteHostBadge": "Host: {{value0}}"
"paletteHostBadge": "Host: {{value0}}",
"workspaceTabMissing": "탭이 더 이상 존재하지 않습니다"
},
"github": {
"pr": {

View File

@ -1657,7 +1657,7 @@
"1628fd7dfa": "没有活动的工作树、设置、操作或打开的选项卡",
"b781ae05e3": "键入以搜索工作树、设置、选项卡和操作。",
"f60f8730be": "没有其他工作树可供切换",
"c4afa68159": "尝试工作树、设置、操作、页面标题、模拟器、URL、PR 或端口。",
"c4afa68159": "尝试工作树、设置、操作、标签页标题、智能体提示、URL、PR 或端口。",
"dbd9d87eec": "没有结果符合您的搜索",
"2c38630a01": "工作区不再存在",
"7726ce9970": "手机模拟器选项卡不再存在",
@ -1674,7 +1674,8 @@
"recentWorktreesHeader": "最近的工作树",
"settingsBadge": "设置",
"actionBadge": "操作",
"paletteHostBadge": "主机:{{value0}}"
"paletteHostBadge": "主机:{{value0}}",
"workspaceTabMissing": "标签页不再存在"
},
"github": {
"pr": {

View File

@ -0,0 +1,167 @@
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
export type AgentMetadata = {
paneKey: string
textParts: string[]
snippetCandidates: string[]
}
export type WorkspaceTabAgentMetadataState = {
agentStatusByPaneKey: Record<string, AgentStatusEntry>
retainedAgentsByPaneKey: Record<string, RetainedAgentEntry>
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord>
}
function normalizeText(value: string | null | undefined): string {
return value?.trim() ?? ''
}
function addText(target: string[], value: string | null | undefined): void {
const trimmed = normalizeText(value)
if (trimmed) {
target.push(trimmed)
}
}
function addProviderSession(
target: string[],
providerSession: { key: string; id: string } | null | undefined
): void {
if (!providerSession) {
return
}
addText(target, providerSession.key)
addText(target, providerSession.id)
}
function getPaneKeyTabId(paneKey: string): string | null {
const separator = paneKey.indexOf(':')
if (separator <= 0 || separator !== paneKey.lastIndexOf(':')) {
return null
}
return paneKey.slice(0, separator)
}
function agentRecordMatchesTab({
paneKey,
recordWorktreeId,
recordTabId,
terminalTabId,
worktreeId
}: {
paneKey: string
recordWorktreeId?: string | null
recordTabId?: string | null
terminalTabId: string
worktreeId: string
}): boolean {
if (recordWorktreeId && recordWorktreeId !== worktreeId) {
return false
}
if (recordTabId) {
return recordTabId === terminalTabId
}
return getPaneKeyTabId(paneKey) === terminalTabId
}
function collectLiveMetadata(
entry: AgentStatusEntry
): Pick<AgentMetadata, 'snippetCandidates' | 'textParts'> {
const textParts: string[] = []
const snippetCandidates: string[] = []
addText(textParts, entry.prompt)
addText(snippetCandidates, entry.prompt)
addText(textParts, entry.agentType)
addText(textParts, entry.state)
addText(textParts, entry.terminalTitle)
addText(snippetCandidates, entry.terminalTitle)
addProviderSession(textParts, entry.providerSession)
for (const historyEntry of entry.stateHistory) {
addText(textParts, historyEntry.prompt)
addText(snippetCandidates, historyEntry.prompt)
}
return { textParts, snippetCandidates }
}
function collectSleepingMetadata(
record: SleepingAgentSessionRecord
): Pick<AgentMetadata, 'snippetCandidates' | 'textParts'> {
const textParts: string[] = []
const snippetCandidates: string[] = []
addText(textParts, record.prompt)
addText(snippetCandidates, record.prompt)
addText(textParts, record.agent)
addText(textParts, record.state)
addText(textParts, record.terminalTitle)
addText(snippetCandidates, record.terminalTitle)
addProviderSession(textParts, record.providerSession)
return { textParts, snippetCandidates }
}
export function collectAgentMetadataForTerminal({
terminalTabId,
worktreeId,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
sleepingAgentSessionsByPaneKey
}: WorkspaceTabAgentMetadataState & {
terminalTabId: string
worktreeId: string
}): AgentMetadata[] {
const metadataByPaneKey = new Map<string, AgentMetadata>()
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
if (
agentRecordMatchesTab({
paneKey,
recordWorktreeId: entry.worktreeId,
recordTabId: entry.tabId,
terminalTabId,
worktreeId
})
) {
metadataByPaneKey.set(paneKey, { paneKey, ...collectLiveMetadata(entry) })
}
}
for (const [paneKey, retained] of Object.entries(retainedAgentsByPaneKey)) {
if (metadataByPaneKey.has(paneKey)) {
continue
}
if (
agentRecordMatchesTab({
paneKey,
recordWorktreeId: retained.worktreeId,
recordTabId: retained.entry.tabId ?? retained.tab.id,
terminalTabId,
worktreeId
})
) {
const metadata = collectLiveMetadata(retained.entry)
addText(metadata.textParts, retained.tab.title)
addText(metadata.snippetCandidates, retained.tab.title)
metadataByPaneKey.set(paneKey, { paneKey, ...metadata })
}
}
for (const [paneKey, record] of Object.entries(sleepingAgentSessionsByPaneKey)) {
if (metadataByPaneKey.has(paneKey)) {
continue
}
if (
agentRecordMatchesTab({
paneKey,
recordWorktreeId: record.worktreeId,
recordTabId: record.tabId,
terminalTabId,
worktreeId
})
) {
metadataByPaneKey.set(paneKey, { paneKey, ...collectSleepingMetadata(record) })
}
}
return [...metadataByPaneKey.values()]
}

View File

@ -0,0 +1,334 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceTabPaletteSearchResult } from './workspace-tab-palette-search'
const mocks = vi.hoisted(() => {
type MockStore = {
worktreesByRepo: Record<string, { id: string; repoId: string; path: string }[]>
groupsByWorktree: Record<string, Record<string, unknown>[]>
unifiedTabsByWorktree: Record<string, Record<string, unknown>[]>
openFiles: { id: string; worktreeId: string }[]
repos: unknown[]
settings: Record<string, unknown>
activeGroupIdByWorktree: Record<string, string>
focusGroup: ReturnType<typeof vi.fn>
activateTab: ReturnType<typeof vi.fn>
setActiveTab: ReturnType<typeof vi.fn>
setActiveTabType: ReturnType<typeof vi.fn>
setActiveFile: ReturnType<typeof vi.fn>
}
const store: MockStore = {
worktreesByRepo: {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/tmp/wt-1' }]
},
groupsByWorktree: {
'wt-1': [
{
id: 'group-1',
worktreeId: 'wt-1',
activeTabId: 'unified-terminal-1',
tabOrder: ['unified-terminal-1']
}
]
},
unifiedTabsByWorktree: {
'wt-1': [
{
id: 'unified-terminal-1',
entityId: 'terminal-1',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'terminal',
label: 'Terminal',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
]
},
openFiles: [],
repos: [],
settings: {},
activeGroupIdByWorktree: { 'wt-1': 'group-1' },
focusGroup: vi.fn(),
activateTab: vi.fn(),
setActiveTab: vi.fn(),
setActiveTabType: vi.fn(),
setActiveFile: vi.fn()
}
return {
store,
activateAndRevealWorktree: vi.fn(),
activateWebRuntimeSessionTab: vi.fn(),
focusTerminalTabSurface: vi.fn(),
getRuntimeEnvironmentIdForWorktree: vi.fn(),
isWebRuntimeSessionActive: vi.fn()
}
})
vi.mock('@/store', () => ({
useAppStore: {
getState: () => mocks.store
}
}))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
focusTerminalTabSurface: mocks.focusTerminalTabSurface
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getRuntimeEnvironmentIdForWorktree: mocks.getRuntimeEnvironmentIdForWorktree
}))
vi.mock('@/runtime/web-runtime-session', () => ({
activateWebRuntimeSessionTab: mocks.activateWebRuntimeSessionTab,
isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive
}))
vi.mock('./worktree-activation', () => ({
activateAndRevealWorktree: mocks.activateAndRevealWorktree
}))
import { activateWorkspaceTabPaletteResult } from './workspace-tab-palette-activation'
function makeResult(
overrides: Partial<WorkspaceTabPaletteSearchResult> = {}
): WorkspaceTabPaletteSearchResult {
return {
tabId: 'unified-terminal-1',
entityId: 'terminal-1',
worktreeId: 'wt-1',
groupId: 'group-1',
contentType: 'terminal',
title: 'Terminal',
secondaryText: 'Terminal tab',
repoName: 'repo/orca',
worktreeName: 'Palette Worktree',
titleRange: null,
secondaryRange: null,
repoRange: null,
worktreeRange: null,
isCurrentTab: false,
isCurrentWorktree: false,
score: 0,
...overrides
}
}
function resetStore(): void {
mocks.store.worktreesByRepo = {
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/tmp/wt-1' }]
}
mocks.store.groupsByWorktree = {
'wt-1': [
{
id: 'group-1',
worktreeId: 'wt-1',
activeTabId: 'unified-terminal-1',
tabOrder: ['unified-terminal-1']
}
]
}
mocks.store.unifiedTabsByWorktree = {
'wt-1': [
{
id: 'unified-terminal-1',
entityId: 'terminal-1',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'terminal',
label: 'Terminal',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
]
}
mocks.store.openFiles = []
}
describe('activateWorkspaceTabPaletteResult', () => {
beforeEach(() => {
vi.clearAllMocks()
resetStore()
mocks.activateAndRevealWorktree.mockReturnValue(true)
mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue('runtime-1')
mocks.isWebRuntimeSessionActive.mockReturnValue(false)
})
it('activates terminal tabs and focuses the terminal surface', () => {
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({ status: 'activated' })
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1')
expect(mocks.store.focusGroup).toHaveBeenCalledWith('wt-1', 'group-1')
expect(mocks.store.activateTab).toHaveBeenCalledWith('unified-terminal-1')
expect(mocks.store.setActiveTab).toHaveBeenCalledWith('terminal-1')
expect(mocks.store.setActiveTabType).toHaveBeenCalledWith('terminal')
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1')
})
it('uses the web-runtime terminal activation path when active', () => {
mocks.isWebRuntimeSessionActive.mockReturnValue(true)
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({ status: 'activated' })
expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId: 'terminal-1',
environmentId: 'runtime-1'
})
})
it('activates editor-family tabs through the target split group and backing file', () => {
mocks.store.unifiedTabsByWorktree = {
'wt-1': [
{
id: 'diff-tab-1',
entityId: '/tmp/wt-1/src/app.ts',
groupId: 'group-2',
worktreeId: 'wt-1',
contentType: 'diff',
label: 'app.ts (diff)',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
]
}
mocks.store.groupsByWorktree = {
'wt-1': [
{
id: 'group-2',
worktreeId: 'wt-1',
activeTabId: 'diff-tab-1',
tabOrder: ['diff-tab-1']
}
]
}
mocks.store.openFiles = [{ id: '/tmp/wt-1/src/app.ts', worktreeId: 'wt-1' }]
expect(
activateWorkspaceTabPaletteResult(
makeResult({
tabId: 'diff-tab-1',
entityId: '/tmp/wt-1/src/app.ts',
groupId: 'group-2',
contentType: 'diff'
})
)
).toEqual({ status: 'activated' })
expect(mocks.store.focusGroup).toHaveBeenCalledWith('wt-1', 'group-2')
expect(mocks.store.setActiveFile).toHaveBeenCalledWith('/tmp/wt-1/src/app.ts')
expect(mocks.store.activateTab).toHaveBeenLastCalledWith('diff-tab-1')
expect(mocks.store.setActiveTabType).toHaveBeenCalledWith('editor')
expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled()
})
it.each([
['editor' as const, 'editor-tab-1', '/tmp/wt-1/src/app.ts'],
['conflict-review' as const, 'conflict-tab-1', 'wt-1::conflict-review'],
['check-details' as const, 'check-tab-1', 'wt-1::check-details::check-run:42']
])('activates %s tabs with their exact unified tab id', (contentType, tabId, entityId) => {
mocks.store.unifiedTabsByWorktree = {
'wt-1': [
{
id: tabId,
entityId,
groupId: 'group-2',
worktreeId: 'wt-1',
contentType,
label: 'Editor tab',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
]
}
mocks.store.groupsByWorktree = {
'wt-1': [
{
id: 'group-2',
worktreeId: 'wt-1',
activeTabId: tabId,
tabOrder: [tabId]
}
]
}
mocks.store.openFiles = [{ id: entityId, worktreeId: 'wt-1' }]
expect(
activateWorkspaceTabPaletteResult(
makeResult({
tabId,
entityId,
groupId: 'group-2',
contentType
})
)
).toEqual({ status: 'activated' })
expect(mocks.store.focusGroup).toHaveBeenCalledWith('wt-1', 'group-2')
expect(mocks.store.setActiveFile).toHaveBeenCalledWith(entityId)
expect(mocks.store.activateTab).toHaveBeenLastCalledWith(tabId)
expect(mocks.store.setActiveTabType).toHaveBeenCalledWith('editor')
})
it('returns stale failures before focusing a removed group or tab', () => {
mocks.store.groupsByWorktree = { 'wt-1': [] }
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({
status: 'failed',
reason: 'missing-group'
})
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
expect(mocks.store.focusGroup).not.toHaveBeenCalled()
resetStore()
mocks.store.unifiedTabsByWorktree = { 'wt-1': [] }
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({
status: 'failed',
reason: 'missing-tab'
})
expect(mocks.store.focusGroup).not.toHaveBeenCalled()
})
it('treats missing editor backing files and worktrees as stale', () => {
mocks.store.unifiedTabsByWorktree = {
'wt-1': [
{
id: 'editor-tab-1',
entityId: '/tmp/wt-1/src/app.ts',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'editor',
label: 'app.ts',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
]
}
expect(
activateWorkspaceTabPaletteResult(
makeResult({
tabId: 'editor-tab-1',
entityId: '/tmp/wt-1/src/app.ts',
contentType: 'editor'
})
)
).toEqual({ status: 'failed', reason: 'missing-file' })
resetStore()
mocks.store.worktreesByRepo = {}
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({
status: 'failed',
reason: 'missing-worktree'
})
})
})

View File

@ -0,0 +1,115 @@
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
activateWebRuntimeSessionTab,
isWebRuntimeSessionActive
} from '@/runtime/web-runtime-session'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { activateAndRevealWorktree } from './worktree-activation'
import type { WorkspaceTabPaletteSearchResult } from './workspace-tab-palette-search'
export type WorkspaceTabPaletteActivationFailure =
| 'missing-worktree'
| 'missing-group'
| 'missing-tab'
| 'missing-file'
export type WorkspaceTabPaletteActivationResult =
| { status: 'activated' }
| { status: 'failed'; reason: WorkspaceTabPaletteActivationFailure }
type WorkspaceTabPaletteActivationState = Pick<
AppState,
| 'activateTab'
| 'activeGroupIdByWorktree'
| 'focusGroup'
| 'groupsByWorktree'
| 'openFiles'
| 'repos'
| 'settings'
| 'setActiveFile'
| 'setActiveTab'
| 'setActiveTabType'
| 'unifiedTabsByWorktree'
| 'worktreesByRepo'
>
function validateTarget(
state: WorkspaceTabPaletteActivationState,
result: WorkspaceTabPaletteSearchResult
): WorkspaceTabPaletteActivationFailure | null {
if (!findWorktreeById(state.worktreesByRepo, result.worktreeId)) {
return 'missing-worktree'
}
const group = (state.groupsByWorktree[result.worktreeId] ?? []).find(
(candidate) => candidate.id === result.groupId
)
if (!group) {
return 'missing-group'
}
const tab = (state.unifiedTabsByWorktree[result.worktreeId] ?? []).find(
(candidate) =>
candidate.id === result.tabId &&
candidate.entityId === result.entityId &&
candidate.groupId === result.groupId &&
candidate.worktreeId === result.worktreeId &&
candidate.contentType === result.contentType
)
if (!tab) {
return 'missing-tab'
}
if (
result.contentType !== 'terminal' &&
!state.openFiles.some(
(file) => file.id === result.entityId && file.worktreeId === result.worktreeId
)
) {
return 'missing-file'
}
return null
}
export function activateWorkspaceTabPaletteResult(
result: WorkspaceTabPaletteSearchResult
): WorkspaceTabPaletteActivationResult {
const initialState = useAppStore.getState()
const initialFailure = validateTarget(initialState, result)
if (initialFailure) {
return { status: 'failed', reason: initialFailure }
}
const activated = activateAndRevealWorktree(result.worktreeId)
if (!activated) {
return { status: 'failed', reason: 'missing-worktree' }
}
const state = useAppStore.getState()
const finalFailure = validateTarget(state, result)
if (finalFailure) {
return { status: 'failed', reason: finalFailure }
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, result.worktreeId)
state.focusGroup(result.worktreeId, result.groupId)
state.activateTab(result.tabId)
if (result.contentType === 'terminal') {
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
void activateWebRuntimeSessionTab({
worktreeId: result.worktreeId,
tabId: result.entityId,
environmentId: runtimeEnvironmentId
})
}
state.setActiveTab(result.entityId)
state.setActiveTabType('terminal')
focusTerminalTabSurface(result.entityId)
return { status: 'activated' }
}
state.setActiveFile(result.entityId)
state.setActiveTabType('editor')
return { status: 'activated' }
}

View File

@ -0,0 +1,242 @@
import type { MatchRange } from './worktree-palette-search'
import type {
SearchableWorkspaceTab,
WorkspaceTabContentType
} from './workspace-tab-palette-search'
export type WorkspaceTabPaletteSearchResult = {
tabId: string
entityId: string
worktreeId: string
groupId: string
contentType: WorkspaceTabContentType
title: string
secondaryText: string
repoName: string
worktreeName: string
titleRange: MatchRange | null
secondaryRange: MatchRange | null
repoRange: MatchRange | null
worktreeRange: MatchRange | null
isCurrentTab: boolean
isCurrentWorktree: boolean
score: number
}
function compareText(a: string, b: string): number {
return a.localeCompare(b, undefined, { sensitivity: 'base' })
}
function findRange(text: string, query: string): MatchRange | null {
if (!query) {
return null
}
const start = text.toLowerCase().indexOf(query)
if (start === -1) {
return null
}
return { start, end: start + query.length }
}
function compareEmptyQueryResults(
a: WorkspaceTabPaletteSearchResult,
b: WorkspaceTabPaletteSearchResult
): number {
if (a.isCurrentTab !== b.isCurrentTab) {
return a.isCurrentTab ? -1 : 1
}
if (a.isCurrentWorktree !== b.isCurrentWorktree) {
return a.isCurrentWorktree ? -1 : 1
}
if (a.score !== b.score) {
return a.score - b.score
}
const worktreeCmp = compareText(a.worktreeName, b.worktreeName)
if (worktreeCmp !== 0) {
return worktreeCmp
}
return compareText(a.title, b.title)
}
function scoreWorkspaceTabMatch({
fieldWeight,
matchIndex,
entry
}: {
fieldWeight: number
matchIndex: number
entry: SearchableWorkspaceTab
}): number {
// Why: lower scores rank first; field weights preserve title > path > agent
// snippet > worktree > repo ordering while tab position breaks ties.
let score =
fieldWeight +
matchIndex +
entry.worktreeSortIndex * 100 +
entry.groupSortIndex * 10 +
entry.tabSortIndex
if (entry.isCurrentTab) {
score -= 40
} else if (entry.isCurrentWorktree) {
score -= 10
}
return score
}
function getBestAgentSnippet(
entry: SearchableWorkspaceTab,
query: string
): { text: string; range: MatchRange } | null {
for (const metadata of entry.agentMetadata) {
for (const snippet of metadata.snippetCandidates) {
const range = findRange(snippet, query)
if (range) {
return { text: snippet, range }
}
}
}
for (const metadata of entry.agentMetadata) {
for (const text of metadata.textParts) {
const range = findRange(text, query)
if (range) {
return { text, range }
}
}
}
return null
}
export function searchWorkspaceTabs(
entries: SearchableWorkspaceTab[],
query: string
): WorkspaceTabPaletteSearchResult[] {
const trimmedQuery = query.trim().toLowerCase()
const results: WorkspaceTabPaletteSearchResult[] = []
for (const entry of entries) {
const baseResult = {
tabId: entry.tab.id,
entityId: entry.tab.entityId,
worktreeId: entry.worktree.id,
groupId: entry.tab.groupId,
contentType: entry.tab.contentType,
title: entry.title,
secondaryText: entry.secondaryText,
repoName: entry.repoName,
worktreeName: entry.worktree.displayName,
isCurrentTab: entry.isCurrentTab,
isCurrentWorktree: entry.isCurrentWorktree
}
if (!trimmedQuery) {
results.push({
...baseResult,
titleRange: null,
secondaryRange: null,
repoRange: null,
worktreeRange: null,
score: entry.isCurrentTab
? -2
: entry.isCurrentWorktree
? -1
: entry.worktreeSortIndex * 100 + entry.groupSortIndex * 10 + entry.tabSortIndex
})
continue
}
const titleRange = findRange(entry.titleSearchText, trimmedQuery)
if (titleRange) {
results.push({
...baseResult,
titleRange,
secondaryRange: null,
repoRange: null,
worktreeRange: null,
score: scoreWorkspaceTabMatch({ fieldWeight: 0, matchIndex: titleRange.start, entry })
})
continue
}
let secondaryMatch: { text: string; range: MatchRange } | null = null
for (const secondaryText of entry.secondarySearchTexts) {
const range = findRange(secondaryText, trimmedQuery)
if (range) {
secondaryMatch = { text: secondaryText, range }
break
}
}
if (secondaryMatch) {
results.push({
...baseResult,
secondaryText: secondaryMatch.text,
titleRange: null,
secondaryRange: secondaryMatch.range,
repoRange: null,
worktreeRange: null,
score: scoreWorkspaceTabMatch({
fieldWeight: 20,
matchIndex: secondaryMatch.range.start,
entry
})
})
continue
}
const agentMatch = getBestAgentSnippet(entry, trimmedQuery)
if (agentMatch) {
results.push({
...baseResult,
secondaryText: agentMatch.text,
titleRange: null,
secondaryRange: agentMatch.range,
repoRange: null,
worktreeRange: null,
score: scoreWorkspaceTabMatch({
fieldWeight: 30,
matchIndex: agentMatch.range.start,
entry
})
})
continue
}
const worktreeRange = findRange(entry.worktree.displayName, trimmedQuery)
if (worktreeRange) {
results.push({
...baseResult,
titleRange: null,
secondaryRange: null,
repoRange: null,
worktreeRange,
score: scoreWorkspaceTabMatch({
fieldWeight: 40,
matchIndex: worktreeRange.start,
entry
})
})
continue
}
const repoRange = findRange(entry.repoName, trimmedQuery)
if (repoRange) {
results.push({
...baseResult,
titleRange: null,
secondaryRange: null,
repoRange,
worktreeRange: null,
score: scoreWorkspaceTabMatch({ fieldWeight: 60, matchIndex: repoRange.start, entry })
})
}
}
return results.sort((a, b) => {
if (!trimmedQuery) {
return compareEmptyQueryResults(a, b)
}
if (a.score !== b.score) {
return a.score - b.score
}
return compareEmptyQueryResults(a, b)
})
}

View File

@ -0,0 +1,441 @@
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import type { OpenFile } from '@/store/slices/editor'
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import type { Tab, TabGroup, TerminalTab, Worktree } from '../../../shared/types'
import { buildSearchableWorkspaceTabs, searchWorkspaceTabs } from './workspace-tab-palette-search'
const WT_ROOT = path.join('tmp', 'wt-1')
const SRC_APP_RELATIVE_PATH = path.join('src', 'app.ts')
const SRC_APP_PATH = path.join(WT_ROOT, SRC_APP_RELATIVE_PATH)
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'wt-1',
repoId: 'repo-1',
path: WT_ROOT,
head: 'abc123',
branch: 'refs/heads/feature/workspace-tab-search',
isBare: false,
isMainWorktree: false,
displayName: 'Palette Worktree',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...overrides
}
}
function makeUnifiedTab(overrides: Partial<Tab> = {}): Tab {
return {
id: 'unified-terminal-1',
entityId: 'terminal-1',
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'terminal',
label: 'Unified Label',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0,
...overrides
}
}
function makeTerminalTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
return {
id: 'terminal-1',
ptyId: 'pty-1',
worktreeId: 'wt-1',
title: 'Raw Shell Title',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0,
...overrides
}
}
function makeOpenFile(overrides: Partial<OpenFile> = {}): OpenFile {
return {
id: SRC_APP_PATH,
filePath: SRC_APP_PATH,
relativePath: SRC_APP_RELATIVE_PATH,
worktreeId: 'wt-1',
language: 'typescript',
isDirty: false,
mode: 'edit',
...overrides
}
}
function makeGroup(overrides: Partial<TabGroup> = {}): TabGroup {
return {
id: 'group-1',
worktreeId: 'wt-1',
activeTabId: 'unified-terminal-1',
tabOrder: ['unified-terminal-1'],
...overrides
}
}
function makeAgentEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'working',
prompt: 'Implement the websocket retry loop',
updatedAt: 1,
stateStartedAt: 1,
paneKey: 'terminal-1:leaf-a',
tabId: 'terminal-1',
worktreeId: 'wt-1',
stateHistory: [],
agentType: 'codex',
providerSession: { key: 'session_id', id: 'sess-live' },
...overrides
}
}
function buildEntries(overrides: Partial<Parameters<typeof buildSearchableWorkspaceTabs>[0]> = {}) {
const worktree = makeWorktree()
const tab = makeUnifiedTab()
return buildSearchableWorkspaceTabs({
worktrees: [worktree],
repoMap: new Map([[worktree.repoId, { displayName: 'repo/orca' }]]),
worktreeOrder: new Map([[worktree.id, 0]]),
unifiedTabsByWorktree: { [worktree.id]: [tab] },
tabsByWorktree: { [worktree.id]: [makeTerminalTab()] },
openFiles: [],
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
activeGroupIdByWorktree: { [worktree.id]: 'group-1' },
groupsByWorktree: { [worktree.id]: [makeGroup()] },
activeWorktreeId: worktree.id,
activeTabType: 'terminal',
activeTabId: 'terminal-1',
activeTabIdByWorktree: { [worktree.id]: 'terminal-1' },
activeFileId: null,
activeFileIdByWorktree: {},
activeTabTypeByWorktree: { [worktree.id]: 'terminal' },
generatedTitlesEnabled: true,
...overrides
})
}
describe('workspace-tab-palette-search', () => {
it('uses terminal tab title precedence and honors generated-title disabling', () => {
const enabledEntries = buildEntries({
tabsByWorktree: {
'wt-1': [
makeTerminalTab({
customTitle: 'Custom Title',
quickCommandLabel: 'Quick Label',
generatedTitle: 'Generated Label'
})
]
}
})
expect(searchWorkspaceTabs(enabledEntries, 'custom')[0]?.title).toBe('Custom Title')
const disabledEntries = buildEntries({
generatedTitlesEnabled: false,
tabsByWorktree: {
'wt-1': [
makeTerminalTab({
title: 'Raw Shell Title',
generatedTitle: 'Generated Label'
})
]
}
})
expect(searchWorkspaceTabs(disabledEntries, 'generated')).toHaveLength(0)
expect(searchWorkspaceTabs(disabledEntries, 'raw')[0]?.title).toBe('Raw Shell Title')
})
it('falls back to the unified terminal label when the legacy terminal record is gone', () => {
const entries = buildEntries({
tabsByWorktree: { 'wt-1': [] },
unifiedTabsByWorktree: {
'wt-1': [makeUnifiedTab({ customLabel: 'Fallback Terminal' })]
}
})
expect(searchWorkspaceTabs(entries, 'fallback')[0]?.title).toBe('Fallback Terminal')
})
it('indexes editor-family tabs through existing editor labels and paths', () => {
const previewRelativePath = path.join('docs', 'readme.md')
const previewPath = path.join(WT_ROOT, previewRelativePath)
const file = makeOpenFile({
id: `${previewPath}:preview`,
filePath: previewPath,
relativePath: previewRelativePath,
mode: 'markdown-preview'
})
const entries = buildEntries({
unifiedTabsByWorktree: {
'wt-1': [
makeUnifiedTab({
id: 'editor-preview',
entityId: file.id,
contentType: 'editor',
label: 'ignored'
}),
makeUnifiedTab({
id: 'missing-diff',
entityId: path.join(WT_ROOT, 'missing.ts'),
contentType: 'diff'
})
]
},
openFiles: [file],
activeTabType: 'editor',
activeTabId: null,
activeTabIdByWorktree: {},
activeFileId: file.id,
activeFileIdByWorktree: { 'wt-1': file.id },
activeTabTypeByWorktree: { 'wt-1': 'editor' },
groupsByWorktree: {
'wt-1': [makeGroup({ activeTabId: 'editor-preview', tabOrder: ['editor-preview'] })]
}
})
expect(entries.map((entry) => entry.tab.id)).toEqual(['editor-preview'])
expect(searchWorkspaceTabs(entries, 'preview')[0]?.title).toBe('readme.md (preview)')
expect(searchWorkspaceTabs(entries, path.join('docs', 'readme'))[0]?.secondaryRange).toEqual({
start: 0,
end: 11
})
})
it('indexes all editor-family content types when their backing file is open', () => {
const editorFile = makeOpenFile({
id: SRC_APP_PATH,
filePath: SRC_APP_PATH,
relativePath: SRC_APP_RELATIVE_PATH,
mode: 'edit'
})
const diffFile = makeOpenFile({
id: 'wt-1::diff::staged::src/app.ts',
filePath: SRC_APP_PATH,
relativePath: SRC_APP_RELATIVE_PATH,
mode: 'diff',
diffSource: 'staged'
})
const conflictReviewFile = makeOpenFile({
id: 'wt-1::conflict-review',
filePath: WT_ROOT,
relativePath: 'Conflict Review',
mode: 'conflict-review'
})
const checkDetailsFile = makeOpenFile({
id: 'wt-1::check-details::check-run:42',
filePath: WT_ROOT,
relativePath: 'CI / Typecheck',
mode: 'check-details'
})
const files = [editorFile, diffFile, conflictReviewFile, checkDetailsFile]
const entries = buildEntries({
unifiedTabsByWorktree: {
'wt-1': [
makeUnifiedTab({
id: 'editor-tab',
entityId: editorFile.id,
contentType: 'editor'
}),
makeUnifiedTab({
id: 'diff-tab',
entityId: diffFile.id,
contentType: 'diff'
}),
makeUnifiedTab({
id: 'conflict-tab',
entityId: conflictReviewFile.id,
contentType: 'conflict-review'
}),
makeUnifiedTab({
id: 'check-tab',
entityId: checkDetailsFile.id,
contentType: 'check-details'
})
]
},
openFiles: files,
groupsByWorktree: {
'wt-1': [
makeGroup({
activeTabId: 'editor-tab',
tabOrder: ['editor-tab', 'diff-tab', 'conflict-tab', 'check-tab']
})
]
}
})
expect(entries.map((entry) => entry.tab.contentType)).toEqual([
'editor',
'diff',
'conflict-review',
'check-details'
])
expect(searchWorkspaceTabs(entries, 'staged diff')[0]?.tabId).toBe('diff-tab')
expect(searchWorkspaceTabs(entries, 'conflict review')[0]?.tabId).toBe('conflict-tab')
expect(searchWorkspaceTabs(entries, 'typecheck')[0]?.tabId).toBe('check-tab')
})
it('attaches live, retained, and sleeping agent metadata only to matching terminal tabs', () => {
const retainedEntry = makeAgentEntry({
paneKey: 'terminal-1:leaf-b',
prompt: 'Retained branch cleanup',
tabId: undefined,
providerSession: { key: 'session_id', id: 'sess-retained' }
})
const retained: RetainedAgentEntry = {
entry: retainedEntry,
worktreeId: 'wt-1',
tab: makeTerminalTab({ id: 'terminal-1', title: 'Retained Title' }),
agentType: 'codex',
startedAt: 1
}
const sleeping: SleepingAgentSessionRecord = {
paneKey: 'terminal-1:leaf-c',
tabId: 'terminal-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'sess-sleeping' },
prompt: 'Sleeping deployment notes',
state: 'waiting',
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep'
}
const entries = buildEntries({
agentStatusByPaneKey: {
'terminal-1:leaf-a': makeAgentEntry(),
'terminal-2:leaf-x': makeAgentEntry({
paneKey: 'terminal-2:leaf-x',
tabId: 'terminal-2',
prompt: 'Wrong tab prompt'
}),
'terminal-1:leaf-y': makeAgentEntry({
paneKey: 'terminal-1:leaf-y',
worktreeId: 'wt-other',
prompt: 'Wrong worktree prompt'
})
},
retainedAgentsByPaneKey: { [retained.entry.paneKey]: retained },
sleepingAgentSessionsByPaneKey: { [sleeping.paneKey]: sleeping }
})
expect(searchWorkspaceTabs(entries, 'websocket')[0]?.secondaryText).toBe(
'Implement the websocket retry loop'
)
expect(searchWorkspaceTabs(entries, 'retained')[0]?.secondaryText).toBe(
'Retained branch cleanup'
)
expect(searchWorkspaceTabs(entries, 'sess-sleeping')).toHaveLength(1)
expect(searchWorkspaceTabs(entries, 'wrong tab')).toHaveLength(0)
expect(searchWorkspaceTabs(entries, 'wrong worktree')).toHaveLength(0)
})
it('deduplicates live, retained, and sleeping metadata by pane key with live preferred', () => {
const retainedEntry = makeAgentEntry({
paneKey: 'terminal-1:leaf-a',
prompt: 'Retained duplicate prompt'
})
const entries = buildEntries({
agentStatusByPaneKey: {
'terminal-1:leaf-a': makeAgentEntry({ prompt: 'Live duplicate prompt' })
},
retainedAgentsByPaneKey: {
'terminal-1:leaf-a': {
entry: retainedEntry,
worktreeId: 'wt-1',
tab: makeTerminalTab(),
agentType: 'codex',
startedAt: 1
}
},
sleepingAgentSessionsByPaneKey: {
'terminal-1:leaf-a': {
paneKey: 'terminal-1:leaf-a',
tabId: 'terminal-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'sess-sleeping' },
prompt: 'Sleeping duplicate prompt',
state: 'waiting',
capturedAt: 1,
updatedAt: 1
}
}
})
expect(searchWorkspaceTabs(entries, 'live duplicate')[0]?.secondaryText).toBe(
'Live duplicate prompt'
)
expect(searchWorkspaceTabs(entries, 'retained duplicate')).toHaveLength(0)
expect(searchWorkspaceTabs(entries, 'sleeping duplicate')).toHaveLength(0)
})
it('orders empty-query results by current tab, current worktree, and tab position', () => {
const current = makeUnifiedTab({ id: 'tab-current', entityId: 'terminal-current' })
const sibling = makeUnifiedTab({
id: 'tab-sibling',
entityId: 'terminal-sibling',
sortOrder: 1
})
const other = makeUnifiedTab({
id: 'tab-other',
entityId: 'terminal-other',
worktreeId: 'wt-2',
groupId: 'group-2'
})
const entries = buildEntries({
worktrees: [
makeWorktree({ id: 'wt-1', displayName: 'Current WT' }),
makeWorktree({ id: 'wt-2', repoId: 'repo-2', displayName: 'Other WT' })
],
repoMap: new Map([
['repo-1', { displayName: 'repo/current' }],
['repo-2', { displayName: 'repo/other' }]
]),
worktreeOrder: new Map([
['wt-1', 1],
['wt-2', 2]
]),
unifiedTabsByWorktree: { 'wt-1': [sibling, current], 'wt-2': [other] },
tabsByWorktree: {
'wt-1': [
makeTerminalTab({ id: 'terminal-sibling', title: 'Sibling' }),
makeTerminalTab({ id: 'terminal-current', title: 'Current' })
],
'wt-2': [makeTerminalTab({ id: 'terminal-other', worktreeId: 'wt-2', title: 'Other' })]
},
groupsByWorktree: {
'wt-1': [
makeGroup({
activeTabId: 'tab-current',
tabOrder: ['tab-current', 'tab-sibling']
})
],
'wt-2': [makeGroup({ id: 'group-2', worktreeId: 'wt-2', tabOrder: ['tab-other'] })]
},
activeTabId: 'terminal-current',
activeTabIdByWorktree: { 'wt-1': 'terminal-current' }
})
expect(searchWorkspaceTabs(entries, '').map((result) => result.entityId)).toEqual([
'terminal-current',
'terminal-sibling',
'terminal-other'
])
})
})

View File

@ -0,0 +1,242 @@
import { getEditorDisplayLabel } from '@/components/editor/editor-labels'
import type { OpenFile } from '@/store/slices/editor'
import {
resolveTerminalTabTitle,
resolveUnifiedTabLabel
} from '../../../shared/tab-title-resolution'
import type { Tab, TabContentType, TabGroup, TerminalTab, Worktree } from '../../../shared/types'
import {
collectAgentMetadataForTerminal,
type AgentMetadata,
type WorkspaceTabAgentMetadataState
} from './workspace-tab-agent-metadata'
export {
searchWorkspaceTabs,
type WorkspaceTabPaletteSearchResult
} from './workspace-tab-palette-results'
export type WorkspaceTabContentType =
| 'terminal'
| 'editor'
| 'diff'
| 'conflict-review'
| 'check-details'
export type SearchableWorkspaceTab = {
tab: Tab & { contentType: WorkspaceTabContentType }
worktree: Worktree
repoName: string
worktreeSortIndex: number
groupSortIndex: number
tabSortIndex: number
title: string
secondaryText: string
titleSearchText: string
secondarySearchTexts: string[]
agentMetadata: AgentMetadata[]
isCurrentTab: boolean
isCurrentWorktree: boolean
}
type WorkspaceTabPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator'
export type BuildSearchableWorkspaceTabsOptions = WorkspaceTabAgentMetadataState & {
worktrees: readonly Worktree[]
repoMap: ReadonlyMap<string, { displayName?: string | null }>
worktreeOrder: ReadonlyMap<string, number>
unifiedTabsByWorktree: Record<string, readonly Tab[] | undefined>
tabsByWorktree: Record<string, readonly TerminalTab[] | undefined>
openFiles: readonly OpenFile[]
activeGroupIdByWorktree: Record<string, string | undefined>
groupsByWorktree: Record<string, readonly TabGroup[] | undefined>
activeWorktreeId: string | null
activeTabType: WorkspaceTabPaletteActiveTabType
activeTabId: string | null
activeTabIdByWorktree: Record<string, string | null | undefined>
activeFileId: string | null
activeFileIdByWorktree: Record<string, string | null | undefined>
activeTabTypeByWorktree: Record<string, WorkspaceTabPaletteActiveTabType | undefined>
generatedTitlesEnabled: boolean
}
function getActiveUnifiedTabId({
worktreeId,
activeWorktreeId,
activeTabType,
activeGroupIdByWorktree,
groupsByWorktree
}: Pick<
BuildSearchableWorkspaceTabsOptions,
'activeGroupIdByWorktree' | 'activeTabType' | 'activeWorktreeId' | 'groupsByWorktree'
> & {
worktreeId: string
}): string | null {
if (activeWorktreeId !== worktreeId) {
return null
}
const activeGroupId = activeGroupIdByWorktree[worktreeId]
const activeGroup = activeGroupId
? (groupsByWorktree[worktreeId] ?? []).find((group) => group.id === activeGroupId)
: undefined
const activeUnifiedTabId = activeGroup?.activeTabId ?? null
return activeTabType === 'terminal' || activeTabType === 'editor' ? activeUnifiedTabId : null
}
function isCurrentWorkspaceTab({
tab,
activeWorktreeId,
activeTabType,
activeTabId,
activeTabIdByWorktree,
activeFileId,
activeFileIdByWorktree,
activeTabTypeByWorktree,
activeUnifiedTabId
}: Pick<
BuildSearchableWorkspaceTabsOptions,
| 'activeFileId'
| 'activeFileIdByWorktree'
| 'activeTabId'
| 'activeTabIdByWorktree'
| 'activeTabType'
| 'activeTabTypeByWorktree'
| 'activeWorktreeId'
> & {
tab: Tab & { contentType: WorkspaceTabContentType }
activeUnifiedTabId: string | null
}): boolean {
if (tab.worktreeId !== activeWorktreeId) {
return false
}
const visibleType = tab.contentType === 'terminal' ? 'terminal' : 'editor'
const storedType = activeTabTypeByWorktree[tab.worktreeId] ?? activeTabType
if (storedType !== visibleType || activeUnifiedTabId !== tab.id) {
return false
}
if (visibleType === 'terminal') {
return (activeTabIdByWorktree[tab.worktreeId] ?? activeTabId) === tab.entityId
}
return (activeFileIdByWorktree[tab.worktreeId] ?? activeFileId) === tab.entityId
}
function isWorkspaceTabContentType(
contentType: TabContentType
): contentType is WorkspaceTabContentType {
return (
contentType === 'terminal' ||
contentType === 'editor' ||
contentType === 'diff' ||
contentType === 'conflict-review' ||
contentType === 'check-details'
)
}
export function buildSearchableWorkspaceTabs({
worktrees,
repoMap,
worktreeOrder,
unifiedTabsByWorktree,
tabsByWorktree,
openFiles,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
sleepingAgentSessionsByPaneKey,
activeGroupIdByWorktree,
groupsByWorktree,
activeWorktreeId,
activeTabType,
activeTabId,
activeTabIdByWorktree,
activeFileId,
activeFileIdByWorktree,
activeTabTypeByWorktree,
generatedTitlesEnabled
}: BuildSearchableWorkspaceTabsOptions): SearchableWorkspaceTab[] {
const entries: SearchableWorkspaceTab[] = []
const openFilesById = new Map(openFiles.map((file) => [file.id, file]))
for (const worktree of worktrees) {
const repoName = repoMap.get(worktree.repoId)?.displayName ?? ''
const worktreeSortIndex = worktreeOrder.get(worktree.id) ?? Number.MAX_SAFE_INTEGER
const activeUnifiedTabId = getActiveUnifiedTabId({
worktreeId: worktree.id,
activeWorktreeId,
activeTabType,
activeGroupIdByWorktree,
groupsByWorktree
})
const groups = groupsByWorktree[worktree.id] ?? []
const groupOrder = new Map(groups.map((group, index) => [group.id, index]))
const tabOrder = new Map<string, number>()
for (const group of groups) {
group.tabOrder.forEach((tabId, index) => tabOrder.set(tabId, index))
}
const terminalTabs = new Map((tabsByWorktree[worktree.id] ?? []).map((tab) => [tab.id, tab]))
for (const rawTab of unifiedTabsByWorktree[worktree.id] ?? []) {
if (!isWorkspaceTabContentType(rawTab.contentType)) {
continue
}
const tab = rawTab as Tab & { contentType: WorkspaceTabContentType }
const isCurrentTab = isCurrentWorkspaceTab({
tab,
activeWorktreeId,
activeTabType,
activeTabId,
activeTabIdByWorktree,
activeFileId,
activeFileIdByWorktree,
activeTabTypeByWorktree,
activeUnifiedTabId
})
const baseEntry = {
tab,
worktree,
repoName,
worktreeSortIndex,
groupSortIndex: groupOrder.get(tab.groupId) ?? Number.MAX_SAFE_INTEGER,
tabSortIndex: tabOrder.get(tab.id) ?? tab.sortOrder,
isCurrentTab,
isCurrentWorktree: activeWorktreeId === worktree.id
}
if (tab.contentType === 'terminal') {
const terminalTab = terminalTabs.get(tab.entityId)
const title = terminalTab
? resolveTerminalTabTitle(terminalTab, generatedTitlesEnabled, 'Terminal')
: resolveUnifiedTabLabel(tab, generatedTitlesEnabled, 'Terminal')
entries.push({
...baseEntry,
title,
secondaryText: 'Terminal tab',
titleSearchText: title,
secondarySearchTexts: ['Terminal tab'],
agentMetadata: collectAgentMetadataForTerminal({
terminalTabId: tab.entityId,
worktreeId: worktree.id,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
sleepingAgentSessionsByPaneKey
})
})
continue
}
const file = openFilesById.get(tab.entityId)
if (!file || file.worktreeId !== worktree.id) {
continue
}
const title = getEditorDisplayLabel(file)
entries.push({
...baseEntry,
title,
secondaryText: file.relativePath,
titleSearchText: title,
secondarySearchTexts: [file.relativePath, file.filePath],
agentMetadata: []
})
}
}
return entries
}