From 606eef5839740b560876a7945ac79b7e44b52cfb Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 29 May 2026 16:14:57 -0400 Subject: [PATCH] Add direct URL and file entry to tab menu (#3026) Co-authored-by: Orca --- docs/reference/direct-url-or-file-entry.md | 151 +++++++++ .../runtime-home-service.test.ts | 1 + src/main/codex-accounts/service.test.ts | 1 + src/renderer/src/components/QuickOpen.tsx | 127 +------- src/renderer/src/components/Terminal.tsx | 12 +- .../src/components/quick-open-file-list.ts | 139 +++++++++ .../components/settings/ExperimentalPane.tsx | 43 +++ .../settings/experimental-search.ts | 23 +- .../src/components/tab-bar/TabBar.tsx | 72 ++++- .../components/tab-bar/TabBarCreateEntry.tsx | 294 ++++++++++++++++++ .../tab-bar/tab-agent-launch-options.test.ts | 33 ++ .../tab-bar/tab-agent-launch-options.ts | 73 +++++ .../tab-bar/tab-create-entry-action.test.ts | 197 ++++++++++++ .../tab-bar/tab-create-entry-action.ts | 236 ++++++++++++++ .../tab-create-entry-classifier.test.ts | 149 +++++++++ .../tab-bar/tab-create-entry-classifier.ts | 292 +++++++++++++++++ .../components/tab-group/TabGroupPanel.tsx | 1 + .../tab-group/useTabGroupWorkspaceModel.ts | 4 + src/shared/constants.ts | 1 + src/shared/telemetry-events.ts | 1 + src/shared/types.ts | 3 + 21 files changed, 1728 insertions(+), 125 deletions(-) create mode 100644 docs/reference/direct-url-or-file-entry.md create mode 100644 src/renderer/src/components/quick-open-file-list.ts create mode 100644 src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-agent-launch-options.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-action.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-create-entry-classifier.ts diff --git a/docs/reference/direct-url-or-file-entry.md b/docs/reference/direct-url-or-file-entry.md new file mode 100644 index 000000000..b5ea3c0c7 --- /dev/null +++ b/docs/reference/direct-url-or-file-entry.md @@ -0,0 +1,151 @@ +# Direct URL Or File Entry + +## Problem + +The tab bar `+` menu only offers fixed actions: terminal, browser, new markdown, and open markdown in `src/renderer/src/components/tab-bar/TabBar.tsx`. It has no text entry point for a user who already knows the URL or file path they want. + +Quick Open already loads files for the active worktree through `listRuntimeFiles`, watches the active SSH target status, excludes nested linked worktrees, ranks via `prepareQuickOpenFiles`/`rankQuickOpenFiles`, and opens a selected match with `openFile`. That flow is modal and file-only. It does not live in the `+` menu, does not accept URLs, and cannot create a named new file from the typed query. + +The runtime file client has the required local/SSH/runtime primitives, with caveats: + +- `listRuntimeFiles(context, { rootPath, excludePaths })` returns relative paths only and can fail for auth, missing provider, ripgrep/size, or stale worktree reasons. +- `statRuntimePath(context, absolutePath)` is a one-path existence/type check. +- `createRuntimePath(context, absolutePath, 'file')` creates a single empty file and creates parent directories on the current local, SSH, and runtime-backed paths. Directory creation has separate no-clobber/recursive semantics elsewhere in the file stack, so v1 should create files directly and not expose directory creation as a separate action. + +## Goal + +Add an entry field at the top of the tab bar `+` menu so users can type: + +1. A URL to open an Orca browser tab. +2. An existing file name/path to open an editor tab. +3. A new relative file path to create in the active worktree and open. + +The behavior must work from both the titlebar tab strip and split-group tab strips. File operations must route through `RuntimeFileOperationArgs`; browser/editor creation must preserve the target group. + +## Non-goals + +- Do not replace global Quick Open. +- Do not add persisted launcher state. +- Do not support standalone directory creation. +- Do not open external URLs outside Orca. +- Do not make URL/file detection configurable. + +## Design + +1. Add a `TabBarCreateEntry` surface inside `TabBar`'s dropdown content above the fixed rows. Use the existing `DropdownMenu` shell, but render the input in a plain form/container, not as a `DropdownMenuItem`; Radix menu item typeahead/selection should not own text input keystrokes. Stop propagation only where required for input typing, and close the dropdown only after a successful submit. + +2. Extend `TabBarProps` with a presentation callback that resolves success by returning and failure by throwing: + + ```ts + onOpenEntry?: (args: { query: string; worktreeId: string; groupId: string }) => Promise + ``` + + `TabBar` owns input text, pending state, focus, and dropdown close behavior. It does not create browser tabs or files. `groupId` should be `groupId ?? worktreeId`, matching the existing `resolvedGroupId` fallback. + +3. Add a small hook/helper pair instead of duplicating Quick Open logic: + + - `useTabEntryFileList` loads the file list when the menu opens, using the same inputs as `QuickOpen`: active worktree path, `getConnectionId(worktreeId)`, nested worktree exclusions, and active SSH target status. It cancels stale requests on close or key changes. + - A pure classifier/opening helper accepts the query, file-list snapshot, load/error state, worktree metadata, runtime context, and target group. + +4. Wire the callback in both owners: + + - `Terminal.tsx` titlebar fallback resolves the current active worktree and target group the same way `handleNewTab` / `handleNewBrowserTab` do. + - `useTabGroupWorkspaceModel` passes its explicit `worktreeId` and `groupId`. Do not rely on ambient `activeGroupIdByWorktree`; split-group `+` can be invoked from an unfocused group. + +5. Classify submissions in this order: + + - Empty after trim: reject inline. + - Explicit URL: accept only `http://` and `https://` URLs with a parseable host. + - Existing file: once the file-list snapshot is ready, normalize query separators for matching, prefer exact relative-path match, then exact basename match, then `rankQuickOpenFiles`. Before opening, `statRuntimePath` the matched absolute path and reject directories/stale missing matches instead of blindly opening stale list entries. + - Host-like URL: only after there is no existing file match, accept strict bare hosts such as `example.com`, `localhost:3000`, or `127.0.0.1:3000`, normalized to `https://...` when no scheme is present. Do not run host-like URL parsing for bare input containing `/` or `\`; `new URL('https://docs/readme.md')` parses, so parsing alone is not a path/file guard. Also reject common source/document filename extensions such as `md`, `ts`, `tsx`, `js`, `jsx`, `json`, `yml`, `yaml`, `toml`, `css`, `html`, and `py` so `README.md` and `src/foo.test.ts` stay file/create candidates. + - New file: only after file listing has completed successfully with no existing-file match and no host-like URL match. Treat the query as a relative worktree path. If listing fails, allow explicit `http://` / `https://` URLs only; keep bare host-like inputs blocked because they cannot be disambiguated from files. + +6. Validate new file paths before joining: + + - Reject POSIX absolute paths, Windows drive paths, UNC paths, `~`, empty path, trailing slash, control characters, `.` / `..` segments, and empty raw segments such as `a//b`. + - Normalize `\` to `/` only after absolute-path checks, then run segment validation on the normalized path too so traversal like `a\..\b` is still rejected. + - Build the absolute path with `joinPath(worktreePath, relativePath)`, then create with `createRuntimePath(context, absolutePath, 'file')`. + - On `EEXIST` / "exists", immediately `statRuntimePath`; if it is now a file, open it. If it is a directory, show an error. This handles another window/process winning the create race. + +7. Open actions: + + - URL: in paired web clients, call `createWebRuntimeSessionBrowserTab({ worktreeId, url, targetGroupId: groupId })`; otherwise call `createBrowserTab(worktreeId, url, { activate: true, targetGroupId: groupId, title: url })`. Do not use `openNewBrowserTabInActiveWorkspace`; it only opens the default URL. + - Existing/new file: call `openFile(fileInfo, { preview: false, targetGroupId: groupId })` with `language: detectLanguage(relativePath)`. Include the active runtime environment owner as `runtimeEnvironmentId` through the normal `openFile` fallback; do not suppress runtime ownership. + +8. Preserve current fixed actions. Existing `New Terminal`, `New Browser Tab`, `New Markdown`, `Open Markdown...`, and quick-launch rows remain below the entry and keep their current shortcuts/icons. + +9. Surface errors with existing toast or compact inline text. Keep the dropdown open and preserve the query on validation/runtime errors. For Quick Open's special ripgrep guidance, either extract its parser/UI deliberately or show the cleaned error string; do not duplicate a private parser inline. + +## Data Flow + +- User opens the `+` menu. +- `TabBarCreateEntry` focuses the input and `useTabEntryFileList` starts/reuses the menu-local file-list request. +- User types; the menu may show the best existing-file match or "create file" affordance based on the current snapshot. +- Enter calls `onOpenEntry({ query, worktreeId, groupId })`. +- Helper classifies and dispatches: + - URL -> browser tab creation with target group. + - Existing file -> stat matched path -> `openFile(..., { targetGroupId })`. + - New file -> validate -> `createRuntimePath(..., 'file')` -> `openFile(..., { targetGroupId })`. +- Success closes the dropdown. Failure keeps it open. + +## Edge Cases + +- No active worktree: disable the entry, including URL entry, because Orca browser tabs are worktree-scoped. +- SSH/runtime connection not ready: mirror Quick Open by keying the list request on active target status. Non-URL submissions are disabled while loading/connecting to avoid creating a duplicate before the real list arrives. +- File listing failure: explicit `http://` / `https://` URL submissions still work; file and bare host-like submissions are blocked with the cleaned list error. +- Ambiguous host-like/file names: an existing listed file wins over bare host-like URL normalization. Explicit `http://` or `https://` input is the escape hatch when the user wants a browser tab despite a file-name collision. +- File list stale because another window/process added or removed a file: stat before opening matched files; handle create `EEXIST` by stat-and-open. +- Existing directory match: show an error; do not open as an editor tab. +- Internal spaces in file paths are allowed. Leading/trailing whitespace is trimmed. Control characters are rejected. +- Windows-style separators match existing relative paths after normalization, but Windows absolute and UNC paths are rejected for creation. +- Duplicate Enter while pending is disabled. +- Browser creation in paired web/mobile clients must use `createWebRuntimeSessionBrowserTab`; local desktop uses `createBrowserTab`. +- External file-watch invalidation is not required for v1. The menu-local list reloads on each open and on worktree/connection/status changes; successful create can close the menu without mutating the list. + +## Test Plan + +- Unit test URL classification: schemes, host-like domains, localhost/IP ports, listed file named `example.com` winning over host-like normalization, `README.md`/`readme.md`, `src/foo.test.ts`, `docs/readme.md`, whitespace, and invalid schemes. +- Unit test path validation: Windows/POSIX absolute paths, UNC, `~`, traversal, empty segments, trailing slash, control characters, spaces, and separator normalization. +- Unit test existing-file selection with `prepareQuickOpenFiles`/`rankQuickOpenFiles`: exact path beats basename, basename beats fuzzy, stale stat failure blocks open, directory stat blocks open. +- Unit/helper test new-file creation with `RuntimeFileOperationArgs`, `createRuntimePath`, `statRuntimePath`, EEXIST stat-and-open, and SSH/runtime connection context. +- Component test `TabBar`: input renders above fixed rows, focuses on open, typing does not close the menu, Enter awaits the callback, success closes, failure preserves text, fixed actions still fire. +- Store/model tests: titlebar and split-group callbacks pass `targetGroupId`; URL creation uses `createWebRuntimeSessionBrowserTab` in web runtime and `createBrowserTab` otherwise. +- Electron validation: open URL, open existing file by exact path/name, create nested new file, invalid traversal/absolute path error, and smoke-test existing menu actions. + +## UI Quality Bar + +- Follow `docs/STYLEGUIDE.md` and existing `DropdownMenu`/`Input` tokens. Do not add custom colors, shadows, or a modal-like panel inside the menu. +- The input is the first focus target and must not break menu keyboarding. +- The menu remains compact; loading, match preview, create preview, and error rows fit at the current menu width without overlap or awkward height jumps. +- Long typed paths scroll/truncate within the input; fixed rows retain icons, shortcuts, hover states, and dense spacing. + +## Review Screenshots + +Attach evidence to the PR conversation; do not commit images. + +1. `+` menu open in a normal workspace with the entry focused and fixed rows visible. +2. Typed URL state. +3. Typed existing-file query with visible best match. +4. Typed new-file path/create state. +5. Rejected absolute/traversal path error. +6. Adjacent-feature smoke: fixed menu rows still visible and aligned. + +## Rollout + +1. Add classifier/path-validation helper and tests. +2. Add menu-local file-list hook by extracting the reusable Quick Open loading inputs, not by copying private UI-only parsing. +3. Add `TabBarCreateEntry` and wire it into `TabBar`. +4. Add titlebar and split-group callbacks. +5. Add component/store tests. +6. Run typecheck, lint, targeted tests, UI review, and Electron validation with screenshots. + +## Lightweight Eng Review + +- Scope: Correctly scoped to the tab bar `+` menu. The implementation must not mutate Quick Open behavior except for extracting reusable search/listing code. +- Architecture/data flow: Good if `TabBar` stays presentational and all worktree/runtime/group decisions live in owners plus a shared helper. The original `onOpenEntry(query, groupId?)` shape was under-specified; include `worktreeId` and a resolved `groupId`. +- Failure modes: Must explicitly handle loading list, failed list, stale list, directory matches, create races, no active worktree, no SSH provider yet, and paired web runtime browser creation. +- Feasibility: Specific URL browser tabs cannot go through `openNewBrowserTabInActiveWorkspace` because that action uses the default URL. `createRuntimePath(..., 'file')` is feasible and creates parent directories for current local/SSH/runtime paths, but do not expose recursive directory creation as a separate v1 behavior. +- Concurrency/invalidation: A menu-local file list is acceptable if every matched file is statted before open and `EEXIST` is handled on create. No global cache is needed. +- Performance/blast radius: Listing on menu open has Quick Open's cost class, but the menu is more casually opened than the modal; cancel stale loads and key requests tightly by worktree path, connection, exclusions, and SSH status. +- Tests: Add pure helper tests first; then component and store/model tests. Electron screenshots are required because Radix menu focus/input behavior is the highest-risk UI part. +- Residual risk: Host-like URL heuristics can surprise users. Keep the heuristic narrow and prefer file matches over host-like normalization whenever the query contains path separators or matches a listed file. diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 1235ce5d3..c59b6f162 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -124,6 +124,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalTerminalAttention: false, experimentalCompactWorktreeCards: false, experimentalWorktreeSymlinks: false, + experimentalUnifiedNewTabLauncher: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 8ef253b85..52ef01a22 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -111,6 +111,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalTerminalAttention: false, experimentalCompactWorktreeCards: false, experimentalWorktreeSymlinks: false, + experimentalUnifiedNewTabLauncher: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index e2b91627e..ba0bf4f30 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -1,13 +1,11 @@ /* oxlint-disable max-lines */ -import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useDeferredValue, useMemo, useState } from 'react' import { AlertTriangle, Check, Copy } from 'lucide-react' import { useAppStore } from '@/store' -import { useActiveWorktree, useWorktreesForRepo } from '@/store/selectors' +import { useActiveWorktree } from '@/store/selectors' import { detectLanguage } from '@/lib/language-detect' import { joinPath } from '@/lib/path' -import { getConnectionId } from '@/lib/connection-context' import { getFileTypeIcon } from '@/lib/file-type-icons' -import { listRuntimeFiles } from '@/runtime/runtime-file-client' import { CommandDialog, CommandInput, @@ -16,6 +14,7 @@ import { CommandItem } from '@/components/ui/command' import { prepareQuickOpenFiles, rankQuickOpenFiles } from '@/components/quick-open-search' +import { useRuntimeFileListForWorktree } from '@/components/quick-open-file-list' /** * Parses the install-ripgrep guidance message produced by the relay's @@ -49,18 +48,6 @@ function parseInstallRgGuidance( } } -function isNestedPath(parentPath: string, childPath: string): boolean { - const windowsPath = /^[a-zA-Z]:[\\/]/.test(parentPath) || parentPath.startsWith('\\\\') - const parent = parentPath.replace(/[\\/]+$/, '').replace(/\\/g, '/') - const child = childPath.replace(/\\/g, '/') - // Why: Windows paths are case-insensitive and can arrive with mixed slash - // styles from git/Electron. Normalize before deciding whether to exclude a - // nested linked worktree from Quick Open scans. - const comparableParent = windowsPath ? parent.toLowerCase() : parent - const comparableChild = windowsPath ? child.toLowerCase() : child - return comparableChild.startsWith(`${comparableParent}/`) -} - function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Element { return ( @@ -138,52 +125,16 @@ export default function QuickOpen(): React.JSX.Element | null { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const openFile = useAppStore((s) => s.openFile) const activeWorktree = useActiveWorktree() - const repoWorktrees = useWorktreesForRepo(activeWorktree?.repoId ?? null) const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) - const [files, setFiles] = useState([]) - const [loading, setLoading] = useState(false) - const [loadError, setLoadError] = useState(null) - const lastFilesRequestKeyRef = useRef('') + const { files, loading, loadError } = useRuntimeFileListForWorktree({ + enabled: visible, + worktreeId: activeWorktreeId + }) const worktreePath = activeWorktree?.path ?? null - const excludePathsKey = useMemo(() => { - if (!activeWorktreeId || !worktreePath || repoWorktrees.length === 0) { - return '' - } - // Why: when the active worktree is the repo root (isMainWorktree), linked - // worktrees are nested subdirectories. Restricting the exclusion scan to - // sibling worktrees in the same repo avoids rescanning the entire store. - return repoWorktrees - .filter( - (worktree) => worktree.id !== activeWorktreeId && isNestedPath(worktreePath, worktree.path) - ) - .map((worktree) => worktree.path) - .sort() - .join('\n') - }, [activeWorktreeId, repoWorktrees, worktreePath]) - - const connectionId = useMemo( - () => getConnectionId(activeWorktreeId ?? null) ?? undefined, - [activeWorktreeId] - ) - - // Why: when quick-open opens before the SSH connection is established, - // fs:listFiles returns [] (no provider yet). Watching the active target's - // connection status lets the file-load effect re-fire automatically once - // that specific connection comes up, without being affected by unrelated - // SSH targets reconnecting. - const activeTargetStatus = useAppStore((s) => - connectionId ? s.sshConnectionStates.get(connectionId)?.status : undefined - ) - const filesRequestKey = useMemo( - () => - `${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludePathsKey}\n${activeTargetStatus ?? ''}`, - [connectionId, excludePathsKey, worktreePath, activeTargetStatus] - ) - // Why: reset input only on open. Keeping this out of the file-load effect // prevents unrelated store updates (which can produce a new excludePaths // array reference) from wiping a query the user is currently typing. @@ -195,70 +146,6 @@ export default function QuickOpen(): React.JSX.Element | null { } } - // Load file list when opened - useEffect(() => { - if (!visible) { - return - } - - if (!worktreePath) { - setFiles([]) - setLoadError(null) - setLoading(false) - return - } - - let cancelled = false - const requestKeyChanged = lastFilesRequestKeyRef.current !== filesRequestKey - if (requestKeyChanged) { - setFiles([]) - } - lastFilesRequestKeyRef.current = filesRequestKey - setLoadError(null) - setLoading(true) - - const excludePaths = excludePathsKey ? excludePathsKey.split('\n') : undefined - - void listRuntimeFiles( - { - settings: useAppStore.getState().settings, - worktreeId: activeWorktreeId, - worktreePath, - connectionId - }, - { - rootPath: worktreePath, - excludePaths - } - ) - .then((result) => { - if (!cancelled) { - setFiles(result) - } - }) - .catch((error) => { - if (!cancelled) { - setFiles([]) - // Why: treating list-files failures as "no matches" hides the real - // cause when the active worktree path is unauthorized or stale. - // Strip Electron's "Error invoking remote method 'fs:listFiles': - // Error:" wrapper so the user sees only the actionable message. - const raw = error instanceof Error ? error.message : String(error) - const cleaned = raw.replace(/^Error invoking remote method '[^']+':\s*Error:\s*/, '') - setLoadError(cleaned) - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false) - } - }) - - return () => { - cancelled = true - } - }, [visible, activeWorktreeId, worktreePath, connectionId, excludePathsKey, filesRequestKey]) - const indexedFiles = useMemo(() => prepareQuickOpenFiles(files), [files]) const filtered = useMemo( () => rankQuickOpenFiles(deferredQuery, indexedFiles), diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index c891fdd95..18e80b857 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -85,6 +85,7 @@ import { } from '../../../shared/keybindings' import { matchesRecentTabSwitcherChord } from '../../../shared/window-shortcut-policy' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' +import { openTabBarEntry, type TabCreateEntryArgs } from './tab-bar/tab-create-entry-action' const EditorPanel = lazy(() => import('./editor/EditorPanel')) @@ -738,6 +739,10 @@ function Terminal(): React.JSX.Element | null { openNewBrowserTabInActiveWorkspace ]) + const handleOpenEntry = useCallback(async (args: TabCreateEntryArgs) => { + await openTabBarEntry(args) + }, []) + const handleDuplicateBrowserTab = useCallback( (browserTabId: string) => { if (!activeWorktreeId) { @@ -1140,7 +1145,7 @@ function Terminal(): React.JSX.Element | null { } // Cmd/Ctrl+Shift+T — reopen closed browser tab when browser is active, - // otherwise reopen the most recently closed editor tab (VS Code–style). + // otherwise reopen the most recently closed editor tab. if (!e.repeat && matchShortcut('tab.reopenClosed')) { e.preventDefault() notifyTerminalCapture('tab.reopenClosed') @@ -1232,7 +1237,7 @@ function Terminal(): React.JSX.Element | null { // Cmd/Ctrl+Shift+] and Cmd/Ctrl+Shift+[ - switch tabs (scoped to the // active tab type). Cmd/Ctrl+Alt+] and Cmd/Ctrl+Alt+[ cycles across // every tab type as an escape hatch from the type-scoped default, and - // mirrors Safari/Chrome's tab-switch chord on macOS. + // matches the platform tab-switch chord on macOS. // Why: use e.code instead of e.key because on macOS, Shift+[ reports '{' // as the key value (the shifted character), not '['. Option+[ also // composes to dead-key / punctuation on many layouts, so matching on @@ -1448,7 +1453,7 @@ function Terminal(): React.JSX.Element | null { {/* Why: once split groups are enabled, each group owns its own tab strip - inline like VS Code. The old titlebar portal stays only as a fallback + inline. The old titlebar portal stays only as a fallback before the root-group layout has been established. */} {activeWorktreeId && !effectiveActiveLayout && @@ -1465,6 +1470,7 @@ function Terminal(): React.JSX.Element | null { onNewTerminalTab={() => handleNewTab()} onNewTerminalWithShell={handleNewTab} onNewBrowserTab={handleNewBrowserTab} + onOpenEntry={handleOpenEntry} onNewFileTab={handleNewFile} onSetCustomTitle={setTabCustomTitle} onSetTabColor={setTabColor} diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts new file mode 100644 index 000000000..ea36297bd --- /dev/null +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -0,0 +1,139 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { Worktree } from '../../../shared/types' +import { getConnectionId } from '@/lib/connection-context' +import { listRuntimeFiles } from '@/runtime/runtime-file-client' +import { useAppStore } from '@/store' +import { useWorktreeById, useWorktreesForRepo } from '@/store/selectors' + +export type RuntimeFileListState = { + files: string[] + loading: boolean + loadError: string | null +} + +export function cleanRuntimeFileListError(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error) + return raw.replace(/^Error invoking remote method '[^']+':\s*Error:\s*/, '') +} + +export function isNestedWorktreePath(parentPath: string, childPath: string): boolean { + const windowsPath = /^[a-zA-Z]:[\\/]/.test(parentPath) || parentPath.startsWith('\\\\') + const parent = parentPath.replace(/[\\/]+$/, '').replace(/\\/g, '/') + const child = childPath.replace(/\\/g, '/') + // Why: Windows paths are case-insensitive and can arrive with mixed slash + // styles from git/Electron. Normalize before deciding whether to exclude a + // nested linked worktree from file scans. + const comparableParent = windowsPath ? parent.toLowerCase() : parent + const comparableChild = windowsPath ? child.toLowerCase() : child + return comparableChild.startsWith(`${comparableParent}/`) +} + +export function getNestedWorktreeExcludePaths( + worktreeId: string, + worktreePath: string, + repoWorktrees: readonly Worktree[] +): string[] { + return repoWorktrees + .filter( + (worktree) => worktree.id !== worktreeId && isNestedWorktreePath(worktreePath, worktree.path) + ) + .map((worktree) => worktree.path) + .sort() +} + +export function useRuntimeFileListForWorktree({ + enabled, + worktreeId +}: { + enabled: boolean + worktreeId: string | null +}): RuntimeFileListState { + const worktree = useWorktreeById(worktreeId) + const repoWorktrees = useWorktreesForRepo(worktree?.repoId ?? null) + const [files, setFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [loadError, setLoadError] = useState(null) + const lastRequestKeyRef = useRef('') + + const worktreePath = worktree?.path ?? null + const excludePathsKey = useMemo(() => { + if (!worktreeId || !worktreePath || repoWorktrees.length === 0) { + return '' + } + return getNestedWorktreeExcludePaths(worktreeId, worktreePath, repoWorktrees).join('\n') + }, [repoWorktrees, worktreeId, worktreePath]) + + const connectionId = useMemo(() => getConnectionId(worktreeId) ?? undefined, [worktreeId]) + const activeTargetStatus = useAppStore((state) => + connectionId ? state.sshConnectionStates.get(connectionId)?.status : undefined + ) + const connectionPending = + activeTargetStatus === 'connecting' || + activeTargetStatus === 'deploying-relay' || + activeTargetStatus === 'reconnecting' + const requestKey = useMemo( + () => + `${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludePathsKey}\n${activeTargetStatus ?? ''}`, + [activeTargetStatus, connectionId, excludePathsKey, worktreePath] + ) + + useEffect(() => { + if (!enabled) { + setLoading(false) + return + } + + if (!worktreeId || !worktreePath) { + setFiles([]) + setLoadError(null) + setLoading(false) + return + } + + let cancelled = false + const requestKeyChanged = lastRequestKeyRef.current !== requestKey + if (requestKeyChanged) { + setFiles([]) + } + lastRequestKeyRef.current = requestKey + setLoadError(null) + setLoading(true) + + const excludePaths = excludePathsKey ? excludePathsKey.split('\n') : undefined + + void listRuntimeFiles( + { + settings: useAppStore.getState().settings, + worktreeId, + worktreePath, + connectionId + }, + { + rootPath: worktreePath, + excludePaths + } + ) + .then((result) => { + if (!cancelled) { + setFiles(result) + } + }) + .catch((error) => { + if (!cancelled) { + setFiles([]) + setLoadError(cleanRuntimeFileListError(error)) + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [connectionId, enabled, excludePathsKey, requestKey, worktreeId, worktreePath]) + + return { files, loading: loading || connectionPending, loadError } +} diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index c80ec4b86..d43903e3e 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -33,6 +33,9 @@ export function ExperimentalPane({ const showWorktreeSymlinks = matchesSettingsSearch(searchQuery, [ EXPERIMENTAL_SEARCH_ENTRY.symlinks ]) + const showUnifiedNewTabLauncher = matchesSettingsSearch(searchQuery, [ + EXPERIMENTAL_SEARCH_ENTRY.unifiedNewTabLauncher + ]) return (
@@ -231,6 +234,46 @@ export function ExperimentalPane({ ) : null} + {showUnifiedNewTabLauncher ? ( + +
+
+ +

+ Type in the New Tab menu to open a terminal, launch an agent, visit a URL, or + open/create a file. +

+
+ +
+
+ ) : null} + {hiddenExperimentalUnlocked ? : null}
) diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 2a67f8794..c6d9b7352 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -79,6 +79,26 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'env', 'node_modules' ] + }, + { + title: 'Smart New Tab menu', + description: + 'Type in the New Tab menu to open a terminal, launch an agent, visit a URL, or open/create a file.', + keywords: [ + 'experimental', + 'smart', + 'new tab', + 'new tab menu', + 'launcher', + 'unified', + 'plus', + 'terminal', + 'agents', + 'claude', + 'codex', + 'url', + 'file' + ] } ] @@ -98,5 +118,6 @@ export const EXPERIMENTAL_SEARCH_ENTRY = { activity: findEntry('Agents View'), terminalAttention: findEntry('Terminal attention'), compactWorktreeCards: findEntry('Compact worktree cards'), - symlinks: findEntry('Symlinks on worktrees') + symlinks: findEntry('Symlinks on worktrees'), + unifiedNewTabLauncher: findEntry('Smart New Tab menu') } as const diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index d99d54c85..f43a47b5c 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -6,9 +6,11 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { SortableContext } from '@dnd-kit/sortable' import { FilePlus, FileText, Globe, Plus, TerminalSquare } from 'lucide-react' +import { toast } from 'sonner' import type { BrowserTab as BrowserTabState, TerminalTab, + TuiAgent, WorkspaceVisibleTabType } from '../../../../shared/types' import { useAppStore } from '../../store' @@ -23,9 +25,12 @@ import { reconcileTabOrder } from './reconcile-order' import type { HoveredTabInsertion, TabDragItemData } from '../tab-group/useTabDragSplit' import { resolveTabIndicatorEdges } from '../tab-group/tab-insertion' import { getEditorDisplayLabel } from '@/components/editor/editor-labels' +import TabBarCreateEntry from './TabBarCreateEntry' import { ShellIcon } from './shell-icons' import { resolveWindowsShellLaunchTarget } from './windows-shell-launch' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' +import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { @@ -36,12 +41,15 @@ import { DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import type { TabCreateEntryArgs } from './tab-create-entry-action' +import { buildTabAgentLaunchOptions, orderTabLaunchAgents } from './tab-agent-launch-options' const isWindows = navigator.userAgent.includes('Windows') const NEW_TAB_MENU_TERMINAL_FOCUS_RETRY_MS = 50 const NEW_TAB_MENU_TERMINAL_FOCUS_TIMEOUT_MS = 5000 type GitStatusEntries = ReturnType['gitStatusByWorktree'][string] const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = [] +const EMPTY_AGENT_CMD_OVERRIDES: Partial> = {} type TabBarProps = { tabs: (TerminalTab & { unifiedTabId?: string })[] @@ -57,6 +65,7 @@ type TabBarProps = { /** On Windows, opens a new terminal with a specific shell instead of the default. */ onNewTerminalWithShell?: (shell: string) => void onNewBrowserTab: () => void + onOpenEntry?: (args: TabCreateEntryArgs) => Promise terminalOnly?: boolean showAgentLaunchItems?: boolean onNewFileTab?: () => void @@ -122,6 +131,7 @@ function TabBarInner({ onNewTerminalTab, onNewTerminalWithShell, onNewBrowserTab, + onOpenEntry, terminalOnly = false, showAgentLaunchItems = true, onNewFileTab, @@ -157,6 +167,34 @@ function TabBarInner({ const defaultWindowsPowerShellImplementation = useAppStore( (s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto' ) + const unifiedNewTabLauncherEnabled = useAppStore( + (s) => s.settings?.experimentalUnifiedNewTabLauncher === true + ) + const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) + const agentCmdOverrides = useAppStore( + (s) => s.settings?.agentCmdOverrides ?? EMPTY_AGENT_CMD_OVERRIDES + ) + const connectionId = useAppStore((s) => { + if (!unifiedNewTabLauncherEnabled) { + return undefined + } + const allWorktrees = Object.values(s.worktreesByRepo ?? {}).flat() + const worktree = allWorktrees.find((w) => w.id === worktreeId) + if (!worktree) { + return undefined + } + const repo = s.repos?.find((r) => r.id === worktree.repoId) + return repo?.connectionId ?? null + }) + const { detectedIds } = useDetectedAgents(connectionId) + const agentLaunchOptions = useMemo( + () => + buildTabAgentLaunchOptions( + orderTabLaunchAgents(defaultAgent, detectedIds ?? []), + agentCmdOverrides + ), + [agentCmdOverrides, defaultAgent, detectedIds] + ) const windowsTerminalCapabilities = useWindowsTerminalCapabilities(isWindows) const resolvedGroupId = groupId ?? worktreeId @@ -221,6 +259,20 @@ function TabBarInner({ const queueTerminalTabFocusAfterNewTabMenuClose = (tabId: string): void => { pendingNewTabMenuFocusRef.current = () => focusTerminalTabSurface(tabId) } + const launchAgentFromNewTabEntry = (agent: TuiAgent): void => { + const option = agentLaunchOptions.find((candidate) => candidate.agent === agent) + const result = launchAgentInNewTab({ + agent, + worktreeId, + groupId: resolvedGroupId, + launchSource: 'tab_bar_quick_launch' + }) + if (!result) { + toast.error(`Could not build launch command for ${option?.label ?? agent}.`) + return + } + queueTerminalTabFocusAfterNewTabMenuClose(result.tabId) + } const runPendingNewTabMenuFocusAfterClose = (): void => { const pendingFocus = pendingNewTabMenuFocusRef.current pendingNewTabMenuFocusRef.current = null @@ -535,7 +587,7 @@ function TabBarInner({ { // Why: terminal-producing menu actions activate a freshly-mounted // xterm. Radix's default focus restore sends focus back to the "+" @@ -544,6 +596,24 @@ function TabBarInner({ runPendingNewTabMenuFocusAfterClose() }} > + {!terminalOnly && onOpenEntry && unifiedNewTabLauncherEnabled ? ( + <> + { + queueNewActiveTerminalFocusAfterNewTabMenuClose() + onNewTerminalTab() + }} + onOpenEntry={onOpenEntry} + onDidOpenEntry={() => setNewTabMenuOpen(false)} + /> + + + ) : null} {isWindows && onNewTerminalWithShell ? ( // Why: previously the Windows path nested shell choices under a // Radix submenu. In practice the submenu frequently failed to open diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx new file mode 100644 index 000000000..61d58cdff --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx @@ -0,0 +1,294 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { FilePlus, FileText, Globe, Loader2, Search } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { AgentIcon } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import { useRuntimeFileListForWorktree } from '../quick-open-file-list' +import { + getTabEntryOptions, + type TabCreateEntryArgs, + type TabEntryActionClassification, + type TabEntryOption +} from './tab-create-entry-action' +import { + findMatchingTabAgentLaunchOptions, + type TabAgentLaunchOption +} from './tab-agent-launch-options' +import type { TuiAgent } from '../../../../shared/types' + +type TabBarCreateEntryProps = { + agentOptions?: readonly TabAgentLaunchOption[] + groupId: string + menuOpen: boolean + onDidOpenEntry?: () => void + onLaunchAgent?: (agent: TuiAgent) => void + onOpenDefaultTerminal?: () => void + onOpenEntry?: (args: TabCreateEntryArgs) => Promise + worktreeId: string +} + +export default function TabBarCreateEntry({ + agentOptions = [], + groupId, + menuOpen, + onDidOpenEntry, + onLaunchAgent, + onOpenDefaultTerminal, + onOpenEntry, + worktreeId +}: TabBarCreateEntryProps): React.JSX.Element { + const [query, setQuery] = useState('') + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + const [selectedIndex, setSelectedIndex] = useState(0) + const inputRef = useRef(null) + const fileList = useRuntimeFileListForWorktree({ enabled: menuOpen, worktreeId }) + + useEffect(() => { + if (!menuOpen) { + setQuery('') + setPending(false) + setError(null) + setSelectedIndex(0) + return + } + requestAnimationFrame(() => inputRef.current?.focus()) + }, [menuOpen]) + + const options = useMemo(() => getTabEntryOptions(query, fileList), [fileList, query]) + const matchingAgentOptions = useMemo( + () => findMatchingTabAgentLaunchOptions(query, agentOptions), + [agentOptions, query] + ) + + useEffect(() => { + setSelectedIndex(0) + }, [query]) + + const disabled = !onOpenEntry + const hasQuery = query.trim().length > 0 + const activeOptions: ActiveOption[] = [ + ...matchingAgentOptions.map((option) => ({ + kind: 'agent' as const, + option + })), + ...options.filter(isActiveEntryOption).map((option) => ({ + kind: 'entry' as const, + option + })) + ] + const activeSelectedIndex = Math.min(selectedIndex, Math.max(activeOptions.length - 1, 0)) + const selectedActiveOption = activeOptions[activeSelectedIndex] + const statusOption = options.find( + (option) => option.classification.kind === 'empty' || option.classification.kind === 'blocked' + ) + const statusMessage = + statusOption?.classification.kind === 'empty' || statusOption?.classification.kind === 'blocked' + ? statusOption.classification.message + : 'URL, file, or new file' + + const submitOption = (option?: ActiveOption) => { + if (disabled || pending) { + return + } + const selectedOption = option ?? selectedActiveOption ?? null + if (!selectedOption) { + if (!hasQuery && onOpenDefaultTerminal) { + onOpenDefaultTerminal() + onDidOpenEntry?.() + return + } + setError(statusMessage) + return + } + if (selectedOption.kind === 'agent') { + onLaunchAgent?.(selectedOption.option.agent) + onDidOpenEntry?.() + return + } + setPending(true) + setError(null) + void onOpenEntry({ + query, + worktreeId, + groupId, + fileList, + classification: selectedOption.option.classification + }) + .then(() => { + onDidOpenEntry?.() + }) + .catch((caught) => { + setError(caught instanceof Error ? caught.message : String(caught)) + }) + .finally(() => { + setPending(false) + }) + } + + return ( +
{ + event.preventDefault() + submitOption() + }} + onKeyDown={(event) => { + if (activeOptions.length > 1 && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { + event.preventDefault() + event.stopPropagation() + setSelectedIndex((current) => { + const delta = event.key === 'ArrowDown' ? 1 : -1 + return (current + delta + activeOptions.length) % activeOptions.length + }) + return + } + if (event.key !== 'Escape') { + event.stopPropagation() + } + }} + onPointerDown={(event) => event.stopPropagation()} + > +
+
+ {error || activeOptions.length > 0 || hasQuery ? ( +
+ {error ? ( + + ) : activeOptions.length > 0 ? ( + activeOptions.map((option, index) => ( + submitOption(option)} + /> + )) + ) : ( + + )} +
+ ) : null} +
+ ) +} + +type ActiveEntryOption = TabEntryOption & { + classification: TabEntryActionClassification +} + +type ActiveOption = + | { + kind: 'agent' + option: TabAgentLaunchOption + } + | { + kind: 'entry' + option: ActiveEntryOption + } + +function isActiveEntryOption(option: TabEntryOption): option is ActiveEntryOption { + return option.classification.kind !== 'empty' && option.classification.kind !== 'blocked' +} + +function getActiveOptionId(option: ActiveOption): string { + return option.kind === 'agent' ? `agent:${option.option.agent}` : option.option.id +} + +function EntryStatusRow({ + loading = false, + message +}: { + loading?: boolean + message: string +}): React.JSX.Element { + return ( +
+ {loading ?
+ ) +} + +function EntryActionRow({ + onClick, + option, + selected +}: { + onClick: () => void + option: ActiveOption + selected: boolean +}): React.JSX.Element { + const presentation = getActionPresentation(option) + + return ( + + ) +} + +function getActionPresentation(option: ActiveOption): { + detail: string + icon: React.ReactNode + label: string +} { + if (option.kind === 'agent') { + return { + detail: option.option.label, + icon: , + label: 'Launch agent' + } + } + const { classification } = option.option + if (classification.kind === 'explicit-url' || classification.kind === 'host-url') { + return { + detail: classification.url, + icon: