feat(source-control): send all notes to agent in a new terminal tab (#1568)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-08 13:52:08 -07:00 committed by GitHub
parent 2fafb9b351
commit 2050fa87a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 202 additions and 67 deletions

View File

@ -153,16 +153,13 @@ export default function TerminalSettingsScreen() {
<Text style={styles.heading}>Terminal</Text>
</View>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Text style={styles.groupHeading}>WHEN YOU LEAVE THE APP</Text>
<Text style={styles.groupDescription}>
While you&apos;re using a terminal on your phone, Orca shrinks it to fit your
screen. When you close the app or switch away, this controls whether it stays at
phone size (so interactive CLI tools don&apos;t reflow) or resizes back to your
desktop. You can always tap Restore on the terminal banner to resize it manually.
While you&apos;re using a terminal on your phone, Orca shrinks it to fit your screen. When
you close the app or switch away, this controls whether it stays at phone size (so
interactive CLI tools don&apos;t reflow) or resizes back to your desktop. You can always
tap Restore on the terminal banner to resize it manually.
</Text>
{hosts.length === 0 ? (

View File

@ -742,10 +742,7 @@ describe('mobile subscribe integration', () => {
rows: 20
})
expect(runtime.isMobileSubscriberActive('pty-1'), `iter ${i}: no subscribers`).toBe(false)
expect(
runtime.getTerminalFitOverride('pty-1'),
`iter ${i}: override held`
).not.toBeNull()
expect(runtime.getTerminalFitOverride('pty-1'), `iter ${i}: override held`).not.toBeNull()
// Desktop clicks Restore — held-override branch.
const ok = await runtime.reclaimTerminalForDesktop('pty-1')
@ -754,10 +751,7 @@ describe('mobile subscribe integration', () => {
cols: 150,
rows: 40
})
expect(
runtime.getTerminalFitOverride('pty-1'),
`iter ${i}: override cleared`
).toBeNull()
expect(runtime.getTerminalFitOverride('pty-1'), `iter ${i}: override cleared`).toBeNull()
}
})
@ -778,10 +772,7 @@ describe('mobile subscribe integration', () => {
cols: 150,
rows: 40
})
expect(
runtime.getTerminalFitOverride('pty-1'),
`iter ${i}: override cleared`
).toBeNull()
expect(runtime.getTerminalFitOverride('pty-1'), `iter ${i}: override cleared`).toBeNull()
}
})

View File

@ -2631,11 +2631,7 @@ export class OrcaRuntimeService {
// hidden tab on desktop, container went 0×0 → 1782×1195) reports
// different dims and is the right baseline to remember.
const activeOverride = this.terminalFitOverrides.get(ptyId)
if (
activeOverride &&
activeOverride.cols === cols &&
activeOverride.rows === rows
) {
if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) {
return
}
this.refreshRendererGeometry(ptyId, cols, rows)

View File

@ -445,7 +445,9 @@
border: none;
color: var(--muted-foreground);
cursor: pointer;
transition: background 100ms, color 100ms;
transition:
background 100ms,
color 100ms;
}
.window-controls-btn:hover {

View File

@ -23,6 +23,7 @@ import {
GitPullRequestArrow,
MessageSquare,
Pencil,
Send,
Trash,
TriangleAlert,
CircleCheck,
@ -79,6 +80,8 @@ import {
} from '@/components/ui/dialog'
import { BaseRefPicker } from '@/components/settings/BaseRefPicker'
import { formatDiffComment, formatDiffComments } from '@/lib/diff-comments-format'
import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import {
notifyEditorExternalFileChange,
requestEditorSaveQuiesce
@ -180,6 +183,9 @@ function SourceControlInner(): React.JSX.Element {
const commitInFlightRef = useRef<Record<string, boolean>>({})
const activeWorktree = useActiveWorktree()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeGroupId = useAppStore((s) =>
activeWorktreeId ? s.activeGroupIdByWorktree[activeWorktreeId] : undefined
)
const worktreeMap = useWorktreeMap()
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
@ -229,6 +235,10 @@ function SourceControlInner(): React.JSX.Element {
}
return map
}, [diffCommentsForActive])
const diffCommentsPrompt = useMemo(
() => formatDiffComments(diffCommentsForActive),
[diffCommentsForActive]
)
const [diffCommentsExpanded, setDiffCommentsExpanded] = useState(false)
const [diffCommentsCopied, setDiffCommentsCopied] = useState(false)
@ -236,15 +246,14 @@ function SourceControlInner(): React.JSX.Element {
if (diffCommentsForActive.length === 0) {
return
}
const text = formatDiffComments(diffCommentsForActive)
try {
await window.api.ui.writeClipboardText(text)
await window.api.ui.writeClipboardText(diffCommentsPrompt)
setDiffCommentsCopied(true)
} catch {
// Why: swallow — clipboard write can fail when the window isn't focused.
// No dedicated error surface is warranted for a best-effort copy action.
}
}, [diffCommentsForActive])
}, [diffCommentsForActive, diffCommentsPrompt])
// Why: auto-dismiss the "copied" indicator so the button returns to its
// default icon after a brief confirmation window.
@ -1268,6 +1277,35 @@ function SourceControlInner(): React.JSX.Element {
</span>
)}
</button>
<DropdownMenu>
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Send notes to a new agent"
>
<Send className="size-3.5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Send notes to a new agent
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end" className="min-w-[180px]">
<QuickLaunchAgentMenuItems
worktreeId={activeWorktreeId}
groupId={activeGroupId ?? activeWorktreeId}
onFocusTerminal={focusTerminalTabSurface}
prompt={diffCommentsPrompt}
launchSource="diff_notes_send"
/>
</DropdownMenuContent>
</DropdownMenu>
{diffCommentCount > 0 && (
<TooltipProvider delayDuration={400}>
<Tooltip>

View File

@ -309,9 +309,9 @@ export function MobilePane(): React.JSX.Element {
</div>
<p className="text-muted-foreground mb-3 text-xs">
While you&apos;re using a terminal on your phone, Orca shrinks it to fit your phone
screen. When you close the app or switch away, this controls whether it stays at
phone size (so interactive CLI tools don&apos;t reflow) or resizes back to your
desktop. You can always click Restore on the terminal banner to resize it manually.
screen. When you close the app or switch away, this controls whether it stays at phone
size (so interactive CLI tools don&apos;t reflow) or resizes back to your desktop. You can
always click Restore on the terminal banner to resize it manually.
</p>
<Select
value={autoRestoreValueFromMs(autoRestoreFitMs)}

View File

@ -1,13 +1,14 @@
import React, { useCallback } from 'react'
import { Settings as SettingsIcon } from 'lucide-react'
import { toast } from 'sonner'
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
import { useDetectedAgents } from '@/hooks/useDetectedAgents'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
import { waitForAgentReady } from '@/lib/agent-ready-wait'
import type { TuiAgent } from '../../../../shared/types'
import type { LaunchSource } from '../../../../shared/telemetry-events'
export type QuickLaunchAgentMenuItemsProps = {
worktreeId: string
@ -16,6 +17,14 @@ export type QuickLaunchAgentMenuItemsProps = {
* Reuses the TabBar's existing double-rAF handoff this component does
* not duplicate the focus logic. */
onFocusTerminal: (tabId: string) => void
/** Optional initial prompt forwarded to `launchAgentInNewTab`. When set,
* the picked agent boots with this prompt argv/flag agents auto-submit,
* followup-path agents land it as a draft for the user to confirm. */
prompt?: string
/** Telemetry surface for `agent_started.launch_source`. Defaults to
* `'tab_bar_quick_launch'` so the existing tab-bar `+` callsite is
* unchanged. */
launchSource?: LaunchSource
}
function getCatalogEntry(agent: TuiAgent): { id: TuiAgent; label: string } | null {
@ -40,7 +49,9 @@ function orderAgents(
function QuickLaunchAgentMenuItemsInner({
worktreeId,
groupId,
onFocusTerminal
onFocusTerminal,
prompt,
launchSource
}: QuickLaunchAgentMenuItemsProps): React.JSX.Element | null {
// Why: must be a reactive selector (not getConnectionId() which reads a
// snapshot via getState()). This ensures the component re-renders when the
@ -69,7 +80,13 @@ function QuickLaunchAgentMenuItemsInner({
(agent: TuiAgent) => {
const entry = getCatalogEntry(agent)
const label = entry?.label ?? agent
const result = launchAgentInNewTab({ agent, worktreeId, groupId })
const result = launchAgentInNewTab({
agent,
worktreeId,
groupId,
...(prompt !== undefined ? { prompt } : {}),
...(launchSource !== undefined ? { launchSource } : {})
})
if (!result) {
toast.error(`Could not build launch command for ${label}.`)
return
@ -79,7 +96,8 @@ function QuickLaunchAgentMenuItemsInner({
// Why: the watchdog guards against "queued startup command never ran" —
// e.g. shell failed to spawn. Suppress the toast if the tab has been
// closed or the worktree has been navigated away from before the
// deadline (see §States: Launch failure handling).
// deadline (see §States: Launch failure handling). Bracketed-paste
// failures have their own toast in launch-agent-in-new-tab.ts.
void waitForAgentReady(result.tabId, result.startupPlan.expectedProcess, {
timeoutMs: 5000
}).then((ready) => {
@ -99,14 +117,13 @@ function QuickLaunchAgentMenuItemsInner({
toast.message(`Couldn't launch ${label} — the terminal is still open.`)
})
},
[worktreeId, groupId, onFocusTerminal]
[worktreeId, groupId, onFocusTerminal, prompt, launchSource]
)
const agents = detectedIds ? orderAgents(defaultAgent, detectedIds) : []
return (
<>
<DropdownMenuSeparator />
{agents.length === 0 ? (
<DropdownMenuItem
disabled

View File

@ -30,6 +30,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
@ -557,6 +558,7 @@ function TabBarInner({
<DropdownMenuShortcut>{NEW_FILE_SHORTCUT}</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<QuickLaunchAgentMenuItems
worktreeId={worktreeId}
groupId={resolvedGroupId}

View File

@ -117,6 +117,9 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
}) {
return { type: 'DropdownMenuItem', props }
},
DropdownMenuSeparator: function DropdownMenuSeparator() {
return { type: 'DropdownMenuSeparator', props: {} }
},
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
return { type: 'DropdownMenuShortcut', props }
},

View File

@ -1,9 +1,13 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { buildAgentStartupPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import { track, tuiAgentToAgentKind } from '@/lib/telemetry'
import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../shared/types'
import type { LaunchSource } from '../../../shared/telemetry-events'
export type LaunchAgentInNewTabArgs = {
agent: TuiAgent
@ -11,6 +15,13 @@ export type LaunchAgentInNewTabArgs = {
/** The tab group the user clicked from. Keeps split-group launches in the
* pane the user initiated from instead of falling through to the active group. */
groupId?: string
/** Optional initial prompt. When non-empty, dispatched per the agent's
* `promptInjectionMode`: argv/flag agents auto-submit via the launch
* command; followup-path agents land the prompt as an unsent draft. */
prompt?: string
/** Telemetry surface that initiated this launch. Defaults to the tab-bar
* quick-launch entry point so existing callers stay unchanged. */
launchSource?: LaunchSource
}
export type LaunchAgentInNewTabResult = {
@ -19,34 +30,65 @@ export type LaunchAgentInNewTabResult = {
} | null
/**
* Create a new terminal tab and queue the agent's empty-prompt launch command.
* Create a new terminal tab and queue the agent's launch command, optionally
* with an initial prompt.
*
* Why: this is the single entry point for "launch agent X in a new tab" from
* the tab-bar quick-launch menu. It mirrors the `+` button's path
* (`createNewTerminalTab`) createTab, flip `activeTabType` to terminal, and
* persist the appended tab-bar order then queues the empty-prompt agent
* startup through the same `pendingStartupByTabId` channel the
* new-workspace ("cmd+N") flow uses. TerminalPane consumes the queued command
* on first mount and the local PTY provider writes it once the shell is ready
* (see `pty-connection.ts`: startup-command path), so the CLI boots in exactly
* the same way as a composer-initiated launch.
* the tab-bar quick-launch menu and the Source Control "send notes to agent"
* action. It mirrors the `+` button's path (`createNewTerminalTab`) createTab,
* flip `activeTabType` to terminal, and persist the appended tab-bar order
* then queues the agent startup through the same `pendingStartupByTabId`
* channel the new-workspace ("cmd+N") flow uses. TerminalPane consumes the
* queued command on first mount and the local PTY provider writes it once the
* shell is ready (see `pty-connection.ts`: startup-command path).
*
* Returns `null` when `buildAgentStartupPlan` cannot produce a plan (should
* not happen with `allowEmptyPromptLaunch: true` but guarded for safety).
* Submission mode by `promptInjectionMode`: argv/flag agents include the
* prompt directly in the launch command (auto-submit, atomic via the shell);
* followup-path agents have no argv prompt slot, so we launch empty-prompt
* and bracketed-paste the prompt as an unsent draft once the agent's input
* box is ready.
*
* Returns `null` when no startup plan can be built for example, a whitespace-
* only prompt on the trim-empty branch of `buildAgentStartupPlan`. Callers
* surface that as a launch failure (see `QuickLaunchButton.runLaunch`).
*/
export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult {
const { agent, worktreeId, groupId } = args
const { agent, worktreeId, groupId, prompt, launchSource } = args
const store = useAppStore.getState()
const cmdOverrides = store.settings?.agentCmdOverrides ?? {}
const trimmedPrompt = prompt?.trim() ?? ''
const hasPrompt = trimmedPrompt.length > 0
const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
// Why: argv/flag agents fold the prompt into the launch command and
// auto-submit — keeping behavior consistent with the composer/tab-bar `+`
// mental model, where the prompt is "the first turn the user sent".
// Followup-path agents have no argv prompt slot, so the only way to
// deliver a prompt is post-launch bracketed paste; we leave it as an
// unsent draft so the user confirms before sending (avoids the typed-`\r`
// race if readiness detection misses).
let startupPlan: AgentStartupPlan | null = null
let pasteDraftAfterLaunch: string | null = null
if (hasPrompt && isFollowupPath) {
startupPlan = buildAgentStartupPlan({
agent,
prompt: '',
cmdOverrides,
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
})
pasteDraftAfterLaunch = trimmedPrompt
} else {
startupPlan = buildAgentStartupPlan({
agent,
prompt: hasPrompt ? trimmedPrompt : '',
cmdOverrides,
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: !hasPrompt
})
}
// Why: empty-prompt launch is the whole point of quick-launch — the user
// just wants to get into the agent's input box with no prefilled prompt.
const startupPlan = buildAgentStartupPlan({
agent,
prompt: '',
cmdOverrides: store.settings?.agentCmdOverrides ?? {},
platform: CLIENT_PLATFORM,
allowEmptyPromptLaunch: true
})
if (!startupPlan) {
return null
}
@ -60,17 +102,60 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
// The telemetry payload is threaded through the queue → pty-connection →
// pty-transport → pty:spawn IPC → main, where main fires `agent_started`
// only after the spawn succeeds. `request_kind: 'new'` because
// quick-launch always opens a fresh empty-prompt session.
// quick-launch always opens a fresh session.
const tab = store.createTab(worktreeId, groupId)
store.queueTabStartupCommand(tab.id, {
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
telemetry: {
agent_kind: tuiAgentToAgentKind(agent),
launch_source: 'tab_bar_quick_launch',
launch_source: launchSource ?? 'tab_bar_quick_launch',
request_kind: 'new'
}
})
// Why: schedule the bracketed-paste-after-ready follow-up immediately after
// the startup command is queued. Fire-and-forget so callers keep their
// synchronous `{ tabId, startupPlan }` signature. The helper short-circuits
// for agents with a `draftPromptFlag`, so calling it on the followup path
// is safe even when the draft was already injected via the native flag.
if (pasteDraftAfterLaunch !== null) {
// Why: surface silent paste failures — without onTimeout, a stalled agent
// readiness wait drops the user's notes with no feedback. Suppress when
// the user closed the tab or switched worktrees so the toast/telemetry
// don't fire for user-initiated cancellation (mirrors the 5s launch
// watchdog in QuickLaunchButton).
const tabId = tab.id
void pasteDraftWhenAgentReady({
tabId,
content: pasteDraftAfterLaunch,
agent,
onTimeout: () => {
const state = useAppStore.getState()
const tabsForWorktree = state.tabsByWorktree[worktreeId] ?? []
const tab = tabsForWorktree.find((t) => t.id === tabId)
// Why: if the PTY never spawned, QuickLaunch's 5s watchdog already
// surfaced the launch failure. Don't double-toast for the same root
// cause. Looking up directly in `worktreeId` (not scanning every
// worktree) also preserves "still in this worktree" intent.
if (!tab) {
return // tab closed by user
}
if (tab.ptyId === null) {
return // launch failed; QuickLaunch handled the user-facing toast
}
if (state.activeWorktreeId !== worktreeId) {
return
}
toast.message("Your notes weren't sent — paste them once the agent is ready.")
track('agent_error', {
error_class: 'paste_readiness_timeout',
agent_kind: tuiAgentToAgentKind(agent)
})
}
})
}
// Why: match the `+` button's `createNewTerminalTab` sequence — without
// `setActiveTabType('terminal')`, a worktree currently showing an editor
// file keeps rendering the editor and the new terminal tab stays invisible.

View File

@ -54,20 +54,23 @@ export const AGENT_KIND_VALUES = [
export const agentKindSchema = z.enum(AGENT_KIND_VALUES)
export type AgentKind = z.infer<typeof agentKindSchema>
// Trimmed to the two values Orca's PTY-typed-command launch architecture can
// actually emit:
// Trimmed to a small set of values Orca's PTY-typed-command launch architecture
// can emit:
// - `binary_not_found` — `provider.spawn` ENOENT (the *shell* binary is
// missing). The agent CLI being missing is invisible: Orca spawns a
// healthy shell and types the command, and bash/zsh's "command not found"
// surfaces only as terminal output.
// - `unknown` — every other thrown error (paste-readiness timeout, env-build
// failures, unclassifiable shell-spawn errors).
// - `paste_readiness_timeout` — bracketed-paste readiness wait timed out.
// The agent process spawned but its TUI input box didn't reach a ready
// state before the watchdog deadline, so the queued draft was dropped.
// - `unknown` — every other thrown error (env-build failures,
// unclassifiable shell-spawn errors).
// Provider-side errors (`auth_expired`, `rate_limited`, `network_timeout`,
// `provider_*`) happen inside the agent CLI subprocess and are not observable
// to Orca — see telemetry-plan.md §Decision: Defer per-incident error fields.
// Adding a new value is additive-safe; do it when the call site lands, not in
// anticipation.
export const errorClassSchema = z.enum(['binary_not_found', 'unknown'])
export const errorClassSchema = z.enum(['binary_not_found', 'paste_readiness_timeout', 'unknown'])
export type ErrorClass = z.infer<typeof errorClassSchema>
export const repoMethodSchema = z.enum(['folder_picker', 'clone_url', 'drag_drop'])
@ -117,6 +120,7 @@ export const launchSourceSchema = z.enum([
'new_workspace_composer',
'workspace_jump_palette',
'shortcut',
'diff_notes_send',
'unknown'
])
export type LaunchSource = z.infer<typeof launchSourceSchema>