Merge branch 'main' into brennanb2025/pr3-sidebar-status

Conflicts resolved in WorktreeCard.tsx and worktree-status.ts:
- worktree-status.ts auto-merges main's split-pane-aware getWorktreeStatus
  (#1064) with this branch's 'done' status addition.
- WorktreeCard.tsx keeps both narrowed selectors (the agent-status selector
  from this branch and the runtimePaneTitlesForWorktree selector from main).
- The derived `status` memo now delegates to getWorktreeStatus when
  AGENT_DASHBOARD_ENABLED is false, so the dark-launched sidebar inherits
  main's split-pane heuristic for free. When the flag is on, the per-tab
  explicit-status path also consults per-pane titles for tabs without a
  fresh explicit entry — matching main's spinner semantics.
This commit is contained in:
brennanb2025
2026-04-24 18:22:34 -07:00
19 changed files with 502 additions and 222 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.3.17-rc.4",
"version": "1.3.17",
"description": "An Electron application with React and TypeScript",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+5 -1
View File
@@ -440,8 +440,12 @@ export function registerFilesystemHandlers(store: Store): void {
): Promise<string[]> => {
if (args.connectionId) {
const provider = getSshFilesystemProvider(args.connectionId)
// Why: when the SSH connection is not yet established (cold start) or
// temporarily disconnected, return [] so quick-open shows "No matching
// files" instead of an error banner. The file list will repopulate when
// the user re-opens quick-open after the connection is restored.
if (!provider) {
throw new Error(`No filesystem provider for connection "${args.connectionId}"`)
return []
}
return provider.listFiles(args.rootPath)
}
+7 -4
View File
@@ -99,11 +99,14 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
gitWorktrees = [createFolderWorktree(repo)]
} else if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
// Why: when SSH is disconnected the provider is null. Throwing here
// makes the renderer's fetchWorktrees catch block preserve its cached
// worktree list instead of replacing it with an empty array.
// Why: when SSH is disconnected the provider is null. Return [] so the
// renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`)
// preserves its cached worktree list. This avoids a console error on every
// fetchAllWorktrees cycle while the connection is being (re-)established —
// worktrees will be properly populated when the SSH `connected` event fires
// and triggers a re-fetch.
if (!provider) {
throw new Error(`SSH connection "${repo.connectionId}" is not active`)
return []
}
gitWorktrees = await provider.listWorktrees(repo.path)
} else {
+82
View File
@@ -0,0 +1,82 @@
/**
* Plain readdir-based file listing fallback.
*
* Why: when neither ripgrep nor git is available (e.g. a non-git folder on a
* remote machine without rg), we still need to list files for quick-open.
* This walks the directory tree using Node's fs.readdir, respecting the same
* blocklist and timeout constraints as the git-based fallback.
*/
import { readdir } from 'fs/promises'
import { join, relative } from 'path'
const MAX_FILES = 10_000
const TIMEOUT_MS = 10_000
// Why: mirrors the HIDDEN_DIR_BLOCKLIST in fs-handler-git-fallback.ts —
// tool-generated dirs that clutter quick-open. User-authored dotdirs like
// .github/ and .devcontainer/ are intentionally kept discoverable.
const HIDDEN_DIR_BLOCKLIST = new Set([
'.git',
'.next',
'.nuxt',
'.cache',
'.stably',
'.vscode',
'.idea',
'.yarn',
'.pnpm-store',
'.terraform',
'.docker',
'.husky'
])
function shouldDescend(name: string): boolean {
if (name === 'node_modules' || HIDDEN_DIR_BLOCKLIST.has(name)) {
return false
}
return true
}
/**
* Recursively list files under `rootPath` using fs.readdir.
* Returns relative POSIX paths, capped at MAX_FILES with a timeout.
*/
export async function listFilesWithReaddir(rootPath: string): Promise<string[]> {
const files: string[] = []
const deadline = Date.now() + TIMEOUT_MS
async function walk(dir: string): Promise<void> {
if (files.length >= MAX_FILES || Date.now() > deadline) {
return
}
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
// Permission denied, symlink loop, etc. — skip silently.
return
}
for (const entry of entries) {
if (files.length >= MAX_FILES || Date.now() > deadline) {
return
}
const name = entry.name
if (entry.isDirectory()) {
if (shouldDescend(name)) {
await walk(join(dir, name))
}
} else if (entry.isFile()) {
// Why: path.relative() returns backslashes on Windows. The quick-open
// UI assumes POSIX separators for display and fuzzy matching.
const relPath = relative(rootPath, join(dir, name)).replace(/\\/g, '/')
files.push(relPath)
}
}
}
await walk(rootPath)
return files
}
+17 -2
View File
@@ -11,6 +11,7 @@ import {
realpath
} from 'fs/promises'
import { extname } from 'path'
import { execFile } from 'child_process'
import type { RelayDispatcher } from './dispatcher'
import type { RelayContext } from './context'
import { expandTilde } from './context'
@@ -24,6 +25,7 @@ import {
checkRgAvailable
} from './fs-handler-utils'
import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback'
import { listFilesWithReaddir } from './fs-handler-readdir-fallback'
type WatchState = {
rootPath: string
@@ -228,10 +230,23 @@ export class FsHandler {
const rootPath = expandTilde(params.rootPath as string)
await this.context.validatePathResolved(rootPath)
const rgAvailable = await checkRgAvailable()
if (!rgAvailable) {
if (rgAvailable) {
return listFilesWithRg(rootPath)
}
// Why: git ls-files only works inside git repos. Use rev-parse to detect
// git ancestry — unlike checking for a local .git entry, this works from
// subdirectories of a checkout (e.g. /repo/packages/app added as a folder).
// Without this, a git subdirectory would fall through to readdir and
// surface .gitignore'd build artifacts.
const isGitRepo = await new Promise<boolean>((resolve) => {
execFile('git', ['rev-parse', '--is-inside-work-tree'], { cwd: rootPath }, (err) =>
resolve(!err)
)
})
if (isGitRepo) {
return listFilesWithGit(rootPath)
}
return listFilesWithRg(rootPath)
return listFilesWithReaddir(rootPath)
}
private async watch(params: Record<string, unknown>) {
+143 -139
View File
@@ -2,7 +2,7 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
import { ChevronLeft, ChevronRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react'
import { ArrowLeft, ArrowRight, Minimize2, PanelLeft, PanelRight } from 'lucide-react'
import { FOCUS_TERMINAL_PANE_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { syncZoomCSSVar } from '@/lib/ui-zoom'
import { toast } from 'sonner'
@@ -645,149 +645,153 @@ function App(): React.JSX.Element {
// the sidebar-width left header (workspace view) can share the same
// controls without duplicating the agent badge popover.
const titlebarLeftControls = (
<div ref={titlebarLeftControlsRef} className="flex h-full shrink-0 items-center">
<div className={isMac && !isFullScreen ? 'titlebar-traffic-light-pad' : 'pl-2'} />
{showSidebar && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
onClick={actions.toggleSidebar}
aria-label="Toggle sidebar"
>
<PanelLeft size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{`Toggle sidebar (${isMac ? '⌘B' : 'Ctrl+B'})`}
</TooltipContent>
</Tooltip>
)}
{settings?.showTitlebarAgentActivity !== false ? (
<Popover>
<PopoverTrigger asChild>
<button
className={`titlebar-agent-badge${activeAgentCount === 0 ? ' titlebar-agent-badge-idle' : ''}`}
aria-label={`${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`}
>
<span
className={`titlebar-agent-badge-dot${activeAgentCount === 0 ? ' titlebar-agent-badge-dot-idle' : ''}`}
aria-hidden
/>
<span className="titlebar-agent-badge-count">{activeAgentCount}</span>
</button>
</PopoverTrigger>
<PopoverContent side="bottom" sideOffset={6} className="titlebar-agent-hovercard">
<div
className={`titlebar-agent-hovercard-header${activeAgentCount > 0 ? ' titlebar-agent-hovercard-header-with-list' : ''}`}
>
{activeAgentCount === 0
? 'No agents active'
: `${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`}
</div>
{activeAgentCount > 0 && (
<div className="titlebar-agent-hovercard-list">
{Object.entries(workingAgentsPerWorktree).map(([worktreeId, { agents }]) => {
const wt = findWorktreeById(worktreesByRepo, worktreeId)
// Why: when a transient git error causes worktreesByRepo to
// lose a worktree, the raw worktreeId (uuid::path) is not
// useful. Extract a cross-platform path basename as a
// readable fallback.
const sepIdx = worktreeId.indexOf('::')
const pathPart = sepIdx !== -1 ? worktreeId.slice(sepIdx + 2) : worktreeId
const fallbackName = pathPart.split(/[\\/]/).pop() || pathPart
return (
<div key={worktreeId}>
<button
className="titlebar-agent-hovercard-worktree"
onClick={() => {
// Why: if the worktree is missing from worktreesByRepo
// (transient git error cleared the list), refresh the
// repo's worktrees before navigating so the activation
// lookup succeeds instead of silently failing.
if (!wt) {
const repoId = getRepoIdFromWorktreeId(worktreeId)
void useAppStore
.getState()
.fetchWorktrees(repoId)
.then(() => {
activateAndRevealWorktree(worktreeId)
})
return
}
activateAndRevealWorktree(worktreeId)
}}
>
<span className="titlebar-agent-hovercard-name">
{wt?.displayName ?? fallbackName}
</span>
</button>
{agents.map((agent) => (
<button
key={`${agent.tabId}:${agent.paneId ?? 'none'}:${agent.label}`}
className="titlebar-agent-hovercard-agent"
onClick={() => {
activateAndRevealWorktree(worktreeId)
useAppStore.getState().setActiveTab(agent.tabId)
if (agent.paneId !== null) {
// Why: a split-terminal tab can host multiple
// agents. After selecting the tab, wait one
// frame so the active TerminalPane can mount
// and then focus the specific pane the user
// clicked instead of leaving whichever pane
// was previously active highlighted.
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent(FOCUS_TERMINAL_PANE_EVENT, {
detail: { tabId: agent.tabId, paneId: agent.paneId }
})
)
})
}
}}
>
<span className="titlebar-agent-hovercard-agent-label">
{agent.label}
</span>
<span className="titlebar-agent-hovercard-agent-dot" />
</button>
))}
</div>
)
})}
</div>
)}
<button
className="titlebar-agent-hovercard-hide"
onClick={() => {
void actions.updateSettings({ showTitlebarAgentActivity: false })
toast('Agent activity badge hidden', {
description: 'You can turn it back on in Settings → Appearance.',
duration: Infinity,
dismissible: true
})
}}
>
Hide from titlebar
</button>
</PopoverContent>
</Popover>
) : null}
{/* Why: Back/Forward navigate worktree-activation history. Only
meaningful while viewing a worktree (terminal view); hidden in
Settings/Tasks/Landing to keep the titlebar compact and the
semantics unambiguous. */}
{activeView === 'terminal' && (
<>
<div className="flex h-full w-full shrink-0 items-center">
<div ref={titlebarLeftControlsRef} className="flex h-full items-center">
<div className={isMac && !isFullScreen ? 'titlebar-traffic-light-pad' : 'pl-2'} />
{showSidebar && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
onClick={actions.toggleSidebar}
aria-label="Toggle sidebar"
>
<PanelLeft size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{`Toggle sidebar (${isMac ? '⌘B' : 'Ctrl+B'})`}
</TooltipContent>
</Tooltip>
)}
{settings?.showTitlebarAgentActivity !== false ? (
<Popover>
<PopoverTrigger asChild>
<button
className={`titlebar-agent-badge${activeAgentCount === 0 ? ' titlebar-agent-badge-idle' : ''}`}
aria-label={`${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`}
>
<span
className={`titlebar-agent-badge-dot${activeAgentCount === 0 ? ' titlebar-agent-badge-dot-idle' : ''}`}
aria-hidden
/>
<span className="titlebar-agent-badge-count">{activeAgentCount}</span>
</button>
</PopoverTrigger>
<PopoverContent side="bottom" sideOffset={6} className="titlebar-agent-hovercard">
<div
className={`titlebar-agent-hovercard-header${activeAgentCount > 0 ? ' titlebar-agent-hovercard-header-with-list' : ''}`}
>
{activeAgentCount === 0
? 'No agents active'
: `${activeAgentCount} ${activeAgentCount === 1 ? 'agent' : 'agents'} active`}
</div>
{activeAgentCount > 0 && (
<div className="titlebar-agent-hovercard-list">
{Object.entries(workingAgentsPerWorktree).map(([worktreeId, { agents }]) => {
const wt = findWorktreeById(worktreesByRepo, worktreeId)
// Why: when a transient git error causes worktreesByRepo to
// lose a worktree, the raw worktreeId (uuid::path) is not
// useful. Extract a cross-platform path basename as a
// readable fallback.
const sepIdx = worktreeId.indexOf('::')
const pathPart = sepIdx !== -1 ? worktreeId.slice(sepIdx + 2) : worktreeId
const fallbackName = pathPart.split(/[\\/]/).pop() || pathPart
return (
<div key={worktreeId}>
<button
className="titlebar-agent-hovercard-worktree"
onClick={() => {
// Why: if the worktree is missing from worktreesByRepo
// (transient git error cleared the list), refresh the
// repo's worktrees before navigating so the activation
// lookup succeeds instead of silently failing.
if (!wt) {
const repoId = getRepoIdFromWorktreeId(worktreeId)
void useAppStore
.getState()
.fetchWorktrees(repoId)
.then(() => {
activateAndRevealWorktree(worktreeId)
})
return
}
activateAndRevealWorktree(worktreeId)
}}
>
<span className="titlebar-agent-hovercard-name">
{wt?.displayName ?? fallbackName}
</span>
</button>
{agents.map((agent) => (
<button
key={`${agent.tabId}:${agent.paneId ?? 'none'}:${agent.label}`}
className="titlebar-agent-hovercard-agent"
onClick={() => {
activateAndRevealWorktree(worktreeId)
useAppStore.getState().setActiveTab(agent.tabId)
if (agent.paneId !== null) {
// Why: a split-terminal tab can host multiple
// agents. After selecting the tab, wait one
// frame so the active TerminalPane can mount
// and then focus the specific pane the user
// clicked instead of leaving whichever pane
// was previously active highlighted.
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent(FOCUS_TERMINAL_PANE_EVENT, {
detail: { tabId: agent.tabId, paneId: agent.paneId }
})
)
})
}
}}
>
<span className="titlebar-agent-hovercard-agent-label">
{agent.label}
</span>
<span className="titlebar-agent-hovercard-agent-dot" />
</button>
))}
</div>
)
})}
</div>
)}
<button
className="titlebar-agent-hovercard-hide"
onClick={() => {
void actions.updateSettings({ showTitlebarAgentActivity: false })
toast('Agent activity badge hidden', {
description: 'You can turn it back on in Settings → Appearance.',
duration: Infinity,
dismissible: true
})
}}
>
Hide from titlebar
</button>
</PopoverContent>
</Popover>
) : null}
</div>
{/* Why: Back/Forward navigate worktree-activation history. Only
meaningful while viewing a worktree (terminal view); hidden in
Settings/Tasks/Landing to keep the titlebar compact and the
semantics unambiguous. The group sits outside the measured inner
container so the sidebar-collapse spacer stays sized to the left
controls only. */}
{activeView === 'terminal' && (
<div className="ml-auto mr-3 flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle sidebar-toggle-compact"
onClick={() => useAppStore.getState().goBackWorktree()}
disabled={!canGoBackWorktree}
aria-label="Go back"
>
<ChevronLeft size={16} />
<ArrowLeft size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
@@ -797,19 +801,19 @@ function App(): React.JSX.Element {
<Tooltip>
<TooltipTrigger asChild>
<button
className="sidebar-toggle"
className="sidebar-toggle sidebar-toggle-compact"
onClick={() => useAppStore.getState().goForwardWorktree()}
disabled={!canGoForwardWorktree}
aria-label="Go forward"
>
<ChevronRight size={16} />
<ArrowRight size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{`Go forward (${isMac ? '⌘⌥→' : 'Ctrl+Alt+→'})`}
</TooltipContent>
</Tooltip>
</>
</div>
)}
</div>
)
+7
View File
@@ -369,6 +369,13 @@
color: var(--foreground);
}
/* Why: tighter padding for the back/forward pair so the arrows sit
close together as a single navigation group, while the standard
.sidebar-toggle spacing stays unchanged for other titlebar icons. */
.sidebar-toggle-compact {
padding: 4px 5px;
}
.sidebar-toggle:disabled {
cursor: default;
opacity: 0.45;
@@ -19,7 +19,6 @@ import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import type { TuiAgent } from '../../../shared/types'
import { isGitRepoKind } from '../../../shared/repo-kind'
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
@@ -190,11 +189,9 @@ export default function NewWorkspaceComposerCard({
createError
}: NewWorkspaceComposerCardProps): React.JSX.Element {
const { isFileDragOver, dragHandlers } = useComposerFileDragOver()
const addRepo = useAppStore((s) => s.addRepo)
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const openModal = useAppStore((s) => s.openModal)
const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null)
const updateSettings = useAppStore((s) => s.updateSettings)
const [isAddingRepo, setIsAddingRepo] = React.useState(false)
const handleSetDefaultAgent = React.useCallback(
(next: TuiAgent | 'blank' | null) => {
@@ -218,25 +215,9 @@ export default function NewWorkspaceComposerCard({
[detectedAgentIds]
)
const handleAddRepo = React.useCallback(async (): Promise<void> => {
if (isAddingRepo) {
return
}
setIsAddingRepo(true)
try {
const repo = await addRepo()
if (!repo) {
return
}
if (isGitRepoKind(repo)) {
await fetchWorktrees(repo.id)
}
onRepoChange(repo.id)
focusNameInput()
} finally {
setIsAddingRepo(false)
}
}, [addRepo, fetchWorktrees, focusNameInput, isAddingRepo, onRepoChange])
const handleAddRepo = React.useCallback((): void => {
openModal('add-repo')
}, [openModal])
return (
<div
@@ -265,12 +246,9 @@ export default function NewWorkspaceComposerCard({
type="button"
variant="ghost"
size="icon-xs"
disabled={isAddingRepo}
onClick={() => void handleAddRepo()}
onClick={handleAddRepo}
className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground"
aria-label={
isAddingRepo ? 'Adding folder or repository' : 'Add folder or repository'
}
aria-label="Add folder or repository"
>
<FolderPlus className="size-3" />
</Button>
+12 -2
View File
@@ -92,9 +92,19 @@ export default function QuickOpen(): React.JSX.Element | null {
() => getConnectionId(activeWorktreeId ?? null) ?? undefined,
[activeWorktreeId]
)
// Why: when quick-open opens before the SSH connection is established,
// fs:listFiles returns [] (no provider yet). Watching the active target's
// connection status lets the file-load effect re-fire automatically once
// that specific connection comes up, without being affected by unrelated
// SSH targets reconnecting.
const activeTargetStatus = useAppStore((s) =>
connectionId ? s.sshConnectionStates.get(connectionId)?.status : undefined
)
const filesRequestKey = useMemo(
() => `${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludePathsKey}`,
[connectionId, excludePathsKey, worktreePath]
() =>
`${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludePathsKey}\n${activeTargetStatus ?? ''}`,
[connectionId, excludePathsKey, worktreePath, activeTargetStatus]
)
// Why: reset input only on open. Keeping this out of the file-load effect
@@ -1,7 +1,7 @@
/* oxlint-disable max-lines */
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Globe, Plus } from 'lucide-react'
import { Globe, Plus, WifiOff } from 'lucide-react'
import { useAppStore } from '@/store'
import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors'
import {
@@ -138,6 +138,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const allWorktrees = useAllWorktrees()
const repos = useAppStore((s) => s.repos)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
// Why: getWorktreeStatus needs per-pane titles so split-pane tabs with a
// working agent in a non-focused pane still surface as 'working' in the
// jump palette. Without this, clicking between panes would desync the
// palette's spinner from the sidebar's spinner.
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const prCache = useAppStore((s) => s.prCache)
const issueCache = useAppStore((s) => s.issueCache)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
@@ -145,6 +150,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
@@ -701,10 +707,16 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const branch = branchName(worktree.branch)
const status = getWorktreeStatus(
tabsByWorktree[worktree.id] ?? [],
browserTabsByWorktree[worktree.id] ?? []
browserTabsByWorktree[worktree.id] ?? [],
runtimePaneTitlesByTabId
)
const statusLabel = getWorktreeStatusLabel(status)
const isCurrentWorktree = activeWorktreeId === worktree.id
const sshConnectionId = repo?.connectionId ?? null
const sshStatus = sshConnectionId
? (sshConnectionStates.get(sshConnectionId)?.status ?? 'disconnected')
: null
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
return (
<CommandItem
@@ -725,6 +737,21 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{sshConnectionId && (
<span
aria-label={isSshDisconnected ? 'SSH disconnected' : 'SSH remote'}
className="shrink-0 inline-flex items-center"
>
{isSshDisconnected ? (
<WifiOff className="size-3.5 text-red-400" aria-hidden="true" />
) : (
<Globe
className="size-3.5 text-muted-foreground"
aria-hidden="true"
/>
)}
</span>
)}
<span className="truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
{entry.match.displayNameRange ? (
<HighlightedText
@@ -316,6 +316,11 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
setRemoteError(null)
}}
onAdd={handleAddRemoteRepo}
onOpenSshSettings={() => {
closeModal()
openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' })
openSettingsPage()
}}
/>
) : step === 'clone' ? (
<CloneStep
@@ -7,7 +7,7 @@
*/
import React, { useCallback, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Folder, FolderOpen } from 'lucide-react'
import { Folder, FolderOpen, Settings } from 'lucide-react'
import { useAppStore } from '@/store'
import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
@@ -148,6 +148,7 @@ type RemoteStepProps = {
onSelectTarget: (id: string) => void
onRemotePathChange: (value: string) => void
onAdd: () => void
onOpenSshSettings: () => void
}
export function RemoteStep({
@@ -158,7 +159,8 @@ export function RemoteStep({
isAddingRemote,
onSelectTarget,
onRemotePathChange,
onAdd
onAdd,
onOpenSshSettings
}: RemoteStepProps): React.JSX.Element {
const [browsing, setBrowsing] = useState(false)
@@ -197,9 +199,18 @@ export function RemoteStep({
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">SSH target</label>
{sshTargets.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">
No SSH targets configured. Add one in Settings first.
</p>
<div className="space-y-1.5 py-1">
<p className="text-xs text-muted-foreground">No SSH targets configured.</p>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={onOpenSshSettings}
>
<Settings className="size-3.5" />
Add in Settings
</Button>
</div>
) : (
<div className="space-y-1.5">
{sshTargets.map((target) => {
@@ -252,7 +263,7 @@ export function RemoteStep({
}}
placeholder="/home/user/project"
className="h-8 text-xs flex-1"
disabled={isAddingRemote}
disabled={isAddingRemote || !selectedTargetId}
/>
<Button
variant="outline"
@@ -12,7 +12,7 @@ import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import AgentStatusHover from './AgentStatusHover'
import { cn } from '@/lib/utils'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { type WorktreeStatus } from '@/lib/worktree-status'
import { getWorktreeStatus, type WorktreeStatus } from '@/lib/worktree-status'
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
import {
AGENT_STATUS_STALE_AFTER_MS,
@@ -162,6 +162,24 @@ const WorktreeCard = React.memo(function WorktreeCard({
})
)
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
// Why: split-pane tabs expose per-pane titles that the aggregate
// `tab.title` does not preserve (onActivePaneChange overwrites it with the
// focused pane's title). getWorktreeStatus needs those pane titles to keep
// the sidebar spinner reflecting *any* working pane, not just the focused
// one. Narrow the subscription to this worktree's tabs via useShallow so
// unrelated pane-title updates do not re-render every sidebar card.
const runtimePaneTitlesForWorktree = useAppStore(
useShallow((s) => {
const out: Record<string, Record<number, string>> = {}
for (const tab of s.tabsByWorktree[worktree.id] ?? []) {
const paneTitles = s.runtimePaneTitlesByTabId[tab.id]
if (paneTitles) {
out[tab.id] = paneTitles
}
}
return out
})
)
const branch = branchDisplayName(worktree.branch)
const isFolder = repo ? isFolderRepo(repo) : false
@@ -181,9 +199,16 @@ const WorktreeCard = React.memo(function WorktreeCard({
const isDeleting = deleteState?.isDeleting ?? false
// Derive status — explicit agent status (OSC 9999) takes precedence over
// heuristic title parsing, per the design doc's precedence rule.
// Derive status — when the dashboard flag is on, explicit agent status
// (OSC 9999) takes precedence over heuristic title parsing per the design
// doc's per-tab precedence rule. When it is off, delegate to the canonical
// `getWorktreeStatus` so the split-pane-aware heuristic path is the single
// source of truth for the sidebar's pre-dashboard behavior.
const status: WorktreeStatus = useMemo(() => {
if (!AGENT_DASHBOARD_ENABLED) {
return getWorktreeStatus(tabs, browserTabs, runtimePaneTitlesForWorktree)
}
const liveTabs = tabs.filter((tab) => tab.ptyId)
// Why: browser-only worktrees are still active from the user's point of
// view even when they have no PTY-backed terminal. The sidebar filter
@@ -228,9 +253,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
let hasDone = false
for (const tab of liveTabs) {
const fresh = freshByTabId.get(tab.id)
// Why: AGENT_DASHBOARD_ENABLED gates the explicit-status path so the
// sidebar falls back to pure heuristic detection when the feature is off.
if (AGENT_DASHBOARD_ENABLED && fresh && fresh.length > 0) {
if (fresh && fresh.length > 0) {
if (fresh.some((e) => e.state === 'blocked' || e.state === 'waiting')) {
hasPermission = true
} else if (fresh.some((e) => e.state === 'working')) {
@@ -238,11 +261,22 @@ const WorktreeCard = React.memo(function WorktreeCard({
} else if (fresh.some((e) => e.state === 'done')) {
hasDone = true
}
} else {
const heuristic = detectAgentStatusFromTitle(tab.title)
continue
}
// Why: fall back to the split-pane-aware heuristic for tabs without a
// fresh explicit entry. Consult per-pane titles first (matching
// `getWorktreeStatus`) so an idle focused pane doesn't mask a working
// background pane in the same tab.
const paneTitles = runtimePaneTitlesForWorktree[tab.id]
const titlesToCheck =
paneTitles && Object.keys(paneTitles).length > 0 ? Object.values(paneTitles) : [tab.title]
for (const title of titlesToCheck) {
const heuristic = detectAgentStatusFromTitle(title)
if (heuristic === 'permission') {
hasPermission = true
} else if (heuristic === 'working') {
break
}
if (heuristic === 'working') {
hasWorking = true
}
}
@@ -269,7 +303,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
// the memo body. It forces re-derivation when an agent status entry crosses
// the freshness threshold so the visual status updates without polling.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabs, browserTabs, worktreeAgentEntries, agentStatusEpoch])
}, [tabs, browserTabs, worktreeAgentEntries, runtimePaneTitlesForWorktree, agentStatusEpoch])
const showPR = cardProps.includes('pr')
const showCI = cardProps.includes('ci')
@@ -449,6 +483,23 @@ const WorktreeCard = React.memo(function WorktreeCard({
{/* Header row: Title and Checks */}
<div className="flex items-center justify-between min-w-0 gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{repo?.connectionId && (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 inline-flex items-center">
{isSshDisconnected ? (
<WifiOff className="size-3 text-red-400" />
) : (
<Globe className="size-3 text-muted-foreground" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{isSshDisconnected ? 'SSH disconnected' : 'Remote repository via SSH'}
</TooltipContent>
</Tooltip>
)}
<div className="text-[12px] font-semibold text-foreground truncate leading-tight">
{worktree.displayName}
</div>
@@ -513,23 +564,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
</div>
)}
{repo?.connectionId && (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 inline-flex items-center gap-0.5">
{isSshDisconnected ? (
<WifiOff className="size-3 text-red-400" />
) : (
<Globe className="size-3 text-muted-foreground" />
)}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{isSshDisconnected ? 'SSH disconnected' : 'Remote repository via SSH'}
</TooltipContent>
</Tooltip>
)}
{isFolder ? (
<Badge
variant="secondary"
+16 -3
View File
@@ -33,6 +33,7 @@ import {
renderIssueCommandTemplate,
type LinkedWorkItemSummary
} from '@/lib/new-workspace'
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
export type UseComposerStateOptions = {
initialRepoId?: string
@@ -169,6 +170,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const activeRepoId = useAppStore((s) => s.activeRepoId)
const settings = useAppStore((s) => s.settings)
const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
@@ -320,15 +322,24 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const isSetupCheckPending = Boolean(repoId) && checkedHooksRepoId !== repoId
const shouldWaitForSetupCheck = Boolean(selectedRepo) && isSetupCheckPending
// Why: when the user leaves the workspace name blank and provides no other
// seed source (prompt, linked issue/PR), pick a repo-scoped unique marine
// creature name so the workspace gets a distinct, readable identifier
// instead of colliding on a literal "workspace" default.
const fallbackCreatureName = useMemo(
() => getSuggestedCreatureName(repoId, worktreesByRepo, settings?.nestWorkspaces ?? true),
[repoId, worktreesByRepo, settings?.nestWorkspaces]
)
const workspaceSeedName = useMemo(
() =>
getWorkspaceSeedName({
explicitName: name,
prompt: agentPrompt,
linkedIssueNumber: parsedLinkedIssueNumber,
linkedPR
linkedPR,
fallbackName: fallbackCreatureName
}),
[agentPrompt, linkedPR, name, parsedLinkedIssueNumber]
[agentPrompt, fallbackCreatureName, linkedPR, name, parsedLinkedIssueNumber]
)
// Why: when the user links an issue/PR but has not typed any prompt text
// (attachments don't count), swap the generic "Linked work items:" context
@@ -989,7 +1000,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
explicitName: name,
prompt: '',
linkedIssueNumber: null,
linkedPR: null
linkedPR: null,
fallbackName: fallbackCreatureName
})
if (
!repoId ||
@@ -1057,6 +1069,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
applyWorktreeMeta,
clearNewWorkspaceDraft,
createWorktree,
fallbackCreatureName,
name,
note,
onCreated,
@@ -88,4 +88,28 @@ describe('getWorkspaceSeedName', () => {
})
).toBe('workspace')
})
it('uses the fallback name when no other seed source is available', () => {
expect(
getWorkspaceSeedName({
explicitName: '',
prompt: '',
linkedIssueNumber: null,
linkedPR: null,
fallbackName: 'Nautilus'
})
).toBe('Nautilus')
})
it('prefers an explicit name over the fallback name', () => {
expect(
getWorkspaceSeedName({
explicitName: 'my-workspace',
prompt: '',
linkedIssueNumber: null,
linkedPR: null,
fallbackName: 'Nautilus'
})
).toBe('my-workspace')
})
})
+9 -1
View File
@@ -153,8 +153,13 @@ export function getWorkspaceSeedName(args: {
prompt: string
linkedIssueNumber: number | null
linkedPR: number | null
/** Why: when none of the other seed sources produce a name, the composer
* supplies a repo-scoped unique marine-creature name so blank submissions
* still get a distinct, readable workspace rather than a collision-prone
* "workspace" literal that git would append numeric suffixes to. */
fallbackName?: string
}): string {
const { explicitName, prompt, linkedIssueNumber, linkedPR } = args
const { explicitName, prompt, linkedIssueNumber, linkedPR, fallbackName } = args
if (explicitName.trim()) {
return explicitName.trim()
}
@@ -175,6 +180,9 @@ export function getWorkspaceSeedName(args: {
return slug
}
}
if (fallbackName && fallbackName.trim()) {
return fallbackName.trim()
}
// Why: the prompt is optional in this flow. Fall back to a stable default
// branch/workspace seed so users can launch an empty draft without first
// writing a brief or naming the workspace manually.
+26 -2
View File
@@ -5,8 +5,8 @@ describe('worktree-status', () => {
it('prioritizes permission over other live activity states', () => {
const status = getWorktreeStatus(
[
{ ptyId: 'pty-working', title: 'claude [working]' },
{ ptyId: 'pty-permission', title: 'claude [permission]' }
{ id: 'tab-1', ptyId: 'pty-working', title: 'claude [working]' },
{ id: 'tab-2', ptyId: 'pty-permission', title: 'claude [permission]' }
],
[{ id: 'browser-1' }]
)
@@ -24,4 +24,28 @@ describe('worktree-status', () => {
it('returns inactive when neither tabs nor browser state are live', () => {
expect(getWorktreeStatus([], [])).toBe('inactive')
})
it('reports working when any pane in a split-pane tab is working even if tab.title is idle', () => {
// Regression: clicking between split panes rewrites tab.title to the
// focused pane's title (see onActivePaneChange in
// use-terminal-pane-lifecycle.ts). If the focused pane is idle while
// another pane is still working, the sidebar spinner must stay spinning.
const status = getWorktreeStatus(
[{ id: 'tab-1', ptyId: 'pty-1', title: 'claude [done]' }],
[],
{ 'tab-1': { 0: 'codex [working]', 1: 'claude [done]' } }
)
expect(status).toBe('working')
})
it('prefers pane-level permission status over tab.title', () => {
const status = getWorktreeStatus(
[{ id: 'tab-1', ptyId: 'pty-1', title: 'claude [done]' }],
[],
{ 'tab-1': { 0: 'claude [permission]', 1: 'claude [done]' } }
)
expect(status).toBe('permission')
})
})
+34 -4
View File
@@ -12,14 +12,27 @@ const STATUS_LABELS: Record<WorktreeStatus, string> = {
}
export function getWorktreeStatus(
tabs: Pick<TerminalTab, 'ptyId' | 'title'>[],
browserTabs: { id: string }[]
tabs: Pick<TerminalTab, 'id' | 'ptyId' | 'title'>[],
browserTabs: { id: string }[],
runtimePaneTitlesByTabId: Record<string, Record<number, string>> = {}
): WorktreeStatus {
const liveTabs = tabs.filter((tab) => tab.ptyId)
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'permission')) {
// Why: a split-pane tab can host multiple concurrent agents, but `tab.title`
// only reflects the most-recently-focused pane (see onActivePaneChange in
// use-terminal-pane-lifecycle.ts). Reading just `tab.title` causes the
// sidebar spinner to follow the focused pane instead of the aggregate tab
// state — e.g. clicking an idle Claude pane while Codex is still working in
// another pane would collapse the spinner to solid green. Consult per-pane
// titles first (same pattern as countWorkingAgentsForTab) and only fall back
// to `tab.title` for tabs that have no mounted panes yet.
const hasStatus = (status: 'permission' | 'working'): boolean =>
liveTabs.some((tab) => tabHasStatus(tab, runtimePaneTitlesByTabId, status))
if (hasStatus('permission')) {
return 'permission'
}
if (liveTabs.some((tab) => detectAgentStatusFromTitle(tab.title) === 'working')) {
if (hasStatus('working')) {
return 'working'
}
if (liveTabs.length > 0 || browserTabs.length > 0) {
@@ -32,6 +45,23 @@ export function getWorktreeStatus(
return 'inactive'
}
function tabHasStatus(
tab: Pick<TerminalTab, 'id' | 'title'>,
runtimePaneTitlesByTabId: Record<string, Record<number, string>>,
status: 'permission' | 'working'
): boolean {
const paneTitles = runtimePaneTitlesByTabId[tab.id]
if (paneTitles && Object.keys(paneTitles).length > 0) {
for (const title of Object.values(paneTitles)) {
if (detectAgentStatusFromTitle(title) === status) {
return true
}
}
return false
}
return detectAgentStatusFromTitle(tab.title) === status
}
export function getWorktreeStatusLabel(status: WorktreeStatus): string {
return STATUS_LABELS[status]
}
+1
View File
@@ -93,6 +93,7 @@ export type UISlice = {
| 'repo'
| 'agents'
| 'experimental'
| 'ssh'
repoId: string | null
sectionId?: string
} | null