mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 'Create From'{' '}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">“{row.name}”</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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }]
|
||||
]
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user