mirror of
https://github.com/stablyai/orca.git
synced 2026-10-14 00:02:42 +00:00
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:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ export type UISlice = {
|
||||
| 'repo'
|
||||
| 'agents'
|
||||
| 'experimental'
|
||||
| 'ssh'
|
||||
repoId: string | null
|
||||
sectionId?: string
|
||||
} | null
|
||||
|
||||
Reference in New Issue
Block a user