feat(new-workspace): unified source picker + non-submitted URL drafts for agents (#1426)

* Fix new workspace composer focus restore

* Unify new workspace source selection

* WIP: selected source pill in smart workspace name field

Co-authored-by: Orca <help@stably.ai>

* fix(new-workspace): truncate source pill so it doesn't expand the dialog

Co-authored-by: Orca <help@stably.ai>

* feat(new-workspace): add open-in-browser button to source pill, fix vertical alignment

Co-authored-by: Orca <help@stably.ai>

* refactor(new-workspace): drop redundant kind suffix, distinct PR/issue icons, tooltips on pill actions

Co-authored-by: Orca <help@stably.ai>

* fix(new-workspace): type linked URL into agent input without auto-submit

Co-authored-by: Orca <help@stably.ai>

* fix(new-workspace): use bracketed-paste for draft URL injection so it actually appears in the agent input

Co-authored-by: Orca <help@stably.ai>

* feat(agents): per-agent draft injection strategy (codex slow paste, pi/opencode type-chars)

Co-authored-by: Orca <help@stably.ai>

* fix(agents): smarter TUI-ready heuristic + bracketed paste for codex/pi/opencode

Replaces per-agent strategy guesswork with a measured readiness check:
title-idle / non-shell-foreground stable for 1.5s / 2.5s minimum floor.
Verified against codex, pi, opencode, claude in a node-pty + xterm-headless
test rig — bracketed paste lands in the input buffer for all four.

Co-authored-by: Orca <help@stably.ai>

* refactor(agents): drop unused per-agent draft strategy abstraction

The TUI-ready heuristic in agent-paste-draft.ts works for every tested
agent (claude/codex/pi/opencode), so the AgentDraftInjectionStrategy
field, type-chars + bracketed-paste-slow code paths, and per-agent
overrides are dead. Keep the `agent` arg on pasteDraftWhenAgentReady
for future per-agent escape hatches without touching every call site.

Co-authored-by: Orca <help@stably.ai>

* feat(agents): skip draft URL injection for copilot + cursor-agent

Both TUIs open with a 'Do you trust this folder?' menu on first launch
that consumes keystrokes as menu input — pasting a URL there either
selects an arbitrary option or quits the session. Mark them with
skipDraftUrlInjection so the workspace still opens cleanly; the user
types/pastes the URL themselves once past the trust menu.

Co-authored-by: Orca <help@stably.ai>

* feat(agents): native --prefill for claude, trust pre-write for cursor/copilot

Replaces the empirical TUI-ready waits with two deterministic mechanisms:

1) `claude --prefill <text>` flag — Claude launches with the URL already in
   its input box, no submit. Eliminates the readiness/paste race entirely
   for the most common agent.
2) DECSET 2004 (`\x1b[?2004h`) detection on the PTY data stream for every
   other agent. That escape is the protocol-level "input layer ready,
   accepting bracketed paste" handshake — emitted by claude/codex/pi/
   opencode/gemini/cursor-agent/copilot the moment the input box mounts.
   We tap it via a sidecar subscription on pty-dispatcher (no interference
   with the primary xterm handler) and paste as soon as it lands. The
   8s budget is now an upper bound, not a target.

Cursor-agent and Copilot's "Do you trust this folder?" menus are bypassed
by writing the same trust artifacts the CLIs themselves write after the
user accepts:
- Cursor: `~/.cursor/projects/<slug>/.workspace-trusted` (slug = abs path
  with leading `/` stripped, remaining `/` → `-`).
- Copilot: append cwd to `trustedFolders` in `~/.copilot/config.json`
  (the same array the bundled `addTrustedFolder` writes).

Verified against the cursor-agent CLI bundle (versions/2026.04.17-787b533/
index.js: `_=".workspace-trusted"`) and the @github/copilot 1.0.32 bundle
(`isFolderTrusted` / `addTrustedFolder` both read/write `trustedFolders`).
Both check via realpath() before string-comparing, so the trust preset
canonicalizes too.

skipDraftUrlInjection is dropped — both agents now get the draft URL
paste once the trust menu is pre-resolved.

Tests: 24 passing across tui-agent-startup, agent-trust-presets,
pty-dispatcher routing.

Co-authored-by: Orca <help@stably.ai>

* fix(agents): wait for post-?2004h render burst to settle before paste

OpenCode emits DECSET 2004 at ~500ms during alt-screen setup, then runs
a 1.3s splash render with NO bytes on the PTY, then paints the actual
input box at ~1.85s. Pasting on the bare ?2004h signal lands during the
silent gap and the bytes are dropped.

The fix: take ?2004h as the necessary precondition, then wait for the
TUI's render burst to finish — defined as 1500ms of stream silence
after the most recent post-?2004h byte. This captures both the fast
TUIs (claude/pi/codex emit setup escapes in one burst then go quiet)
and the slow ones (opencode emits, sleeps for the splash, emits again,
then goes quiet).

Verified against opencode/claude/pi in a node-pty rig: paste lands on
the first try with the new strategy. The hard 8s timeout still caps
the wait when an agent fails to launch.

Co-authored-by: Orca <help@stably.ai>

* fix(agents): guard agentTrust IPC so stale preload doesn't crash launch

If the preload bundle is older than the renderer (a real situation in
electron-vite dev because preload changes only apply on full restart,
not HMR), `window.api.agentTrust` is undefined and the launch crashes
with "Cannot read properties of undefined (reading 'markTrusted')"
before the worktree even opens.

Guard the call sites in launch-work-item-direct and useComposerState
to skip the trust pre-write when the IPC isn't exposed, and wrap the
invoke in try/catch so an IPC error never blocks the launch — the user
just sees the trust menu and accepts it manually, same as before this
feature shipped.

Co-authored-by: Orca <help@stably.ai>

* feat(tasks): route 'Use' through the New Workspace dialog instead of yolo-create

The Use CTA on the Tasks page used to create+activate a worktree
synchronously, which surprised users — the worktree appeared in the
sidebar before they had a chance to confirm name / agent / setup. The
unified New Workspace dialog landed in this branch already supports
opening with a linked work item pre-filled (see openComposerForItem /
openComposerForLinearItem), so just route Use through it.

The launchWorkItemDirect helper stays exported for ProjectViewWrapper,
which has its own UX where the immediate-create flow is the right call.

Co-authored-by: Orca <help@stably.ai>

* test(agents): include `agent` field in autohand startup-plan assertion

Merging main brought in the Autohand Code agent test (PR #1382), which
predated this branch's addition of `agent` to AgentStartupPlan.
Aligning the assertion fixes the lone CI test failure on this PR.

Co-authored-by: Orca <help@stably.ai>

* refactor(agents): drop unused expectedProcess arg + snapshot sidecar set

Two minor follow-ups from self-review:

1. `pasteDraftWhenAgentReady` no longer reads `expectedProcess` — readiness
   is gated on DECSET 2004 alone now, not on PTY foreground process. Drop
   it from the signature and from the two callers (launch-work-item-direct,
   new-workspace).

2. The pty-dispatcher's sidecar fan-out iterates the live Set, which is
   safe against deleting the current element but not against a watcher
   that synchronously subscribes a sibling. Snapshot via Array.from
   before the loop. Cheap (Set is tiny) and removes the latent footgun.

No behavior change.

Co-authored-by: Orca <help@stably.ai>

* test(e2e): match the unified smart-name input's new placeholder

The CreateFromTab refactor in this branch replaced the separate "Workspace
name" Input with a single SmartWorkspaceNameField whose default-mode
placeholder is "Type a name, #1234, branch, GitHub or Linear URL". The
worktree-create e2e test was still anchoring on the old "Workspace name"
text and could not find the input.

Update the placeholder regex to match the new copy. Free-form text typed
into smart mode is treated as a workspace name by submitQuick — same
contract the test used before.

Verified locally: targeted e2e passes in 2.2s.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-05-05 01:07:52 -07:00
committed by GitHub
co-authored by Orca
parent f9120cb2f1
commit 62e02a3b4c
30 changed files with 2001 additions and 1884 deletions
+113
View File
@@ -0,0 +1,113 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const testState = {
fakeHomeDir: ''
}
vi.mock('node:os', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
const actual = await vi.importActual<typeof import('node:os')>('node:os')
return {
...actual,
homedir: () => testState.fakeHomeDir
}
})
const { markCopilotFolderTrusted, markCursorWorkspaceTrusted } =
await import('./agent-trust-presets')
beforeEach(() => {
testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-trust-presets-'))
})
afterEach(() => {
rmSync(testState.fakeHomeDir, { recursive: true, force: true })
testState.fakeHomeDir = ''
})
describe('markCursorWorkspaceTrusted', () => {
it('writes ~/.cursor/projects/<slug>/.workspace-trusted with the cwd payload', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-cursor-ws-'))
try {
markCursorWorkspaceTrusted(workspace)
const projectsDir = join(testState.fakeHomeDir, '.cursor', 'projects')
const slugDirs = readdirSync(projectsDir)
expect(slugDirs.length).toBe(1)
const trustFile = join(projectsDir, slugDirs[0], '.workspace-trusted')
expect(existsSync(trustFile)).toBe(true)
const payload = JSON.parse(readFileSync(trustFile, 'utf-8'))
expect(payload.workspacePath).toBeTruthy()
expect(typeof payload.trustedAt).toBe('string')
} finally {
rmSync(workspace, { recursive: true, force: true })
}
})
it('is idempotent — re-marking the same workspace does not overwrite trustedAt', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-cursor-ws-'))
try {
markCursorWorkspaceTrusted(workspace)
const projectsDir = join(testState.fakeHomeDir, '.cursor', 'projects')
const slugDirs = readdirSync(projectsDir)
const trustFile = join(projectsDir, slugDirs[0], '.workspace-trusted')
const firstPayload = readFileSync(trustFile, 'utf-8')
markCursorWorkspaceTrusted(workspace)
const secondPayload = readFileSync(trustFile, 'utf-8')
expect(secondPayload).toBe(firstPayload)
} finally {
rmSync(workspace, { recursive: true, force: true })
}
})
})
describe('markCopilotFolderTrusted', () => {
it('appends the workspace to trustedFolders in ~/.copilot/config.json', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
try {
markCopilotFolderTrusted(workspace)
const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json')
expect(existsSync(configPath)).toBe(true)
const parsed = JSON.parse(readFileSync(configPath, 'utf-8'))
expect(Array.isArray(parsed.trustedFolders)).toBe(true)
expect(parsed.trustedFolders.length).toBe(1)
expect(typeof parsed.trustedFolders[0]).toBe('string')
} finally {
rmSync(workspace, { recursive: true, force: true })
}
})
it('preserves existing config keys and dedups already-trusted folders', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-'))
const realpath = realpathSync(workspace)
try {
mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true })
writeFileSync(
join(testState.fakeHomeDir, '.copilot', 'config.json'),
JSON.stringify({
firstLaunchAt: '2026-01-01T00:00:00.000Z',
trustedFolders: [realpath]
})
)
markCopilotFolderTrusted(workspace)
const parsed = JSON.parse(
readFileSync(join(testState.fakeHomeDir, '.copilot', 'config.json'), 'utf-8')
)
expect(parsed.firstLaunchAt).toBe('2026-01-01T00:00:00.000Z')
expect(parsed.trustedFolders).toHaveLength(1)
} finally {
rmSync(workspace, { recursive: true, force: true })
}
})
})
+115
View File
@@ -0,0 +1,115 @@
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { writeFileAtomically } from './codex-accounts/fs-utils'
/**
* Pre-mark a workspace as trusted for cursor-agent / GitHub Copilot CLI so
* the agent's "Do you trust this folder?" menu does not fire on first launch.
*
* Why: Orca's "drop URL into agent input as a draft" flow injects the URL
* via bracketed-paste once the TUI is up. If the trust menu intercepts the
* keystrokes (each menu reads a single character or numbered option), the
* paste either selects an arbitrary option or quits the session. Pre-writing
* the same trust artifacts that the agent writes after the user accepts is
* the only documented bypass — both CLIs read these files at startup before
* showing the menu.
*
* Side note: a `--trust`-style CLI flag exists in cursor-agent but only
* applies in `--print/headless` mode (per its --help). Copilot has no
* documented flag at all (verified against @github/copilot 1.0.32 bundle).
*/
/**
* Cursor's CLI keeps a per-workspace trust marker at:
* ~/.cursor/projects/<slug>/.workspace-trusted
* where <slug> is the absolute path with the leading `/` stripped and
* remaining `/` replaced with `-`. The file payload is `{ trustedAt,
* workspacePath }`. Verified against the cursor-agent CLI bundle
* (versions/2026.04.17-787b533/index.ts: `_=".workspace-trusted"`, slug
* derived via the same util that resolves `~/.cursor/projects/<slug>`).
*/
export function markCursorWorkspaceTrusted(workspacePath: string): void {
const absPath = canonicalize(workspacePath)
const slug = cursorWorkspaceSlug(absPath)
if (!slug) {
return
}
const trustDir = join(homedir(), '.cursor', 'projects', slug)
const trustFile = join(trustDir, '.workspace-trusted')
if (existsSync(trustFile)) {
return
}
mkdirSync(trustDir, { recursive: true })
const payload = JSON.stringify(
{ trustedAt: new Date().toISOString(), workspacePath: absPath },
null,
2
)
writeFileAtomically(trustFile, `${payload}\n`)
}
/**
* GitHub Copilot CLI keeps a global list of trusted folders in
* ~/.copilot/config.json under `trustedFolders` (verified against the
* @github/copilot 1.0.32 bundle: `addTrustedFolder` and `isFolderTrusted`
* both read/write this exact key, and folder comparison is done after a
* realpath() resolution).
*
* We append to the array in-place so unrelated config keys (loggedInUsers,
* copilotTokens, etc.) survive untouched.
*/
export function markCopilotFolderTrusted(workspacePath: string): void {
const absPath = canonicalize(workspacePath)
const configDir = join(homedir(), '.copilot')
const configPath = join(configDir, 'config.json')
let config: Record<string, unknown> = {}
try {
if (existsSync(configPath)) {
const raw = readFileSync(configPath, 'utf-8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
config = parsed as Record<string, unknown>
}
}
} catch {
// Why: a corrupted config.json is the user's to fix — refuse to overwrite
// it from this side-effect path. Copilot will rewrite the file itself
// after the user accepts the trust prompt manually.
return
}
const existing = Array.isArray(config.trustedFolders) ? (config.trustedFolders as unknown[]) : []
const normalizedExisting = existing.map((entry) =>
typeof entry === 'string' ? canonicalize(entry) : null
)
if (normalizedExisting.includes(absPath)) {
return
}
const next = [...existing.filter((e) => typeof e === 'string'), absPath]
config.trustedFolders = next
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true })
}
writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`)
}
function canonicalize(p: string): string {
// Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but
// both Cursor and Copilot's trust comparators run realpath() before the
// string compare. Mirror that so a worktree under a symlinked parent
// (orca caches realpath()'d worktree paths) matches the agent's lookup.
try {
if (existsSync(p)) {
return realpathSync(p)
}
} catch {
// Fall through to the raw input.
}
return p
}
function cursorWorkspaceSlug(absPath: string): string {
const stripped = absPath.replace(/^[\\/]+/, '')
const slug = stripped.replace(/[\\/]+/g, '-')
return slug
}
+1 -1
View File
@@ -318,7 +318,7 @@ export function setupGuestShortcutForwarding(args: {
} else if (action?.type === 'openQuickOpen') {
renderer.send('ui:openQuickOpen')
} else if (action?.type === 'openNewWorkspace') {
renderer.send('ui:openNewWorkspace', action.tab)
renderer.send('ui:openNewWorkspace')
} else if (action?.type === 'jumpToWorktreeIndex') {
renderer.send('ui:jumpToWorktreeIndex', action.index)
} else {
+19
View File
@@ -829,6 +829,25 @@ export async function getWorkItem(
}
}
export async function getWorkItemByOwnerRepo(
repoPath: string,
ownerRepo: OwnerRepo,
number: number,
type: 'issue' | 'pr'
): Promise<MainWorkItem | null> {
await acquire()
try {
if (type === 'issue') {
return await fetchIssueWorkItem(repoPath, ownerRepo, number)
}
return await fetchPullRequestWorkItem(repoPath, ownerRepo, number)
} catch {
return null
} finally {
release()
}
}
/**
* Get PR info for a given branch using gh CLI.
* Returns null if gh is not installed, or no PR exists for the branch.
+35
View File
@@ -0,0 +1,35 @@
import { ipcMain } from 'electron'
import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets'
export type AgentTrustPreset = 'cursor' | 'copilot'
/**
* Why: cursor-agent and GitHub Copilot CLI gate first-launch in an unfamiliar
* directory behind a "Do you trust this folder?" menu that consumes
* keystrokes (numbered options / single-letter shortcuts). Orca's draft-URL
* paste flow needs the input box, not the menu, so before Orca spawns the
* agent it asks main to write the same trust artifacts the agents write
* after the user accepts. Best-effort: any IO error is swallowed so a failed
* trust write never blocks the workspace from opening.
*/
export function registerAgentTrustHandlers(): void {
ipcMain.removeHandler('agentTrust:markTrusted')
ipcMain.handle(
'agentTrust:markTrusted',
async (_event, args: { preset: AgentTrustPreset; workspacePath: string }): Promise<void> => {
if (!args || typeof args.workspacePath !== 'string' || !args.workspacePath) {
return
}
try {
if (args.preset === 'cursor') {
markCursorWorkspaceTrusted(args.workspacePath)
} else if (args.preset === 'copilot') {
markCopilotFolderTrusted(args.workspacePath)
}
} catch {
// Best-effort: see Why above. The user can still accept the trust
// prompt manually if writing the artifact fails.
}
}
)
}
+20
View File
@@ -15,6 +15,7 @@ import {
listWorkItems,
countWorkItems,
getWorkItem,
getWorkItemByOwnerRepo,
createIssue,
updateIssue,
addIssueComment,
@@ -143,6 +144,25 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) =>
dispatchWorkItem(args, assertRegisteredRepo(args.repoPath, store).path, getWorkItem)
)
ipcMain.handle(
'gh:workItemByOwnerRepo',
(
_event,
args: {
repoPath: string
owner: string
repo: string
number: number
type: 'issue' | 'pr'
}
) =>
getWorkItemByOwnerRepo(
assertRegisteredRepo(args.repoPath, store).path,
{ owner: args.owner, repo: args.repo },
args.number,
args.type
)
)
ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) =>
dispatchWorkItem(args, assertRegisteredRepo(args.repoPath, store).path, getWorkItemDetails)
)
@@ -21,6 +21,7 @@ const {
registerRuntimeHandlersMock,
registerCodexAccountHandlersMock,
registerAgentHookHandlersMock,
registerAgentTrustHandlersMock,
registerClaudeAccountHandlersMock,
registerClipboardHandlersMock,
registerUpdaterHandlersMock,
@@ -53,6 +54,7 @@ const {
registerRuntimeHandlersMock: vi.fn(),
registerCodexAccountHandlersMock: vi.fn(),
registerAgentHookHandlersMock: vi.fn(),
registerAgentTrustHandlersMock: vi.fn(),
registerClaudeAccountHandlersMock: vi.fn(),
registerClipboardHandlersMock: vi.fn(),
registerUpdaterHandlersMock: vi.fn(),
@@ -158,6 +160,10 @@ vi.mock('./agent-hooks', () => ({
registerAgentHookHandlers: registerAgentHookHandlersMock
}))
vi.mock('./agent-trust', () => ({
registerAgentTrustHandlers: registerAgentTrustHandlersMock
}))
vi.mock('./claude-accounts', () => ({
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
}))
@@ -205,6 +211,7 @@ describe('registerCoreHandlers', () => {
registerRuntimeHandlersMock.mockReset()
registerCodexAccountHandlersMock.mockReset()
registerAgentHookHandlersMock.mockReset()
registerAgentTrustHandlersMock.mockReset()
registerClaudeAccountHandlersMock.mockReset()
registerClipboardHandlersMock.mockReset()
registerUpdaterHandlersMock.mockReset()
+2
View File
@@ -29,6 +29,7 @@ import { registerSidekickHandlers } from './sidekick'
import { registerUIHandlers } from './ui'
import { registerCodexAccountHandlers } from './codex-accounts'
import { registerAgentHookHandlers } from './agent-hooks'
import { registerAgentTrustHandlers } from './agent-trust'
import { registerClaudeAccountHandlers } from './claude-accounts'
import { warmSystemFontFamilies } from '../system-fonts'
import {
@@ -72,6 +73,7 @@ export function registerCoreHandlers(
registerCodexUsageHandlers(codexUsage)
registerCodexAccountHandlers(codexAccounts)
registerAgentHookHandlers()
registerAgentTrustHandlers()
registerClaudeAccountHandlers(claudeAccounts)
registerRateLimitHandlers(rateLimits)
registerGitHubHandlers(store, stats)
+1 -3
View File
@@ -514,9 +514,7 @@ export function createMainWindow(
// Why: routed through the main process so focus contexts that bypass
// the renderer's window-level keydown (contentEditable markdown editor,
// browser-guest webContents) still reach the new-workspace composer.
// Forward the target tab so Cmd/Ctrl+Shift+N lands on the
// "Create from…" tab instead of the default quick-create form.
mainWindow.webContents.send('ui:openNewWorkspace', action.tab)
mainWindow.webContents.send('ui:openNewWorkspace')
return
}
+11 -1
View File
@@ -506,6 +506,13 @@ export type PreloadApi = {
number: number
type?: 'issue' | 'pr'
}) => Promise<Omit<GitHubWorkItem, 'repoId'> | null>
workItemByOwnerRepo: (args: {
repoPath: string
owner: string
repo: string
number: number
type: 'issue' | 'pr'
}) => Promise<Omit<GitHubWorkItem, 'repoId'> | null>
workItemDetails: (args: {
repoPath: string
number: number
@@ -717,6 +724,9 @@ export type PreloadApi = {
geminiStatus: () => Promise<AgentHookInstallStatus>
cursorStatus: () => Promise<AgentHookInstallStatus>
}
agentTrust: {
markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise<void>
}
preflight: PreflightApi
notifications: {
dispatch: (args: NotificationDispatchRequest) => Promise<NotificationDispatchResult>
@@ -941,7 +951,7 @@ export type PreloadApi = {
onToggleRightSidebar: (callback: () => void) => () => void
onToggleWorktreePalette: (callback: () => void) => () => void
onOpenQuickOpen: (callback: () => void) => () => void
onOpenNewWorkspace: (callback: (tab: 'quick' | 'create-from') => void) => () => void
onOpenNewWorkspace: (callback: () => void) => () => void
onJumpToWorktreeIndex: (callback: (index: number) => void) => () => void
onWorktreeHistoryNavigate: (callback: (direction: 'back' | 'forward') => void) => () => void
onNewBrowserTab: (callback: () => void) => () => void
+15 -7
View File
@@ -534,6 +534,14 @@ const api = {
type?: 'issue' | 'pr'
}): Promise<unknown> => ipcRenderer.invoke('gh:workItem', args),
workItemByOwnerRepo: (args: {
repoPath: string
owner: string
repo: string
number: number
type: 'issue' | 'pr'
}): Promise<unknown> => ipcRenderer.invoke('gh:workItemByOwnerRepo', args),
workItemDetails: (args: {
repoPath: string
number: number
@@ -848,6 +856,11 @@ const api = {
ipcRenderer.invoke('agentHooks:cursorStatus')
},
agentTrust: {
markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }): Promise<void> =>
ipcRenderer.invoke('agentTrust:markTrusted', args)
},
preflight: {
check: (args?: {
force?: boolean
@@ -1563,13 +1576,8 @@ const api = {
ipcRenderer.on('ui:openQuickOpen', listener)
return () => ipcRenderer.removeListener('ui:openQuickOpen', listener)
},
onOpenNewWorkspace: (callback: (tab: 'quick' | 'create-from') => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, tab: 'quick' | 'create-from') => {
// Why: older main-process builds may send this event without a payload
// — default to 'quick' so the preload contract stays forward-compatible
// during a partial rollout where only one side has shipped the tab arg.
callback(tab ?? 'quick')
}
onOpenNewWorkspace: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:openNewWorkspace', listener)
return () => ipcRenderer.removeListener('ui:openNewWorkspace', listener)
},
@@ -11,15 +11,17 @@ import {
Settings2
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import RepoCombobox from '@/components/repo/RepoCombobox'
import AgentCombobox from '@/components/agent/AgentCombobox'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import type { SparsePreset, TuiAgent } from '../../../shared/types'
import type { GitHubWorkItem, LinearIssue, SparsePreset, TuiAgent } from '../../../shared/types'
import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect'
import SmartWorkspaceNameField, {
type SmartWorkspaceNameSelection
} from '@/components/new-workspace/SmartWorkspaceNameField'
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
@@ -35,7 +37,12 @@ type NewWorkspaceComposerCardProps = {
repoId: string
onRepoChange: (value: string) => void
name: string
onNameChange: (event: React.ChangeEvent<HTMLInputElement>) => void
onNameValueChange: (value: string) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
onSmartBranchSelect: (refName: string) => void
onSmartLinearIssueSelect: (issue: LinearIssue) => void
smartNameSelection: SmartWorkspaceNameSelection | null
onClearSmartNameSelection: () => void
detectedAgentIds: Set<TuiAgent> | null
onOpenAgentSettings: () => void
advancedOpen: boolean
@@ -175,7 +182,12 @@ export default function NewWorkspaceComposerCard({
repoId,
onRepoChange,
name,
onNameChange,
onNameValueChange,
onSmartGitHubItemSelect,
onSmartBranchSelect,
onSmartLinearIssueSelect,
smartNameSelection,
onClearSmartNameSelection,
detectedAgentIds,
onOpenAgentSettings,
advancedOpen,
@@ -240,12 +252,12 @@ export default function NewWorkspaceComposerCard({
onDragEnter={dragHandlers.onDragEnter}
onDragLeave={dragHandlers.onDragLeave}
className={cn(
'grid gap-1 rounded-md transition',
'grid min-w-0 gap-1 rounded-md transition',
isFileDragOver && 'ring-2 ring-ring/30',
containerClassName
)}
>
<div className="space-y-4 pt-3">
<div className="min-w-0 space-y-4 pt-3">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground">Repository</label>
@@ -284,30 +296,33 @@ export default function NewWorkspaceComposerCard({
/>
</div>
<div className="space-y-1">
<div className="min-w-0 space-y-1">
<label className="text-xs font-medium text-muted-foreground">
Workspace Name <span className="text-muted-foreground/70">[Optional]</span>
Name or &apos;Create From&apos;{' '}
<span className="text-muted-foreground/70">[Optional]</span>
</label>
<Input
ref={nameInputRef}
<SmartWorkspaceNameField
inputRef={nameInputRef}
repos={eligibleRepos}
repoId={repoId}
onRepoChange={onRepoChange}
value={name}
onChange={onNameChange}
onKeyDown={(event) => {
onValueChange={onNameValueChange}
onGitHubItemSelect={onSmartGitHubItemSelect}
onBranchSelect={onSmartBranchSelect}
onLinearIssueSelect={onSmartLinearIssueSelect}
selectedSource={smartNameSelection}
onClearSelectedSource={onClearSmartNameSelection}
onPlainEnter={() => {
// Why: Enter on the workspace name advances focus to the next
// field (Agent combobox) rather than submitting, letting the user
// progress through the form with just the keyboard.
if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey) {
return
}
event.preventDefault()
const root = composerRef?.current
const agentTrigger = root?.querySelector<HTMLElement>(
'[data-agent-combobox-root="true"][role="combobox"]'
)
agentTrigger?.focus()
}}
placeholder="Workspace name"
className="h-9 text-sm"
/>
</div>
@@ -348,6 +363,21 @@ export default function NewWorkspaceComposerCard({
/>
</div>
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggleAdvanced}
className="-ml-2 text-xs"
>
Advanced
<ChevronDown
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
/>
</Button>
</div>
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
@@ -496,21 +526,6 @@ export default function NewWorkspaceComposerCard({
</div>
) : null}
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggleAdvanced}
className="-ml-2 text-xs"
>
Advanced
<ChevronDown
className={cn('size-4 transition-transform', advancedOpen && 'rotate-180')}
/>
</Button>
</div>
<div className="flex justify-end">
<Button
onClick={() => void onCreate()}
@@ -1,21 +1,12 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useAppStore } from '@/store'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import NewWorkspaceComposerCard from '@/components/NewWorkspaceComposerCard'
import AgentSettingsDialog from '@/components/agent/AgentSettingsDialog'
import CreateFromTab from '@/components/new-workspace/CreateFromTab'
import { useComposerState } from '@/hooks/useComposerState'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard'
import { cn } from '@/lib/utils'
import type { TuiAgent } from '../../../shared/types'
type ComposerModalData = {
@@ -23,25 +14,6 @@ type ComposerModalData = {
initialRepoId?: string
linkedWorkItem?: LinkedWorkItemSummary | null
initialBaseBranch?: string
initialTab?: 'quick' | 'create-from'
}
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
const tabShortcut = {
quick: isMac ? '⌘N' : 'Ctrl+N',
'create-from': isMac ? '⌘⇧N' : 'Ctrl+Shift+N'
} as const
function ShortcutHint({ children }: { children: React.ReactNode }): React.JSX.Element {
// Why: a flat muted string reads as "secondary hint" rather than the
// bordered kbd chip, which drew too much attention for a label most users
// will learn once and forget. Stays inside the tab trigger so the hit
// target covers it too.
return (
<span className="text-[10px] font-normal tracking-wide text-muted-foreground/70">
{children}
</span>
)
}
export default function NewWorkspaceComposerModal(): React.JSX.Element | null {
@@ -83,56 +55,15 @@ function ComposerModalBody({
onClose: () => void
onOpenChange: (open: boolean) => void
}): React.JSX.Element {
const activeTab = useAppStore((s) => s.newWorkspaceComposerTab)
const setActiveTab = useAppStore((s) => s.setNewWorkspaceComposerTab)
// Why: when the user starts something on Create-from that needs to fall
// back to Quick (setup policy = 'ask', PR head resolution failed, ...) we
// feed the prefill through this local override and remount the Quick
// composer via a bumped key so its initial state absorbs the new data.
// Without the key bump the useComposerState hook would keep its first
// snapshot and the Quick tab would appear empty after fallback.
const [prefillOverride, setPrefillOverride] = useState<ComposerModalData | null>(null)
const [quickKey, setQuickKey] = useState(0)
const effectiveQuickData = prefillOverride ?? modalData
const handleFallbackToQuick = useCallback(
(data: {
initialRepoId?: string
linkedWorkItem?: LinkedWorkItemSummary | null
prefilledName?: string
initialBaseBranch?: string
}) => {
setPrefillOverride({ ...data })
setQuickKey((k) => k + 1)
setActiveTab('quick')
},
[setActiveTab]
)
const handleCreateFromLaunched = useCallback(() => {
onClose()
}, [onClose])
return (
<Dialog open onOpenChange={onOpenChange}>
<DialogContent
// Why: pin a single width across both tabs. Create-from needs the
// extra horizontal room for PR titles + branch names; Quick tolerates
// it fine. Animating between widths was jarring and made the modal
// feel unstable every time the user toggled tabs.
className="flex flex-col sm:max-w-lg"
onOpenAutoFocus={(event) => {
// Why: Radix's FocusScope fires this once the dialog has mounted and
// the DOM is ready. preventDefault stops it from focusing the first
// tabbable in the Quick tab (the repo combobox trigger) when the
// Create-from tab is active — that tab wants the search input to
// own initial focus. The QuickTabBody handles its own focus below
// when the Quick tab is active.
if (activeTab === 'create-from') {
return
}
// Why: Radix's FocusScope fires this once the dialog has mounted.
// preventDefault stops it from focusing whatever first-tabbable it
// picks (close button), and we instead focus the repo picker so the
// keyboard flow starts at the top of the unified create form.
event.preventDefault()
const content = event.currentTarget as HTMLElement
const trigger = content.querySelector<HTMLElement>(
@@ -141,68 +72,11 @@ function ComposerModalBody({
trigger?.focus({ preventScroll: true })
}}
>
<Tabs
value={activeTab}
onValueChange={(next) => setActiveTab(next as 'quick' | 'create-from')}
// Why: both panels are force-mounted so switching tabs preserves
// their local state (typed query on Create-from, repo pick /
// workspace name on Quick) instead of remounting each time.
// Height is driven by the active panel's intrinsic size — the
// DialogContent handles overflow if the viewport is too short.
className="flex flex-col gap-0"
>
{/* Why: use the shared underline variant so both levels of tabs
read as "tabs" — the default pill variant fought the sub-tabs
inside Create-from for visual weight. The bottom border on the
list gives it clear separation from the content below. */}
{/* Why: DialogContent has p-6 (24px top) and the close button sits
absolutely at top-4 (16px), so its 16px icon centers around 24px
from the top. Pull the h-8 tab list up with -mt-4 so its center
(8 + 16 = 24px) lines up with the X on the same row. Reserve
right padding so the last trigger doesn't slide under the X. */}
<TabsList
variant="line"
className="-mt-4 h-8 w-full justify-start gap-6 border-b border-border/60 px-0 pr-8"
>
<TabsTrigger value="quick" className="flex-none gap-2 px-0 text-xs font-medium">
Form
<ShortcutHint>{tabShortcut.quick}</ShortcutHint>
</TabsTrigger>
<TabsTrigger value="create-from" className="flex-none gap-2 px-0 text-xs font-medium">
Create from
<ShortcutHint>{tabShortcut['create-from']}</ShortcutHint>
</TabsTrigger>
</TabsList>
<DialogHeader className="gap-1">
<DialogTitle className="text-base font-semibold">Create Workspace</DialogTitle>
</DialogHeader>
<DialogHeader className="gap-1 pt-4">
<DialogTitle className="text-base font-semibold">Create Workspace</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{activeTab === 'quick'
? 'Pick a repository and agent to spin up a new workspace.'
: 'Start from an existing PR, issue, branch, or Linear ticket.'}
</DialogDescription>
</DialogHeader>
<AnimatedTabPanels active={activeTab}>
{{
quick: (
<QuickTabBody
key={quickKey}
modalData={effectiveQuickData}
onClose={onClose}
active={activeTab === 'quick'}
/>
),
'create-from': (
<CreateFromTab
onLaunched={handleCreateFromLaunched}
onFallbackToQuick={handleFallbackToQuick}
active={activeTab === 'create-from'}
/>
)
}}
</AnimatedTabPanels>
</Tabs>
<QuickTabBody modalData={modalData} onClose={onClose} active />
</DialogContent>
</Dialog>
)
@@ -321,24 +195,6 @@ function QuickTabBody({
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [active, composerRef, createDisabled, handleCreate, onClose])
// Why: when the Quick tab becomes active (initial mount, or switched to
// from Create-from), focus the repo combobox trigger so the confirmed
// selection sits ready and the keyboard flow starts at the top of the
// form — matching Dialog's onOpenAutoFocus behavior in the pre-tabs modal.
useEffect(() => {
if (!active) {
return
}
const root = composerRef.current
if (!root) {
return
}
const trigger = root.querySelector<HTMLElement>(
'[data-repo-combobox-root="true"][role="combobox"]'
)
trigger?.focus({ preventScroll: true })
}, [active, composerRef])
return (
<>
<NewWorkspaceComposerCard
@@ -354,161 +210,3 @@ function QuickTabBody({
</>
)
}
type TabKey = 'quick' | 'create-from'
/**
* Keeps both tab panels mounted so their local state (typed query on
* Create-from, repo pick / workspace name on Quick) survives a tab swap.
* Only the active panel is in normal flow; the inactive panel is
* absolutely positioned + `visibility: hidden` + `pointer-events-none`
* so it
* (a) doesn't contribute to DialogContent's scrollHeight (otherwise the
* host dialog grows a scrollbar whenever the inactive panel is
* taller than the active one), and
* (b) keeps its React subtree mounted (no remount, so input focus,
* typed query, and selection survive).
*
* The wrapper's height is JS-driven: a ResizeObserver on the active
* panel's inner node keeps wrapper height in sync with content so the
* wrapper never clips. On tab change we capture the pre-swap wrapper
* height and transition to the new active panel's measured height — a
* FLIP-style animation so the modal resizes smoothly rather than
* snapping.
*/
function AnimatedTabPanels({
active,
children
}: {
active: TabKey
children: Record<TabKey, React.ReactNode>
}): React.JSX.Element {
const wrapperRef = useRef<HTMLDivElement | null>(null)
const quickRef = useRef<HTMLDivElement | null>(null)
const createFromRef = useRef<HTMLDivElement | null>(null)
const previousActiveRef = useRef<TabKey>(active)
const [wrapperHeight, setWrapperHeight] = useState<number | null>(null)
const [isAnimating, setIsAnimating] = useState(false)
// Why: track the active panel's intrinsic height via ResizeObserver so
// the wrapper follows content (Advanced drawer open/close, async search
// results landing). When active changes, the FLIP effect below runs
// first (capturing the outgoing height), then this observer swings the
// wrapper height toward the new active panel's height via the CSS
// transition on `height`.
//
// Why offsetHeight and not getBoundingClientRect().height: Radix's
// dialog open animation (`data-[state=open]:zoom-in-95`) scales the
// dialog from 0.95 → 1.0 over 200ms. getBoundingClientRect reports the
// *visual* (transformed) height, so the very first measurement lands at
// ~95 % of the real layout size and pins that into the inline height.
// The ResizeObserver never refires because actual layout size didn't
// change — only the transform did — so the wrapper stays permanently
// ~16 px short and clips the Create Workspace button at the bottom.
// offsetHeight returns the un-transformed layout box, which is what the
// wrapper should match.
useLayoutEffect(() => {
const target = active === 'quick' ? quickRef.current : createFromRef.current
if (!target) {
return
}
const update = (): void => {
const next = target.offsetHeight
if (next > 0) {
setWrapperHeight(next)
}
}
update()
const observer = new ResizeObserver(update)
observer.observe(target)
return () => observer.disconnect()
}, [active])
// Why: on tab swap, override the observer-driven height for one frame
// so the transition starts from the outgoing panel's size. Without this
// the wrapper would snap to the new panel's height before the ResizeObserver
// could read it.
useLayoutEffect(() => {
const prev = previousActiveRef.current
previousActiveRef.current = active
if (prev === active) {
return
}
const wrapper = wrapperRef.current
if (!wrapper) {
return
}
// Why: offsetHeight, not getBoundingClientRect — see comment on the
// observer effect above. Same transform-scale trap applies to this
// FLIP capture if a tab swap happens while any ancestor transform is
// still animating.
const from = wrapper.offsetHeight
if (from > 0) {
setWrapperHeight(from)
setIsAnimating(true)
}
}, [active])
useEffect(() => {
const wrapper = wrapperRef.current
if (!wrapper || !isAnimating) {
return
}
const onEnd = (event: TransitionEvent): void => {
if (event.propertyName !== 'height') {
return
}
setIsAnimating(false)
}
wrapper.addEventListener('transitionend', onEnd)
return () => wrapper.removeEventListener('transitionend', onEnd)
}, [isAnimating])
return (
<div
ref={wrapperRef}
// Why: `overflow: clip` isolates the absolutely-positioned inactive
// panel so the wrapper's measured height drives the dialog (not the
// stacked panel heights). We avoid plain `overflow: hidden` because
// that also clips the 3px focus rings painted by nested inputs
// (RepoCombobox, workspace name field, etc.) — the inner panels are
// `inset-x-0` so their triggers sit flush against the wrapper edges,
// leaving no room for a ring to paint. `overflow-clip-margin` gives
// the ring breathing room on every side without re-introducing scroll
// containers or letting the inactive panel leak layout.
className={cn(
'relative overflow-clip',
isAnimating && 'transition-[height] duration-200 ease-out'
)}
style={{
...(wrapperHeight !== null ? { height: wrapperHeight } : null),
overflowClipMargin: '8px'
}}
>
<div
ref={quickRef}
className={cn(
'pt-4',
active === 'quick'
? 'pointer-events-auto'
: 'pointer-events-none invisible absolute inset-x-0 top-0'
)}
aria-hidden={active !== 'quick'}
>
{children.quick}
</div>
<div
ref={createFromRef}
className={cn(
'pt-3',
active === 'create-from'
? 'pointer-events-auto'
: 'pointer-events-none invisible absolute inset-x-0 top-0'
)}
aria-hidden={active !== 'create-from'}
>
{children['create-from']}
</div>
</div>
)
}
+13 -35
View File
@@ -72,7 +72,6 @@ import {
CROSS_REPO_DISPLAY_LIMIT
} from '@/lib/new-workspace'
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
import { isGitRepoKind } from '../../../shared/repo-kind'
import { useTeamStates } from '@/hooks/useIssueMetadata'
import type {
@@ -1414,17 +1413,13 @@ export default function TaskPage(): React.JSX.Element {
const handleUseWorkItem = useCallback(
(item: GitHubWorkItem): void => {
// Why: the "Use" CTA is the primary way to start work from this page, so
// skip the composer for the common case and create+activate the workspace
// immediately, launch the user's default agent, and paste the work item
// URL into the agent's input as a reviewable draft. Fall back to the
// composer modal only when explicit per-workspace decisions are required
// (setupRunPolicy === 'ask') or the repo/agent resolution fails.
void launchWorkItemDirect({
item,
repoId: item.repoId,
openModalFallback: () => openComposerForItem(item)
})
// Why: open the unified New Workspace dialog pre-filled with the work
// item as the selected source so the user can confirm name / agent /
// setup before the worktree is created. Earlier the "Use" CTA created
// and activated the worktree synchronously, which was disorienting —
// the worktree appeared in the sidebar before the user had a chance
// to review it. The composer already owns the prefill flow.
openComposerForItem(item)
},
[openComposerForItem]
)
@@ -1691,30 +1686,13 @@ export default function TaskPage(): React.JSX.Element {
const handleUseLinearItem = useCallback(
(issue: LinearIssue): void => {
const repoId = primaryRepo?.id
if (!repoId) {
openComposerForLinearItem(issue)
return
}
// Why: unlike GitHub issues (fetchable via `gh`), Linear has no CLI —
// paste the full issue context so the agent can act on it without needing
// to fetch anything externally.
const parts = [
`[${issue.identifier}] ${issue.title}`,
`Status: ${issue.state.name} · Team: ${issue.team.name}`,
issue.assignee ? `Assignee: ${issue.assignee.displayName}` : null,
issue.labels.length > 0 ? `Labels: ${issue.labels.join(', ')}` : null,
`URL: ${issue.url}`,
issue.description ? `\n${issue.description}` : null
]
const pasteContent = parts.filter(Boolean).join('\n')
void launchWorkItemDirect({
item: { title: issue.title, url: issue.url, type: 'issue', number: null, pasteContent },
repoId,
openModalFallback: () => openComposerForLinearItem(issue)
})
// Why: same rationale as handleUseWorkItem — open the New Workspace
// dialog pre-filled rather than yolo-creating the worktree, so the
// user can confirm name / agent / setup before the worktree lands in
// the sidebar.
openComposerForLinearItem(issue)
},
[openComposerForLinearItem, primaryRepo?.id]
[openComposerForLinearItem]
)
const handleLinearConnect = useCallback(async (): Promise<void> => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,850 @@
/* eslint-disable max-lines -- Why: the smart name field owns source tabs,
search orchestration, and result rendering so the unified create flow stays
in one predictable form control instead of splitting state across fragments. */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
CaseSensitive,
CircleDot,
ExternalLink,
GitBranch,
GitPullRequest,
Github,
LoaderCircle,
Search,
Sparkles,
X
} from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import {
normalizeGitHubLinkQuery,
parseGitHubIssueOrPRLink,
type RepoSlug
} from '@/lib/github-links'
import { cn } from '@/lib/utils'
import type { GitHubWorkItem, LinearIssue } from '../../../../shared/types'
type SmartNameMode = 'smart' | 'github' | 'branches' | 'linear' | 'text'
type RepoOption = ReturnType<typeof useAppStore.getState>['repos'][number]
type SmartWorkspaceNameFieldProps = {
repos: RepoOption[]
repoId: string
onRepoChange: (repoId: string) => void
value: string
onValueChange: (value: string) => void
onGitHubItemSelect: (item: GitHubWorkItem) => void
onBranchSelect: (refName: string) => void
onLinearIssueSelect: (issue: LinearIssue) => void
selectedSource: SmartWorkspaceNameSelection | null
onClearSelectedSource: () => void
inputRef?: React.RefObject<HTMLInputElement | null>
onPlainEnter?: () => void
}
export type SmartWorkspaceNameSelection = {
kind: 'github-pr' | 'github-issue' | 'branch' | 'linear'
label: string
url?: string
}
const SEARCH_DEBOUNCE_MS = 200
const RESULT_LIMIT = 12
const MODES: {
id: SmartNameMode
label: string
Icon: React.ComponentType<{ className?: string }>
}[] = [
{ id: 'smart', label: 'Smart', Icon: Sparkles },
{ id: 'github', label: 'GitHub', Icon: Github },
{ id: 'branches', label: 'Branch', Icon: GitBranch },
{
id: 'linear',
label: 'Linear',
Icon: ({ className }: { className?: string }) => (
<svg viewBox="0 0 24 24" aria-hidden className={className} fill="currentColor">
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
)
},
{ id: 'text', label: 'Name', Icon: CaseSensitive }
]
type RowEntry =
| { kind: 'use-name'; value: string; name: string }
| { kind: 'github'; value: string; item: GitHubWorkItem }
| { kind: 'branch'; value: string; refName: string }
| { kind: 'linear'; value: string; issue: LinearIssue }
export default function SmartWorkspaceNameField({
repos,
repoId,
onRepoChange,
value,
onValueChange,
onGitHubItemSelect,
onBranchSelect,
onLinearIssueSelect,
selectedSource,
onClearSelectedSource,
inputRef,
onPlainEnter
}: SmartWorkspaceNameFieldProps): React.JSX.Element {
const {
addRepo,
fetchWorkItems,
getCachedWorkItems,
linearStatus,
listLinearIssues,
searchLinearIssues
} = useAppStore(
useShallow((s) => ({
addRepo: s.addRepo,
fetchWorkItems: s.fetchWorkItems,
getCachedWorkItems: s.getCachedWorkItems,
linearStatus: s.linearStatus,
listLinearIssues: s.listLinearIssues,
searchLinearIssues: s.searchLinearIssues
}))
)
const selectedRepo = useMemo(
() => repos.find((repo) => repo.id === repoId) ?? null,
[repoId, repos]
)
const [mode, setMode] = useState<SmartNameMode>('smart')
const [open, setOpen] = useState(false)
const [debouncedQuery, setDebouncedQuery] = useState(value)
const [githubItems, setGithubItems] = useState<GitHubWorkItem[]>([])
const [branches, setBranches] = useState<string[]>([])
const [linearIssues, setLinearIssues] = useState<LinearIssue[]>([])
const [githubLoading, setGithubLoading] = useState(false)
const [branchesLoading, setBranchesLoading] = useState(false)
const [linearLoading, setLinearLoading] = useState(false)
const [commandValue, setCommandValue] = useState('')
const localInputRef = useRef<HTMLInputElement | null>(null)
const tabsListRef = useRef<HTMLDivElement | null>(null)
const repoSlugCacheRef = useRef<Map<string, RepoSlug | null>>(new Map())
const handledCrossRepoUrlRef = useRef<string | null>(null)
const [crossRepoPrompt, setCrossRepoPrompt] = useState<{
link: NonNullable<ReturnType<typeof parseGitHubIssueOrPRLink>>
matchingRepo: RepoOption | null
} | null>(null)
const setInputNode = useCallback(
(node: HTMLInputElement | null) => {
localInputRef.current = node
if (inputRef) {
inputRef.current = node
}
},
[inputRef]
)
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedQuery(value), SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
}, [value])
useEffect(() => {
if (selectedSource) {
setOpen(false)
}
}, [selectedSource])
const normalizedGhQuery = useMemo(
() => normalizeGitHubLinkQuery(debouncedQuery),
[debouncedQuery]
)
const parsedGhLink = useMemo(() => parseGitHubIssueOrPRLink(debouncedQuery), [debouncedQuery])
const shouldQueryGithub = mode === 'smart' || mode === 'github'
const shouldQueryBranches = mode === 'smart' || mode === 'branches'
const shouldQueryLinear = mode === 'smart' || mode === 'linear'
useEffect(() => {
if (!shouldQueryGithub || !selectedRepo?.path || selectedRepo.connectionId) {
setGithubItems([])
setGithubLoading(false)
return
}
let stale = false
const directNumber = normalizedGhQuery.directNumber
const directLink = parsedGhLink
if (directLink !== null && handledCrossRepoUrlRef.current !== debouncedQuery.trim()) {
setGithubLoading(true)
void getRepoSlugCached(selectedRepo, repoSlugCacheRef.current)
.then(async (selectedSlug) => {
if (stale) {
return
}
if (!selectedSlug || sameSlug(selectedSlug, directLink.slug)) {
handledCrossRepoUrlRef.current = debouncedQuery.trim()
const item = await window.api.gh.workItemByOwnerRepo({
repoPath: selectedRepo.path,
owner: directLink.slug.owner,
repo: directLink.slug.repo,
number: directLink.number,
type: directLink.type
})
if (!stale) {
setGithubItems(item ? [{ ...item, repoId: selectedRepo.id } as GitHubWorkItem] : [])
}
return
}
const matchingRepo = await findMatchingRepoForSlug(
repos,
directLink.slug,
repoSlugCacheRef.current
)
if (!stale) {
setGithubItems([])
setOpen(false)
setCrossRepoPrompt({ link: directLink, matchingRepo })
}
})
.finally(() => {
if (!stale) {
setGithubLoading(false)
}
})
return () => {
stale = true
}
}
if (directNumber !== null) {
setGithubLoading(true)
const request =
directLink !== null
? window.api.gh.workItemByOwnerRepo({
repoPath: selectedRepo.path,
owner: directLink.slug.owner,
repo: directLink.slug.repo,
number: directLink.number,
type: directLink.type
})
: window.api.gh.workItem({ repoPath: selectedRepo.path, number: directNumber })
void request
.then((item) => {
if (!stale) {
setGithubItems(item ? [{ ...item, repoId: selectedRepo.id } as GitHubWorkItem] : [])
}
})
.catch(() => {
if (!stale) {
setGithubItems([])
}
})
.finally(() => {
if (!stale) {
setGithubLoading(false)
}
})
return () => {
stale = true
}
}
const trimmed = normalizedGhQuery.query.trim()
const query = trimmed ? normalizedGhQuery.query : ''
const cached = getCachedWorkItems(selectedRepo.path, RESULT_LIMIT, query)
if (cached) {
setGithubItems(cached.slice(0, RESULT_LIMIT))
setGithubLoading(false)
} else {
setGithubLoading(true)
}
void fetchWorkItems(selectedRepo.id, selectedRepo.path, RESULT_LIMIT, query)
.then((items) => {
if (!stale) {
setGithubItems(items.slice(0, RESULT_LIMIT))
}
})
.catch(() => {
if (!stale) {
setGithubItems([])
}
})
.finally(() => {
if (!stale) {
setGithubLoading(false)
}
})
return () => {
stale = true
}
}, [
debouncedQuery,
fetchWorkItems,
getCachedWorkItems,
normalizedGhQuery,
parsedGhLink,
repos,
selectedRepo,
shouldQueryGithub
])
useEffect(() => {
if (!shouldQueryBranches || !selectedRepo) {
setBranches([])
setBranchesLoading(false)
return
}
let stale = false
setBranchesLoading(true)
void window.api.repos
.searchBaseRefs({
repoId: selectedRepo.id,
query: debouncedQuery.trim(),
limit: RESULT_LIMIT
})
.then((results) => {
if (!stale) {
setBranches(results)
}
})
.catch(() => {
if (!stale) {
setBranches([])
}
})
.finally(() => {
if (!stale) {
setBranchesLoading(false)
}
})
return () => {
stale = true
}
}, [debouncedQuery, selectedRepo, shouldQueryBranches])
useEffect(() => {
if (!shouldQueryLinear || !linearStatus.connected) {
setLinearIssues([])
setLinearLoading(false)
return
}
let stale = false
setLinearLoading(true)
const trimmed = debouncedQuery.trim()
const request = trimmed
? searchLinearIssues(trimmed, RESULT_LIMIT)
: listLinearIssues('assigned', RESULT_LIMIT)
void request
.then((issues) => {
if (!stale) {
setLinearIssues(issues)
}
})
.catch(() => {
if (!stale) {
setLinearIssues([])
}
})
.finally(() => {
if (!stale) {
setLinearLoading(false)
}
})
return () => {
stale = true
}
// Why: list/search actions are stable store methods; depending on them
// would refetch on unrelated store writes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQuery, linearStatus.connected, shouldQueryLinear])
const rows = useMemo<RowEntry[]>(() => {
const trimmed = value.trim()
const nextRows: RowEntry[] = trimmed
? [{ kind: 'use-name', value: `use-name-${trimmed}`, name: trimmed }]
: []
if (mode === 'text') {
return nextRows
}
if (mode === 'smart' || mode === 'github') {
nextRows.push(
...githubItems.map((item) => ({
kind: 'github' as const,
value: `github-${item.type}-${item.number}`,
item
}))
)
}
if (mode === 'smart' || mode === 'branches') {
nextRows.push(
...branches.map((refName) => ({
kind: 'branch' as const,
value: `branch-${refName}`,
refName
}))
)
}
if (mode === 'smart' || mode === 'linear') {
nextRows.push(
...linearIssues.map((issue) => ({
kind: 'linear' as const,
value: `linear-${issue.id}`,
issue
}))
)
}
return nextRows.slice(0, RESULT_LIMIT + 1)
}, [branches, githubItems, linearIssues, mode, value])
useEffect(() => {
if (rows.length > 0) {
setCommandValue((current) =>
rows.some((row) => row.value === current) ? current : rows[0].value
)
}
}, [rows])
const loading = githubLoading || branchesLoading || linearLoading
const ActiveInputIcon = mode === 'text' ? CaseSensitive : loading ? LoaderCircle : Search
const handleSelect = useCallback(
(row: RowEntry) => {
if (row.kind === 'use-name') {
onValueChange(row.name)
} else if (row.kind === 'github') {
onGitHubItemSelect(row.item)
} else if (row.kind === 'branch') {
onBranchSelect(row.refName)
} else {
onLinearIssueSelect(row.issue)
}
setOpen(false)
},
[onBranchSelect, onGitHubItemSelect, onLinearIssueSelect, onValueChange]
)
const acceptGitHubLink = useCallback(
async (targetRepo: RepoOption): Promise<void> => {
if (!crossRepoPrompt) {
return
}
handledCrossRepoUrlRef.current = debouncedQuery.trim()
setGithubLoading(true)
try {
const item = await window.api.gh.workItemByOwnerRepo({
repoPath: targetRepo.path,
owner: crossRepoPrompt.link.slug.owner,
repo: crossRepoPrompt.link.slug.repo,
number: crossRepoPrompt.link.number,
type: crossRepoPrompt.link.type
})
if (!item) {
return
}
onRepoChange(targetRepo.id)
onGitHubItemSelect({ ...item, repoId: targetRepo.id } as GitHubWorkItem)
setOpen(false)
setCrossRepoPrompt(null)
} finally {
setGithubLoading(false)
}
},
[crossRepoPrompt, debouncedQuery, onGitHubItemSelect, onRepoChange]
)
const handleUseCurrentRepo = useCallback(async (): Promise<void> => {
if (!selectedRepo) {
return
}
setCrossRepoPrompt(null)
await acceptGitHubLink(selectedRepo)
}, [acceptGitHubLink, selectedRepo])
const handleAddMatchingRepo = useCallback(async (): Promise<void> => {
if (!crossRepoPrompt) {
return
}
const added = await addRepo()
if (!added) {
return
}
repoSlugCacheRef.current.delete(added.id)
const slug = await getRepoSlugCached(added, repoSlugCacheRef.current)
if (slug && sameSlug(slug, crossRepoPrompt.link.slug)) {
await acceptGitHubLink(added)
}
}, [acceptGitHubLink, addRepo, crossRepoPrompt])
const dismissCrossRepoPrompt = useCallback((): void => {
handledCrossRepoUrlRef.current = debouncedQuery.trim()
setCrossRepoPrompt(null)
}, [debouncedQuery])
const placeholder =
mode === 'smart'
? 'Type a name, #1234, branch, GitHub or Linear URL'
: mode === 'github'
? 'Search GitHub PRs and issues'
: mode === 'branches'
? 'Search branches'
: mode === 'linear'
? 'Search Linear issues'
: 'Workspace name'
return (
<div className="min-w-0 space-y-1.5">
<Tabs
value={mode}
onValueChange={(next) => {
setMode(next as SmartNameMode)
setOpen(next !== 'text')
requestAnimationFrame(() => localInputRef.current?.focus({ preventScroll: true }))
}}
className="gap-0"
>
<TabsList
ref={tabsListRef}
variant="line"
className="h-7 w-full justify-start gap-4 border-b border-border/40 px-0"
>
{MODES.map(({ id, label, Icon }) => (
<TabsTrigger
key={id}
value={id}
tabIndex={-1}
data-smart-name-mode={id}
className="flex-none gap-1.5 px-0 text-xs"
>
<Icon className="size-3.5" />
<span>{label}</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Popover open={open && mode !== 'text'} onOpenChange={setOpen}>
<Command
value={commandValue}
onValueChange={setCommandValue}
shouldFilter={false}
className="overflow-visible bg-transparent"
>
<PopoverAnchor asChild>
<div className="relative min-w-0">
{selectedSource ? (
// Why: min-w-0 + w-full lets the pill shrink to its flex
// parent; without them the inner truncate's intrinsic
// min-content (long PR title) propagates up and pushes the
// dialog wider than its max-w.
<div className="flex h-9 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 text-sm shadow-xs focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50">
<SelectionIcon kind={selectedSource.kind} />
<span className="min-w-0 flex-1 truncate font-medium leading-none text-foreground">
{selectedSource.label}
</span>
{selectedSource.url ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => void window.api.shell.openUrl(selectedSource.url!)}
className="size-6 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
aria-label="Open link in browser"
>
<ExternalLink className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
Open in browser
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClearSelectedSource}
className="size-6 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
aria-label="Clear selected source"
>
<X className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
Clear
</TooltipContent>
</Tooltip>
</div>
) : (
<>
<ActiveInputIcon
className={cn(
'pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground',
loading && mode !== 'text' && 'animate-spin'
)}
/>
<Input
ref={setInputNode}
value={value}
onChange={(event) => {
onValueChange(event.target.value)
if (mode !== 'text') {
setOpen(true)
}
}}
onFocus={() => {
if (mode !== 'text') {
setOpen(true)
}
}}
onKeyDown={(event) => {
if (event.key === 'Tab' && event.shiftKey) {
const activeTrigger = tabsListRef.current?.querySelector<HTMLElement>(
`[data-smart-name-mode="${mode}"]`
)
if (activeTrigger) {
event.preventDefault()
activeTrigger.focus()
return
}
}
if (
event.key === 'Enter' &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey
) {
if (open && rows.length > 0) {
const row = rows.find((entry) => entry.value === commandValue)
if (row) {
event.preventDefault()
handleSelect(row)
}
return
}
onPlainEnter?.()
}
if (event.key === 'Escape' && open) {
event.stopPropagation()
setOpen(false)
}
}}
placeholder={placeholder}
className="h-9 pl-8 text-sm"
/>
</>
)}
</div>
</PopoverAnchor>
<PopoverContent
align="start"
sideOffset={4}
className="popover-scroll-content flex w-[var(--radix-popover-trigger-width)] flex-col p-0"
style={{ maxHeight: 'min(var(--radix-popover-content-available-height,22rem),22rem)' }}
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={(event) => {
// Why: the input is a PopoverAnchor, not a PopoverTrigger, so
// Radix treats clicks on it as outside the popover. Keep focus
// clicks and mode-tab clicks from immediately closing results.
const target = event.target as Node
if (
localInputRef.current?.contains(target) ||
tabsListRef.current?.contains(target)
) {
event.preventDefault()
}
}}
onFocusOutside={(event) => {
const target = event.target as Node
if (
localInputRef.current?.contains(target) ||
tabsListRef.current?.contains(target)
) {
event.preventDefault()
}
}}
>
<CommandList className="!max-h-none min-h-0 flex-1 scrollbar-sleek">
{loading && rows.length === 0 ? (
<div className="space-y-1 p-1">
{[0, 1, 2].map((index) => (
<div key={index} className="h-8 animate-pulse rounded bg-muted/40" />
))}
</div>
) : rows.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{mode === 'linear' && !linearStatus.connected
? 'Connect Linear in Settings to search issues.'
: 'Start typing to create a name or find a source.'}
</div>
) : (
<CommandGroup className="p-1">
{rows.map((row) => (
<CommandItem
key={row.value}
value={row.value}
onSelect={() => handleSelect(row)}
className="gap-2 px-2 py-1.5 text-xs"
>
<RowIcon row={row} />
<RowLabel row={row} />
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</PopoverContent>
</Command>
</Popover>
<Dialog
open={crossRepoPrompt !== null}
onOpenChange={(next) => !next && dismissCrossRepoPrompt()}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Switch project?</DialogTitle>
<DialogDescription>
The GitHub URL points to {crossRepoPrompt?.link.slug.owner}/
{crossRepoPrompt?.link.slug.repo}, which is different from the selected project.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={dismissCrossRepoPrompt}>
Cancel
</Button>
<Button variant="outline" onClick={() => void handleUseCurrentRepo()}>
Keep {selectedRepo?.displayName ?? 'current project'}
</Button>
{crossRepoPrompt?.matchingRepo ? (
<Button onClick={() => void acceptGitHubLink(crossRepoPrompt.matchingRepo!)}>
Switch to {crossRepoPrompt.matchingRepo.displayName}
</Button>
) : (
<Button onClick={() => void handleAddMatchingRepo()}>Add project...</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function RowIcon({ row }: { row: RowEntry }): React.JSX.Element {
if (row.kind === 'use-name') {
return <CaseSensitive className="size-3.5 shrink-0 text-muted-foreground" />
}
if (row.kind === 'github') {
return row.item.type === 'pr' ? (
<GitPullRequest className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<CircleDot className="size-3.5 shrink-0 text-muted-foreground" />
)
}
if (row.kind === 'branch') {
return <GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
}
return (
<span className="size-3.5 shrink-0 rounded-sm bg-muted text-[8px] font-semibold leading-3.5 text-muted-foreground">
L
</span>
)
}
function SelectionIcon({ kind }: { kind: SmartWorkspaceNameSelection['kind'] }): React.JSX.Element {
if (kind === 'github-pr') {
return <GitPullRequest className="size-3.5 shrink-0 text-muted-foreground" />
}
if (kind === 'github-issue') {
return <CircleDot className="size-3.5 shrink-0 text-muted-foreground" />
}
if (kind === 'branch') {
return <GitBranch className="size-3.5 shrink-0 text-muted-foreground" />
}
return (
<span className="size-3.5 shrink-0 rounded-sm bg-muted text-center text-[8px] font-semibold leading-3.5 text-muted-foreground">
L
</span>
)
}
function RowLabel({ row }: { row: RowEntry }): React.JSX.Element {
if (row.kind === 'use-name') {
return (
<span className="min-w-0 truncate">
Use <span className="font-medium text-foreground">&ldquo;{row.name}&rdquo;</span> as
workspace name
</span>
)
}
if (row.kind === 'github') {
return (
<span className="min-w-0 truncate">
<span className="font-medium text-foreground">#{row.item.number}</span> {row.item.title}
</span>
)
}
if (row.kind === 'branch') {
return <span className="min-w-0 truncate font-mono text-[11px]">{row.refName}</span>
}
return (
<span className="min-w-0 truncate">
<span className="font-medium text-foreground">{row.issue.identifier}</span> {row.issue.title}
</span>
)
}
function sameSlug(left: RepoSlug, right: RepoSlug): boolean {
return (
left.owner.toLowerCase() === right.owner.toLowerCase() &&
left.repo.toLowerCase() === right.repo.toLowerCase()
)
}
async function getRepoSlugCached(
repo: RepoOption,
cache: Map<string, RepoSlug | null>
): Promise<RepoSlug | null> {
if (cache.has(repo.id)) {
return cache.get(repo.id) ?? null
}
if (repo.connectionId) {
cache.set(repo.id, null)
return null
}
try {
const slug = await window.api.gh.repoSlug({ repoPath: repo.path })
cache.set(repo.id, slug)
return slug
} catch {
cache.set(repo.id, null)
return null
}
}
async function findMatchingRepoForSlug(
repos: RepoOption[],
slug: RepoSlug,
cache: Map<string, RepoSlug | null>
): Promise<RepoOption | null> {
for (const repo of repos) {
const candidate = await getRepoSlugCached(repo, cache)
if (candidate && sameSlug(candidate, slug)) {
return repo
}
}
return null
}
@@ -13,6 +13,36 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-t
// MaxListenersExceededWarning with many panes/tabs.
export const ptyDataHandlers = new Map<string, (data: string) => void>()
/** Sidecar subscriptions that observe PTY data without owning the primary
* handler. Used by features that need to react to the live byte stream
* (e.g. agent-paste-draft watching for DECSET 2004 / bracketed-paste-
* enable). Sidecars are invoked AFTER the primary handler so xterm rendering
* is never delayed by a side-effect-only watcher. Each Set entry is one
* active subscription; removal is by Set.delete inside the unsubscribe fn. */
export const ptyDataSidecars = new Map<string, Set<(data: string) => void>>()
/** Register a side-channel data watcher for a PTY without taking ownership
* of the primary handler. Returns an unsubscribe fn. ensurePtyDispatcher()
* is called automatically so the underlying IPC stream is wired up. */
export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void {
ensurePtyDispatcher()
let set = ptyDataSidecars.get(ptyId)
if (!set) {
set = new Set()
ptyDataSidecars.set(ptyId, set)
}
set.add(watcher)
return () => {
const current = ptyDataSidecars.get(ptyId)
if (!current) {
return
}
current.delete(watcher)
if (current.size === 0) {
ptyDataSidecars.delete(ptyId)
}
}
}
/** Per-PTY replay handlers for relay pty.attach replay data. Routed through
* a dedicated pty:replay IPC channel so the renderer can engage the replay
* guard and suppress xterm auto-replies during replay. */
@@ -50,6 +80,20 @@ export function ensurePtyDispatcher(): void {
ptyDispatcherAttached = true
window.api.pty.onData((payload) => {
ptyDataHandlers.get(payload.id)?.(payload.data)
const sidecars = ptyDataSidecars.get(payload.id)
if (sidecars && sidecars.size > 0) {
// Why: snapshot the Set before iterating because watchers commonly
// unsubscribe themselves on the very chunk that satisfies them
// (e.g. agent-paste-draft resolves on DECSET 2004 and immediately
// tears down). Iterating the live Set in that case can skip a
// watcher or — if a watcher synchronously subscribes a sibling —
// double-fire. The Set is never large (one watcher per active
// ready-wait), so the array allocation is cheap.
const snapshot = Array.from(sidecars)
for (const watcher of snapshot) {
watcher(payload.data)
}
}
})
window.api.pty.onReplay((payload) => {
ptyReplayHandlers.get(payload.id)?.(payload.data)
+206 -21
View File
@@ -9,10 +9,12 @@ import { useAppStore } from '@/store'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { parseGitHubIssueOrPRNumber, normalizeGitHubLinkQuery } from '@/lib/github-links'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { isGitRepoKind } from '../../../shared/repo-kind'
import type {
GitHubWorkItem,
LinearIssue,
OrcaHooks,
SetupDecision,
SetupRunPolicy,
@@ -35,6 +37,7 @@ import {
type LinkedWorkItemSummary
} from '@/lib/new-workspace'
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
@@ -65,7 +68,12 @@ export type ComposerCardProps = {
repoId: string
onRepoChange: (value: string) => void
name: string
onNameChange: (event: React.ChangeEvent<HTMLInputElement>) => void
onNameValueChange: (value: string) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
onSmartBranchSelect: (refName: string) => void
onSmartLinearIssueSelect: (issue: LinearIssue) => void
smartNameSelection: SmartWorkspaceNameSelection | null
onClearSmartNameSelection: () => void
agentPrompt: string
onAgentPromptChange: (value: string) => void
onPromptKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
@@ -675,8 +683,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// discriminant, so the union-preserving shape must be asserted.
// Why: the link popover intentionally does NOT surface
// `envelope.errors?.issues`. Per-surface error copy lives in the
// Tasks view (TaskPage) and the new-workspace Create tab
// (CreateFromTab) — a partial-failure banner inside the small
// Tasks view (TaskPage) and the smart workspace-name field — a
// partial-failure banner inside the small
// @-mention popover would crowd the input and the user would
// already see the same error on the originating Tasks page. If a
// future UX decision flips this, add an error row to the popover's
@@ -807,9 +815,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}, [name])
const handleNameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
const nextName = event.target.value
const handleNameValueChange = useCallback(
(nextName: string): void => {
// Why: linked GitHub items should keep refreshing the suggested workspace
// name only while the current value is still auto-managed. As soon as the
// user edits the field by hand, later issue/PR selections must stop
@@ -824,7 +831,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
},
[name]
)
const handleAddAttachment = useCallback(async (): Promise<void> => {
try {
const selectedPath = await window.api.shell.pickAttachment()
@@ -1042,6 +1048,119 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[applyLinkedWorkItem]
)
const handleSmartGitHubItemSelect = useCallback(
(item: GitHubWorkItem): void => {
applyLinkedWorkItem(item)
setStartFromResetHint(null)
const repoForItem = eligibleRepos.find((repo) => repo.id === item.repoId) ?? selectedRepo
if (item.type !== 'pr' || !repoForItem) {
return
}
void window.api.worktrees
.resolvePrBase({
repoId: repoForItem.id,
prNumber: item.number,
...(item.branchName ? { headRefName: item.branchName } : {}),
...(item.isCrossRepository !== undefined
? { isCrossRepository: item.isCrossRepository }
: {})
})
.then((result) => {
if ('error' in result) {
return
}
handleBaseBranchPrSelect(result.baseBranch, item)
})
},
[applyLinkedWorkItem, eligibleRepos, handleBaseBranchPrSelect, selectedRepo]
)
const handleSmartBranchSelect = useCallback(
(refName: string): void => {
setBaseBranch(refName)
setStartFromResetHint(null)
if (!name.trim() || name === lastAutoNameRef.current) {
setName(refName)
lastAutoNameRef.current = refName
}
},
[name]
)
const handleSmartLinearIssueSelect = useCallback(
(issue: LinearIssue): void => {
setLinkedIssue('')
setLinkedPR(null)
setLinkedWorkItem({
type: 'issue',
// Why: Linear identifiers are strings (e.g. ENG-123); keep GitHub
// numeric metadata empty and carry the real source through the URL.
number: 0,
title: issue.title,
url: issue.url
})
const suggestedName = issue.title
if (!name.trim() || name === lastAutoNameRef.current) {
setName(suggestedName)
lastAutoNameRef.current = suggestedName
}
const details = [
`[${issue.identifier}] ${issue.title}`,
`Status: ${issue.state.name} · Team: ${issue.team.name}`,
issue.assignee ? `Assignee: ${issue.assignee.displayName}` : null,
issue.labels.length > 0 ? `Labels: ${issue.labels.join(', ')}` : null,
`URL: ${issue.url}`,
issue.description ? `\n${issue.description}` : null
]
.filter(Boolean)
.join('\n')
if (!noteRef.current.trim() || noteRef.current === lastAutoNoteRef.current) {
setNote(details)
lastAutoNoteRef.current = details
}
},
[name]
)
const handleClearSmartNameSelection = useCallback((): void => {
setLinkedIssue('')
setLinkedPR(null)
setLinkedWorkItem(null)
setBaseBranch(undefined)
setStartFromResetHint(null)
if (name === lastAutoNameRef.current) {
setName('')
lastAutoNameRef.current = ''
}
if (noteRef.current === lastAutoNoteRef.current) {
setNote('')
lastAutoNoteRef.current = ''
}
}, [name])
const smartNameSelection = useMemo<SmartWorkspaceNameSelection | null>(() => {
if (linkedWorkItem) {
const isLinear = linkedWorkItem.number === 0 && !linkedWorkItem.url.includes('github.com')
const kind: SmartWorkspaceNameSelection['kind'] = isLinear
? 'linear'
: linkedWorkItem.type === 'pr'
? 'github-pr'
: 'github-issue'
return {
kind,
label:
isLinear || linkedWorkItem.number === 0
? linkedWorkItem.title
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
url: linkedWorkItem.url
}
}
if (baseBranch) {
return { kind: 'branch', label: baseBranch }
}
return null
}, [baseBranch, linkedWorkItem])
const handleOpenAgentSettings = useCallback((): void => {
openSettingsTarget({ pane: 'agents', repoId: null })
openSettingsPage()
@@ -1202,8 +1321,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const workspaceName = getWorkspaceSeedName({
explicitName: name,
prompt: '',
linkedIssueNumber: null,
linkedPR: null,
linkedIssueNumber: parsedLinkedIssueNumber,
linkedPR,
fallbackName: fallbackCreatureName
})
if (
@@ -1241,18 +1360,76 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const worktree = result.worktree
const trimmedNote = note.trim()
await applyWorktreeMeta(worktree.id, trimmedNote ? { comment: trimmedNote } : {})
await applyWorktreeMeta(worktree.id, {
...(parsedLinkedIssueNumber !== null ? { linkedIssue: parsedLinkedIssueNumber } : {}),
...(linkedPR !== null ? { linkedPR } : {}),
...(trimmedNote ? { comment: trimmedNote } : {})
})
const startupPlan =
agent === null
? null
: buildAgentStartupPlan({
agent,
prompt: '',
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
// Why: when a linked work item is selected in the quick flow, launch
// the agent with a blank prompt and type the URL into its input as a
// draft (no trailing Enter). This lets the user review/edit before
// sending instead of auto-executing a "Complete <url>" template.
// Falls back to the trimmed note when the linked item carries no
// number/URL (Linear typed-only entries).
const isLinearTypedOnly = linkedWorkItem?.number === 0 && Boolean(trimmedNote)
const quickPrompt = isLinearTypedOnly && trimmedNote ? trimmedNote : ''
const quickDraftPrompt = linkedWorkItem && !isLinearTypedOnly ? linkedWorkItem.url : null
// Why: agents that gate first-launch behind a "Do you trust this
// folder?" menu (cursor-agent, copilot) consume the bracketed paste
// as menu input. Pre-write the trust artifact so the menu is
// skipped — best-effort, errors swallowed by main. Guard the IPC
// presence so a stale preload bundle doesn't crash the launch with
// "Cannot read properties of undefined".
if (agent && worktree.path && window.api.agentTrust?.markTrusted) {
const preflight = TUI_AGENT_CONFIG[agent].preflightTrust
if (preflight) {
try {
await window.api.agentTrust.markTrusted({
preset: preflight,
workspacePath: worktree.path
})
} catch {
// Best-effort: continue with launch.
}
}
}
// Why: prefer the agent's native prefill flag (currently Claude's
// `--prefill`) when it has one — sidesteps the readiness/paste race
// entirely. Falls through to the type-after-ready path for every
// other agent.
const draftLaunchPlan =
agent === null || !quickDraftPrompt
? null
: buildAgentDraftLaunchPlan({
agent,
draft: quickDraftPrompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM
})
let startupPlan: ReturnType<typeof buildAgentStartupPlan> = null
if (draftLaunchPlan) {
startupPlan = {
agent: draftLaunchPlan.agent,
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null
}
} else if (agent !== null) {
startupPlan = buildAgentStartupPlan({
agent,
prompt: quickPrompt,
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
})
if (startupPlan && quickDraftPrompt) {
startupPlan.draftPrompt = quickDraftPrompt
}
}
activateAndRevealWorktree(worktree.id, {
setup: result.setup,
@@ -1287,10 +1464,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
clearNewWorkspaceDraft,
createWorktree,
fallbackCreatureName,
linkedPR,
linkedWorkItem,
name,
normalizedSparseDirectories,
note,
onCreated,
parsedLinkedIssueNumber,
persistDraft,
repoId,
requiresExplicitSetupChoice,
@@ -1323,7 +1503,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
repoId,
onRepoChange: handleRepoChange,
name,
onNameChange: handleNameChange,
onNameValueChange: handleNameValueChange,
onSmartGitHubItemSelect: handleSmartGitHubItemSelect,
onSmartBranchSelect: handleSmartBranchSelect,
onSmartLinearIssueSelect: handleSmartLinearIssueSelect,
smartNameSelection,
onClearSmartNameSelection: handleClearSmartNameSelection,
agentPrompt,
onAgentPromptChange: setAgentPrompt,
onPromptKeyDown: handlePromptKeyDown,
+2 -7
View File
@@ -137,7 +137,7 @@ export function useIpcEvents(): void {
)
unsubs.push(
window.api.ui.onOpenNewWorkspace((tab) => {
window.api.ui.onOpenNewWorkspace(() => {
// Why: mirror the renderer's App.tsx Cmd+N guard — only open the
// composer when there is at least one real git repo configured, so
// users on a fresh install don't get a modal with nothing to target.
@@ -146,15 +146,10 @@ export function useIpcEvents(): void {
return
}
dispatchClearModifierHints()
// Why: if the composer is already open, switch tabs in place so
// repeated Cmd+N / Cmd+Shift+N presses toggle between Quick and
// Create-from without remounting and losing in-flight composer state
// (repo pick, note drafts). Opening when closed seeds the initial tab.
if (store.activeModal === 'new-workspace-composer') {
store.setNewWorkspaceComposerTab(tab)
return
}
store.openModal('new-workspace-composer', { initialTab: tab })
store.openModal('new-workspace-composer')
})
)
+172
View File
@@ -0,0 +1,172 @@
import type { TuiAgent } from '../../../shared/types'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { useAppStore } from '@/store'
import { subscribeToPtyData } from '@/components/terminal-pane/pty-dispatcher'
// Why: bracketed paste markers let modern TUIs (Claude Code / Codex / Pi /
// OpenCode / Gemini / cursor-agent / copilot) treat the inserted text as a
// single atomic paste — the payload lands in the input buffer as a draft
// instead of echoing character-by-character or triggering line-edit
// shortcuts. Intentionally omit a trailing '\r' so the draft never auto-
// submits; the user reviews and sends the prompt themselves.
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
const BRACKETED_PASTE_END = '\x1b[201~'
// Why: every prefill-capable TUI we ship support for (claude / codex / pi /
// opencode / gemini / cursor-agent / copilot) emits `CSI ? 2004 h` (DECSET
// 2004 — bracketed-paste-enable) on its output stream when its input layer
// is wired up. That sequence is the protocol-level "I accept bracketed
// paste" handshake — but on its own it doesn't mean "the input box is
// rendered and visible". OpenCode in particular emits DECSET 2004 during
// its alt-screen setup at ~500ms, then runs a 1.3s splash render with NO
// data on the PTY, then paints the actual input box at ~1.85s. Pasting
// during the silent gap drops the bytes.
//
// Strategy: take DECSET 2004 as the necessary precondition, then wait for
// the TUI's render burst to finish — defined as `BRACKETED_PASTE_QUIET_MS`
// of stream silence after the most recent post-`?2004h` byte. This
// captures both the fast TUIs (claude/pi/codex emit their setup escapes
// in one burst, then go quiet) and the slow ones (opencode emits, sleeps,
// emits again, then goes quiet). Verified against opencode/claude/pi in
// a node-pty rig: paste lands on the first try with a 1500ms quiet window.
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
const BRACKETED_PASTE_QUIET_MS = 1500
// Why: deterministic signal can fail in two ways: (1) the agent never
// emits DECSET 2004 (no shipped agent does this — guarded as a fallback),
// or (2) the launch fails outright. The hard timeout caps the wait so a
// stuck launch doesn't pin a Promise forever.
const READINESS_TIMEOUT_MS = 8000
/**
* Wait until the agent on `tabId` has rendered its input-accepting TUI,
* then bracketed-paste `content` into its input buffer. Never appends
* `\r`, so the draft stays editable for the user to review / append
* before sending.
*
* Returns true when the paste was issued, false on timeout or missing
* PTY. `onTimeout` lets the caller surface a UI hint (e.g. toast) when
* the agent doesn't reach a ready state inside `timeoutMs`.
*
* Readiness combines two stream signals:
* 1. `\x1b[?2004h` (DECSET 2004 — bracketed-paste-enable) on the PTY
* output. This is the protocol-level "I accept bracketed paste"
* handshake.
* 2. ≥`BRACKETED_PASTE_QUIET_MS` of silence after the last byte of the
* post-handshake render burst. Captures TUIs (OpenCode) that emit
* DECSET 2004 early and then run a multi-second splash before
* drawing the actual input box.
*/
export async function pasteDraftWhenAgentReady(args: {
tabId: string
content: string
agent?: TuiAgent
timeoutMs?: number
onTimeout?: () => void
}): Promise<boolean> {
const { tabId, content, agent, timeoutMs, onTimeout } = args
// Why: agents with a documented prefill flag (currently Claude — see
// TUI_AGENT_CONFIG.claude.draftPromptFlag) launch with the URL already
// in their input box. Pasting again would duplicate it. Callers should
// not invoke this helper for those agents; the early return guards
// against accidental double-injection if a stale call slips through.
if (agent && TUI_AGENT_CONFIG[agent].draftPromptFlag) {
return false
}
const budget = timeoutMs ?? READINESS_TIMEOUT_MS
const ptyId = await waitForPtyId(tabId, budget)
if (!ptyId) {
onTimeout?.()
return false
}
const ready = await waitForInputBoxReady(ptyId, budget)
if (!ready) {
onTimeout?.()
return false
}
window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`)
return true
}
/**
* Tap the PTY data stream as a side-channel observer (does NOT take over
* the primary handler that feeds xterm) and resolve `true` once we see
* DECSET 2004 *and* the post-handshake render burst settles for
* `BRACKETED_PASTE_QUIET_MS`. Resolves `false` on hard timeout.
*
* Why a sidecar subscription:
* - the main pane may attach mid-flight; we must not race against its
* handler registration on the dispatcher's primary slot.
* - DECSET 2004 may straddle two data chunks at ANSI parser boundaries,
* so we keep a small ring of recent bytes and search the union.
*/
function waitForInputBoxReady(ptyId: string, timeoutMs: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
let settled = false
let recent = ''
let saw2004 = false
let quietTimer: number | null = null
let unsubscribe: (() => void) | null = null
const finish = (value: boolean): void => {
if (settled) {
return
}
settled = true
window.clearTimeout(hardTimer)
if (quietTimer !== null) {
window.clearTimeout(quietTimer)
}
unsubscribe?.()
resolve(value)
}
const armQuietTimer = (): void => {
if (quietTimer !== null) {
window.clearTimeout(quietTimer)
}
quietTimer = window.setTimeout(() => finish(true), BRACKETED_PASTE_QUIET_MS)
}
unsubscribe = subscribeToPtyData(ptyId, (data) => {
// Why: keep just enough recent bytes that an escape sequence split
// across two IPC frames is still detectable. 64 bytes >> 8-byte
// sequence; cheap and bounded.
recent = (recent + data).slice(-64)
if (!saw2004 && recent.includes(DECSET_BRACKETED_PASTE)) {
saw2004 = true
}
if (saw2004) {
// Reset the quiet window on every byte we see post-handshake.
// The TUI's render is "done" when the stream goes quiet for
// BRACKETED_PASTE_QUIET_MS — at that point the input box is
// mounted and bracketed paste lands in the input buffer.
armQuietTimer()
}
})
const hardTimer = window.setTimeout(() => finish(false), timeoutMs)
})
}
/**
* Why: activation creates the tab synchronously but the PTY spawn is
* async. Poll the store until the primary PTY id appears or the budget
* expires. Tight interval because the wait is normally <200ms — only the
* first launch on a cold app reaches the tail of this.
*/
async function waitForPtyId(tabId: string, timeoutMs: number): Promise<string | null> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const ptyId = useAppStore.getState().ptyIdsByTabId[tabId]?.[0]
if (ptyId) {
return ptyId
}
await new Promise<void>((resolve) => window.setTimeout(resolve, 50))
}
return null
}
+2
View File
@@ -52,6 +52,7 @@ export function parseGitHubIssueOrPRNumber(input: string): number | null {
export function parseGitHubIssueOrPRLink(input: string): {
slug: RepoSlug
number: number
type: 'issue' | 'pr'
} | null {
const trimmed = input.trim()
if (!trimmed) {
@@ -76,6 +77,7 @@ export function parseGitHubIssueOrPRLink(input: string): {
return {
slug: { owner: match[1], repo: match[2] },
type: url.pathname.toLowerCase().includes('/pull/') ? 'pr' : 'issue',
number: Number.parseInt(match[3], 10)
}
}
+78 -50
View File
@@ -1,8 +1,9 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { waitForAgentReady } from '@/lib/agent-ready-wait'
import { buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import {
CLIENT_PLATFORM,
@@ -30,13 +31,9 @@ export type LaunchableWorkItem = {
linearIdentifier?: string
}
// Why: bracketed paste markers let modern TUIs treat the inserted text as a
// single atomic paste — Claude Code / Codex / Gemini put it in their input
// buffer as a draft instead of echoing character-by-character. Intentionally
// omit a trailing '\r' so the draft never auto-submits; the user gets to
// review and send the prompt themselves.
const BRACKETED_PASTE_BEGIN = '\x1b[200~'
const BRACKETED_PASTE_END = '\x1b[201~'
// Why: bracketed paste markers and ready-wait grace timing live in
// agent-paste-draft.ts so the new-workspace and "Use" flows share one
// definition of "type into the agent's input as a non-submitted draft".
export type LaunchWorkItemDirectArgs = {
item: LaunchableWorkItem
@@ -47,7 +44,7 @@ export type LaunchWorkItemDirectArgs = {
openModalFallback: () => void
/** Optional base branch to start the worktree from. When omitted the
* worktree inherits the repo's effective base ref. Used by the
* "Create from…" PR row to branch from the PR's head so the first
* smart workspace-name PR selection to branch from the PR's head so the first
* commit lands on the correct base without the user touching the UI. */
baseBranch?: string
}
@@ -105,31 +102,15 @@ async function pasteWorkItemDraftWhenAgentReady(args: {
content: string
}): Promise<void> {
const { primaryTabId, startupPlan, content } = args
const readyResult = await waitForAgentReady(primaryTabId, startupPlan.expectedProcess, {
timeoutMs: 5000
await pasteDraftWhenAgentReady({
tabId: primaryTabId,
content,
agent: startupPlan.agent,
onTimeout: () =>
toast.message(
'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.'
)
})
if (!readyResult.ready) {
toast.message(
'Agent took too long to start. The workspace is ready — paste the issue URL when the agent is idle.'
)
return
}
const finalState = useAppStore.getState()
const ptyId = finalState.ptyIdsByTabId[primaryTabId]?.[0]
if (!ptyId) {
return
}
// Why: TUIs must enable bracketed paste mode (\x1b[?2004h) before they can
// interpret our paste markers. `title-idle` means the TUI has fully rendered
// its input box and enabled paste mode; weaker signals (`foreground-match`,
// `child-process`) only confirm the binary is running — the TUI's input
// setup may still be in-flight, especially on slow shell environments.
const graceMs = readyResult.reason === 'title-idle' ? 150 : 600
await new Promise((resolve) => window.setTimeout(resolve, graceMs))
window.api.pty.write(ptyId, `${BRACKETED_PASTE_BEGIN}${content}${BRACKETED_PASTE_END}`)
}
/**
@@ -179,25 +160,70 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
let worktreeId: string
let primaryTabId: string | null
let startupPlan: ReturnType<typeof buildAgentStartupPlan> = null
let draftLaunchedNatively = false
try {
const result = await store.createWorktree(repoId, workspaceName, baseBranch, finalSetupDecision)
worktreeId = result.worktree.id
const worktreePath = result.worktree.path
const detectedIds = new Set(await detectedAgentsPromise)
const effectiveAgent = pickAgent(settings?.defaultTuiAgent, detectedIds)
// Why: launch the agent with no prompt so the first frame it draws is the
// empty input box. The URL paste below populates that input buffer, which
// gives the user a reviewable draft instead of a submitted request.
startupPlan =
const draftContent = item.pasteContent ?? item.url
// Why: agents that gate first-launch behind a "Do you trust this folder?"
// menu (cursor-agent, copilot) consume the bracketed paste as menu input.
// Pre-write the same trust artifact those CLIs write after the user
// accepts so the menu never fires. Best-effort — main swallows errors,
// and we guard the IPC presence so a stale preload bundle (which can
// ship a renderer that's ahead of the loaded preload) doesn't crash the
// launch with "Cannot read properties of undefined".
if (effectiveAgent && worktreePath && window.api.agentTrust?.markTrusted) {
const preflight = TUI_AGENT_CONFIG[effectiveAgent].preflightTrust
if (preflight) {
try {
await window.api.agentTrust.markTrusted({
preset: preflight,
workspacePath: worktreePath
})
} catch {
// Best-effort: continue with launch even if the trust write
// throws. The user can dismiss the trust menu manually.
}
}
}
// Why: prefer a native prefill flag (e.g. `claude --prefill <url>`) when
// the agent's CLI exposes one — the TUI mounts with the URL already in
// its input box, which sidesteps the readiness/paste race entirely. Fall
// back to launching with no prompt + bracketed-paste-after-ready for
// every other agent so the URL still lands as a draft (not auto-
// submitted as the first turn).
const draftLaunchPlan =
effectiveAgent === null
? null
: buildAgentStartupPlan({
: buildAgentDraftLaunchPlan({
agent: effectiveAgent,
prompt: '',
draft: draftContent,
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
platform: CLIENT_PLATFORM
})
if (draftLaunchPlan) {
startupPlan = {
agent: draftLaunchPlan.agent,
launchCommand: draftLaunchPlan.launchCommand,
expectedProcess: draftLaunchPlan.expectedProcess,
followupPrompt: null
}
draftLaunchedNatively = true
} else if (effectiveAgent !== null) {
startupPlan = buildAgentStartupPlan({
agent: effectiveAgent,
prompt: '',
cmdOverrides: settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
})
}
const activation = activateAndRevealWorktree(worktreeId, {
setup: result.setup,
@@ -241,18 +267,20 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
store.setRightSidebarOpen(true)
}
// Why: at this point the workspace is live and the agent (if any) has been
// queued on `primaryTabId`. The paste step below is the only remaining
// draft-specific work; bail out cleanly when either prerequisite is missing.
if (!primaryTabId || !startupPlan) {
// Why: at this point the workspace is live and the agent (if any) has
// been queued on `primaryTabId`. The post-launch paste step below only
// applies to agents that lacked a native prefill flag; for agents that
// were launched with the URL already on argv (Claude --prefill today),
// the URL is in the input box already — pasting again would duplicate it.
if (!primaryTabId || !startupPlan || draftLaunchedNatively) {
return
}
const content = item.pasteContent ?? item.url
// Why: the workspace is already created and visible; waiting up to 5s for
// agent readiness here kept the Create-from modal in "Creating workspace…".
// Continue the draft paste in the background so selection latency ends when
// the worktree is ready, not when the TUI input buffer is ready.
// Why: the workspace is already created and visible; do not block selection
// latency on agent readiness. Run the paste in the background so the
// "Use" CTA's spinner ends when the worktree is ready, not when the TUI
// input buffer is ready.
void pasteWorkItemDraftWhenAgentReady({ primaryTabId, startupPlan, content })
}
+59 -36
View File
@@ -1,4 +1,5 @@
import { useAppStore } from '@/store'
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import { isShellProcess } from '@/lib/tui-agent-startup'
import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types'
@@ -195,65 +196,87 @@ export function getWorkspaceSeedName(args: {
return 'workspace'
}
// Why: bracketed paste markers and ready-wait grace timing live in
// agent-paste-draft.ts so the new-workspace and "Use" flows share one
// definition of "type into the agent's input as a non-submitted draft".
export async function ensureAgentStartupInTerminal(args: {
worktreeId: string
startup: AgentStartupPlan
}): Promise<void> {
const { worktreeId, startup } = args
if (startup.followupPrompt === null) {
const draftPrompt = startup.draftPrompt ?? null
if (startup.followupPrompt === null && draftPrompt === null) {
return
}
let promptInjected = false
// Why: poll until a terminal tab + PTY exists for the worktree before we
// can interact with it. Activation creates the tab synchronously but the
// PTY spawn is async, so a brief wait is normal.
let tabId: string | null = null
let ptyId: string | null = null
for (let attempt = 0; attempt < 30; attempt += 1) {
if (attempt > 0) {
await new Promise((resolve) => window.setTimeout(resolve, 150))
}
const state = useAppStore.getState()
const tabId =
tabId =
state.activeTabIdByWorktree[worktreeId] ?? state.tabsByWorktree[worktreeId]?.[0]?.id ?? null
if (!tabId) {
continue
}
const ptyId = state.ptyIdsByTabId[tabId]?.[0]
if (!ptyId) {
continue
ptyId = state.ptyIdsByTabId[tabId]?.[0] ?? null
if (ptyId) {
break
}
}
if (!tabId || !ptyId) {
return
}
// Why: followupPrompt is the legacy path for stdin-after-start agents
// (aider, goose, etc.) that need their initial prompt typed into the live
// session and submitted. Wait until the agent owns the PTY before writing.
if (startup.followupPrompt) {
await waitForAgentForeground(ptyId, startup.expectedProcess)
window.api.pty.write(ptyId, `${startup.followupPrompt}\r`)
}
// Why: draftPrompt uses bracketed-paste so the URL lands atomically in the
// agent's input buffer (no per-char echo, no auto-submit). Shared with the
// launch-work-item-direct flow so both behave identically.
if (draftPrompt) {
await pasteDraftWhenAgentReady({
tabId,
content: draftPrompt,
agent: startup.agent
})
}
}
// Why: legacy followupPrompt path used `agentOwnsForeground` exclusively (with
// a hasChildProcesses fallback after several polls). Preserve that behavior so
// stdin-after-start agents still receive their prompt under the same
// conditions. Returns when the agent appears ready or the budget expires.
async function waitForAgentForeground(ptyId: string, expectedProcess: string): Promise<void> {
for (let attempt = 0; attempt < 30; attempt += 1) {
if (attempt > 0) {
await new Promise((resolve) => window.setTimeout(resolve, 150))
}
try {
const foreground = (await window.api.pty.getForegroundProcess(ptyId))?.toLowerCase() ?? ''
const agentOwnsForeground =
foreground === startup.expectedProcess ||
foreground.startsWith(`${startup.expectedProcess}.`)
if (agentOwnsForeground && !promptInjected && startup.followupPrompt) {
window.api.pty.write(ptyId, `${startup.followupPrompt}\r`)
promptInjected = true
const owns =
foreground === expectedProcess ||
foreground.startsWith(`${expectedProcess}.`) ||
foreground.endsWith(`/${expectedProcess}`)
if (owns) {
return
}
if (agentOwnsForeground && promptInjected) {
return
}
const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId)
if (
!promptInjected &&
startup.followupPrompt &&
hasChildProcesses &&
!isShellProcess(foreground) &&
attempt >= 4
) {
// Why: the initial agent launch is already queued on the first terminal
// tab. Only agents without a verified startup-prompt flag need extra
// help here: once the TUI owns the PTY, type the draft prompt into the
// live session instead of launching the binary a second time.
window.api.pty.write(ptyId, `${startup.followupPrompt}\r`)
promptInjected = true
return
if (attempt >= 4 && !isShellProcess(foreground)) {
const hasChildProcesses = await window.api.pty.hasChildProcesses(ptyId)
if (hasChildProcesses) {
return
}
}
} catch {
// Ignore transient PTY inspection failures and keep polling.
+67 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { buildAgentStartupPlan, isShellProcess } from './tui-agent-startup'
import {
buildAgentDraftLaunchPlan,
buildAgentStartupPlan,
isShellProcess
} from './tui-agent-startup'
describe('buildAgentStartupPlan', () => {
it('passes Claude prompts as a positional interactive argument', () => {
@@ -11,6 +15,7 @@ describe('buildAgentStartupPlan', () => {
platform: 'darwin'
})
).toEqual({
agent: 'claude',
launchCommand: "claude 'Fix the bug'",
expectedProcess: 'claude',
followupPrompt: null
@@ -26,6 +31,7 @@ describe('buildAgentStartupPlan', () => {
platform: 'linux'
})
).toEqual({
agent: 'gemini',
launchCommand: "gemini --prompt-interactive 'Investigate this regression'",
expectedProcess: 'gemini',
followupPrompt: null
@@ -41,6 +47,7 @@ describe('buildAgentStartupPlan', () => {
platform: 'linux'
})
).toEqual({
agent: 'aider',
launchCommand: 'aider',
expectedProcess: 'aider',
followupPrompt: 'Refactor the parser'
@@ -56,6 +63,7 @@ describe('buildAgentStartupPlan', () => {
platform: 'linux'
})
).toEqual({
agent: 'autohand',
launchCommand: 'autohand',
expectedProcess: 'autohand',
followupPrompt: 'Add tests for the parser'
@@ -71,6 +79,7 @@ describe('buildAgentStartupPlan', () => {
platform: 'darwin'
})
).toEqual({
agent: 'cursor',
launchCommand: "cursor-agent 'Review this file'",
expectedProcess: 'cursor-agent',
followupPrompt: null
@@ -86,6 +95,7 @@ describe('buildAgentStartupPlan', () => {
platform: 'linux'
})
).toEqual({
agent: 'droid',
launchCommand: "/opt/factory/bin/droid 'Ship the fix'",
expectedProcess: 'droid',
followupPrompt: null
@@ -101,6 +111,7 @@ describe('buildAgentStartupPlan', () => {
platform: 'darwin'
})
).toEqual({
agent: 'copilot',
launchCommand: "copilot -i 'Fix the bug'",
expectedProcess: 'copilot',
followupPrompt: null
@@ -127,6 +138,7 @@ describe('buildAgentStartupPlan', () => {
platform: 'darwin'
})
).toEqual({
agent: 'copilot',
launchCommand: "copilot -i 'Fix the bug'",
expectedProcess: 'copilot',
followupPrompt: null
@@ -134,6 +146,60 @@ describe('buildAgentStartupPlan', () => {
})
})
describe('buildAgentDraftLaunchPlan', () => {
it('uses Claude --prefill to seed the input box without submitting', () => {
expect(
buildAgentDraftLaunchPlan({
agent: 'claude',
draft: 'https://github.com/acme/repo/issues/42',
cmdOverrides: {},
platform: 'darwin'
})
).toEqual({
agent: 'claude',
launchCommand: "claude --prefill 'https://github.com/acme/repo/issues/42'",
expectedProcess: 'claude'
})
})
it('returns null for agents without a documented prefill flag', () => {
expect(
buildAgentDraftLaunchPlan({
agent: 'codex',
draft: 'https://github.com/acme/repo/issues/42',
cmdOverrides: {},
platform: 'darwin'
})
).toBeNull()
})
it('returns null for an empty draft so callers fall back cleanly', () => {
expect(
buildAgentDraftLaunchPlan({
agent: 'claude',
draft: ' ',
cmdOverrides: {},
platform: 'darwin'
})
).toBeNull()
})
it('honors cmdOverrides so custom Claude install paths still prefill', () => {
expect(
buildAgentDraftLaunchPlan({
agent: 'claude',
draft: 'review this',
cmdOverrides: { claude: '/opt/anthropic/bin/claude' },
platform: 'linux'
})
).toEqual({
agent: 'claude',
launchCommand: "/opt/anthropic/bin/claude --prefill 'review this'",
expectedProcess: 'claude'
})
})
})
describe('isShellProcess', () => {
it('treats common shells as non-agent foreground processes', () => {
expect(isShellProcess('bash')).toBe(true)
+57
View File
@@ -2,9 +2,19 @@ import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../shared/types'
export type AgentStartupPlan = {
/** Why: surfaces the agent id so downstream paste-draft logic can resolve
* the per-agent draft injection strategy without re-deriving from the
* launch command string. */
agent: TuiAgent
launchCommand: string
expectedProcess: string
followupPrompt: string | null
/** Why: text to type into the live agent input WITHOUT submitting it (no
* trailing \r). Used by the quick-create flow to pre-fill a linked work
* item URL so the user can edit/add to it before sending. Independent from
* `followupPrompt` so the call site can choose: type-and-submit (followup)
* or type-and-leave-pending (draft). */
draftPrompt?: string | null
}
function quoteStartupArg(value: string, platform: NodeJS.Platform): string {
@@ -32,6 +42,7 @@ export function buildAgentStartupPlan(args: {
return null
}
return {
agent,
launchCommand: baseCommand,
expectedProcess: config.expectedProcess,
followupPrompt: null
@@ -42,6 +53,7 @@ export function buildAgentStartupPlan(args: {
if (config.promptInjectionMode === 'argv') {
return {
agent,
launchCommand: `${baseCommand} ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
@@ -50,6 +62,7 @@ export function buildAgentStartupPlan(args: {
if (config.promptInjectionMode === 'flag-prompt') {
return {
agent,
launchCommand: `${baseCommand} --prompt ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
@@ -58,6 +71,7 @@ export function buildAgentStartupPlan(args: {
if (config.promptInjectionMode === 'flag-prompt-interactive') {
return {
agent,
launchCommand: `${baseCommand} --prompt-interactive ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
@@ -66,6 +80,7 @@ export function buildAgentStartupPlan(args: {
if (config.promptInjectionMode === 'flag-interactive') {
return {
agent,
launchCommand: `${baseCommand} -i ${quotedPrompt}`,
expectedProcess: config.expectedProcess,
followupPrompt: null
@@ -73,6 +88,7 @@ export function buildAgentStartupPlan(args: {
}
return {
agent,
launchCommand: baseCommand,
expectedProcess: config.expectedProcess,
// Why: several agent TUIs either lack a documented "start interactive
@@ -83,4 +99,45 @@ export function buildAgentStartupPlan(args: {
}
}
export type AgentDraftLaunchPlan = {
agent: TuiAgent
launchCommand: string
expectedProcess: string
}
/**
* Why: when the agent's CLI exposes a documented "prefill but don't submit"
* flag (currently only `claude --prefill <text>`), launch with that flag so
* the TUI mounts with the draft already in its input box. This is strictly
* better than the post-launch bracketed-paste fallback in agent-paste-draft.ts
* because it eliminates the empirical readiness wait entirely — the agent
* controls when its input is rendered.
*
* Returns `null` when the agent has no native prefill flag; callers fall
* back to the paste-after-ready path.
*/
export function buildAgentDraftLaunchPlan(args: {
agent: TuiAgent
draft: string
cmdOverrides: Partial<Record<TuiAgent, string>>
platform: NodeJS.Platform
}): AgentDraftLaunchPlan | null {
const { agent, draft, cmdOverrides, platform } = args
const config = TUI_AGENT_CONFIG[agent]
if (!config.draftPromptFlag) {
return null
}
const trimmed = draft.trim()
if (!trimmed) {
return null
}
const baseCommand = cmdOverrides[agent] ?? config.launchCmd
const quoted = quoteStartupArg(trimmed, platform)
return {
agent,
launchCommand: `${baseCommand} ${config.draftPromptFlag} ${quoted}`,
expectedProcess: config.expectedProcess
}
}
export { isShellProcess } from '../../../shared/agent-detection'
+1 -31
View File
@@ -158,17 +158,6 @@ export type UISlice = {
modalData: Record<string, unknown>
openModal: (modal: UISlice['activeModal'], data?: Record<string, unknown>) => void
closeModal: () => void
/** Active tab inside the new-workspace composer modal. Mutable while the
* modal is open so Cmd+N / Cmd+Shift+N can toggle tabs without tearing
* down the composer state. */
newWorkspaceComposerTab: 'quick' | 'create-from'
setNewWorkspaceComposerTab: (tab: 'quick' | 'create-from') => void
/** Remembered sub-tab inside the Create-from tab (PRs / Issues / Branches /
* Linear). Persists across composer opens within a session so users who
* always start from Linear (for example) don't have to click back to that
* tab every time. */
createFromSubTab: 'prs' | 'issues' | 'branches' | 'linear'
setCreateFromSubTab: (tab: 'prs' | 'issues' | 'branches' | 'linear') => void
trustedOrcaHooks: PersistedTrustedOrcaHooks
markOrcaHookScriptConfirmed: (
repoId: string,
@@ -369,27 +358,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
activeModal: 'none',
modalData: {},
openModal: (modal, data = {}) => {
// Why: when the new-workspace composer opens, seed its active tab from
// modalData.initialTab so Cmd+Shift+N lands directly on the "Create from…"
// tab without the Quick tab flashing first. Default to 'quick' when no
// explicit target is provided so existing callers keep their behavior.
if (modal === 'new-workspace-composer') {
const requestedTab = (data as { initialTab?: 'quick' | 'create-from' }).initialTab
set({
activeModal: modal,
modalData: data,
newWorkspaceComposerTab: requestedTab ?? 'quick'
})
return
}
set({ activeModal: modal, modalData: data })
},
openModal: (modal, data = {}) => set({ activeModal: modal, modalData: data }),
closeModal: () => set({ activeModal: 'none', modalData: {} }),
newWorkspaceComposerTab: 'quick',
setNewWorkspaceComposerTab: (tab) => set({ newWorkspaceComposerTab: tab }),
createFromSubTab: 'prs',
setCreateFromSubTab: (tab) => set({ createFromSubTab: tab }),
trustedOrcaHooks: {},
markOrcaHookScriptConfirmed: (repoId, kind, contentHash) =>
+36 -3
View File
@@ -12,6 +12,22 @@ export type TuiAgentConfig = {
launchCmd: string
expectedProcess: string
promptInjectionMode: AgentPromptInjectionMode
/** Why: flag that launches the TUI with the given text already in the
* input box but NOT submitted, so the user still gets a reviewable draft.
* Only set when the CLI documents native support — e.g. Claude's
* `--prefill <text>`. The draft-launch flow prefers this over the
* post-launch bracketed-paste path because it eliminates the empirical
* agent-readiness wait entirely: the TUI mounts with the input pre-filled.
* Agents without native support fall through to the paste-after-ready
* code path in agent-paste-draft.ts. */
draftPromptFlag?: string
/** Why: agents that gate first-launch behind a "Do you trust this
* folder?" menu (Cursor-Agent, GitHub Copilot CLI) consume the bracketed
* paste as menu input. Pre-write the same trust artifact the agent writes
* after the user accepts so the menu never fires. The actual file/path
* written lives in src/main/agent-trust-presets.ts; this flag just routes
* the workspace path through the matching preset before the agent spawns. */
preflightTrust?: 'cursor' | 'copilot'
}
// Why: the new-workspace handoff depends on three pieces of per-agent
@@ -25,7 +41,12 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
detectCmd: 'claude',
launchCmd: 'claude',
expectedProcess: 'claude',
promptInjectionMode: 'argv'
promptInjectionMode: 'argv',
// Why: `claude --prefill <text>` lands the TUI with `<text>` in the
// input box, nothing submitted. Strictly better than the paste-after-
// ready fallback because it eliminates the readiness race entirely.
// See PR https://github.com/stablyai/orca/pull/926 for context.
draftPromptFlag: '--prefill'
},
codex: {
detectCmd: 'codex',
@@ -128,7 +149,13 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
detectCmd: 'cursor-agent',
launchCmd: 'cursor-agent',
expectedProcess: 'cursor-agent',
promptInjectionMode: 'argv'
promptInjectionMode: 'argv',
// Why: cursor-agent's first-launch trust menu ([a]/[w]/[q]) used to
// swallow our bracketed paste. Pre-writing the same `.workspace-trusted`
// marker the CLI itself writes after the user accepts (see
// agent-trust-presets.ts) makes the menu skip entirely, so the draft
// URL paste lands in the input as intended.
preflightTrust: 'cursor'
},
droid: {
detectCmd: 'droid',
@@ -174,6 +201,12 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
// completion, which would kill the TUI session Orca is hosting.
// `-i/--interactive <prompt>` starts an interactive session with the
// initial prompt pre-executed — the behavior Orca needs.
promptInjectionMode: 'flag-interactive'
promptInjectionMode: 'flag-interactive',
// Why: Copilot's first-launch trust menu used to swallow our bracketed
// paste. Pre-appending the workspace path to `trustedFolders` in
// ~/.copilot/config.json (the same array Copilot's own
// `addTrustedFolder` writes after the user accepts) makes the menu skip
// entirely. See agent-trust-presets.ts for the file layout.
preflightTrust: 'copilot'
}
}
+7 -8
View File
@@ -270,23 +270,22 @@ describe('resolveWindowShortcutAction', () => {
).toBeNull()
})
it('routes Cmd/Ctrl+Shift+N to the Create-from tab of the new-workspace composer', () => {
// Why: the shift variant of the new-workspace shortcut jumps straight to
// the "Create from…" tab so users can start from an existing GH/Linear
// item without a detour through the quick-create form.
it('routes Cmd/Ctrl+Shift+N to the unified new-workspace composer', () => {
// Why: keep the former Create-from shortcut accepted so muscle memory
// still opens the composer; source switching now lives in the smart name field.
expect(
resolveWindowShortcutAction(
{ code: 'KeyN', key: 'n', meta: true, control: false, alt: false, shift: true },
'darwin'
)
).toEqual({ type: 'openNewWorkspace', tab: 'create-from' })
).toEqual({ type: 'openNewWorkspace' })
expect(
resolveWindowShortcutAction(
{ code: 'KeyN', key: 'n', meta: false, control: true, alt: false, shift: true },
'linux'
)
).toEqual({ type: 'openNewWorkspace', tab: 'create-from' })
).toEqual({ type: 'openNewWorkspace' })
// Alt must still be rejected — the allowlist is alt-free for Cmd/Ctrl+N
// so future chords like Cmd+Alt+Shift+N remain available.
@@ -320,7 +319,7 @@ describe('resolveWindowShortcutAction', () => {
[{ code: 'KeyR', key: 'p', meta: true, alt: false, shift: false }, { type: 'openQuickOpen' }],
[
{ code: 'KeyL', key: 'n', meta: true, alt: false, shift: false },
{ type: 'openNewWorkspace', tab: 'quick' }
{ type: 'openNewWorkspace' }
],
[
{ code: 'KeyC', key: 'j', meta: true, alt: false, shift: false },
@@ -350,7 +349,7 @@ describe('resolveWindowShortcutAction', () => {
],
[
{ code: 'KeyN', key: 'Dead', meta: true, alt: false, shift: false },
{ type: 'openNewWorkspace', tab: 'quick' }
{ type: 'openNewWorkspace' }
],
[{ code: 'KeyP', meta: true, alt: false, shift: false }, { type: 'openQuickOpen' }]
]
+4 -5
View File
@@ -13,7 +13,7 @@ export type WindowShortcutAction =
| { type: 'toggleLeftSidebar' }
| { type: 'toggleRightSidebar' }
| { type: 'openQuickOpen' }
| { type: 'openNewWorkspace'; tab: 'quick' | 'create-from' }
| { type: 'openNewWorkspace' }
| { type: 'jumpToWorktreeIndex'; index: number }
| { type: 'worktreeHistoryNavigate'; direction: 'back' | 'forward' }
@@ -167,12 +167,11 @@ export function resolveWindowShortcutAction(
// main process so it reaches the renderer even when focus lives inside
// a contentEditable surface (markdown rich editor) or a browser guest
// webContents, both of which bypass the renderer's window-level keydown.
// Cmd/Ctrl+Shift+N opens the composer on the "Create from…" tab so users
// can start a workspace directly from an existing PR, issue, branch, or
// Linear ticket without going through the quick-create flow first.
// Shift is accepted for compatibility with the former Create-from shortcut;
// the unified composer now exposes source switching inside the name field.
if (matchesLetterShortcut(input, 'n', 'KeyN')) {
if (!input.alt) {
return { type: 'openNewWorkspace', tab: input.shift ? 'create-from' : 'quick' }
return { type: 'openNewWorkspace' }
}
}
+6 -3
View File
@@ -87,9 +87,12 @@ test.describe('Create Workspace', () => {
})
await orcaPage.waitForTimeout(100)
// 3. Type the workspace name into the Name input. This is what lets
// the composer pass its `workspaceName` guard inside submitQuick.
const nameInput = dialog.getByPlaceholder(/Workspace name/i)
// 3. Type the workspace name into the unified smart-name input.
// The composer's default mode is 'smart'; its placeholder advertises
// multiple input shapes ("Type a name, #1234, branch, GitHub or
// Linear URL"). Plain free-form text is treated as a workspace name
// by submitQuick, which is what we want here.
const nameInput = dialog.getByPlaceholder(/Type a name/i)
await expect(nameInput).toBeVisible()
await nameInput.fill(workspaceName)