Merge remote-tracking branch 'origin/main' into brennanb2025/sta-791-redesign-terminal-settings-page-for-bett

# Conflicts:
#	src/main/github/work-item-details.test.ts
This commit is contained in:
brennanb2025
2026-06-25 01:26:59 -07:00
31 changed files with 2416 additions and 378 deletions
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -229,7 +229,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.97",
"version": "1.4.98-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+6 -5
View File
@@ -283,12 +283,13 @@ describe('getWorkItemDetails', () => {
call[0].includes('repos/acme/widgets/issues/923/timeline?per_page=100&page=7')
)
).toBe(false)
if (!details?.timelineItems) {
throw new Error('Expected timeline items to be loaded')
const timelineItems = details?.timelineItems
expect(timelineItems).toHaveLength(300)
if (!timelineItems) {
throw new Error('Expected timeline items to be present')
}
expect(details.timelineItems).toHaveLength(300)
expect(details.timelineItems.at(0)).toMatchObject({ assignee: 'assignee-3-0' })
expect(details.timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' })
expect(timelineItems.at(0)).toMatchObject({ assignee: 'assignee-3-0' })
expect(timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' })
})
it('falls back to REST + GraphQL when the collapsed issue query fails', async () => {
@@ -23,6 +23,6 @@ export const PLATFORM_COPY: Record<
)
},
ctaLabel: 'Download APK',
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk'
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk'
}
}
@@ -1,10 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
buildAiVaultResumeCommandForWorktree,
buildAiVaultResumeStartupForWorktree
} from '@/lib/ai-vault-resume-command'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { useAppStore } from '@/store'
import {
useActiveRepo,
@@ -14,7 +9,7 @@ import {
useRepoById,
useRepos
} from '@/store/selectors'
import { agentLabel, filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters'
import { filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters'
import {
deriveAiVaultScopeSessionPaths,
deriveAiVaultWorkspaceScopePaths
@@ -25,6 +20,13 @@ import {
normalizeAiVaultScopeForContext
} from './ai-vault-scope-state'
import { buildAiVaultProjectContext } from './ai-vault-session-projects'
import {
resolveAiVaultSessionResumeActions,
resolveAiVaultSessionResumeState
} from './ai-vault-session-resume'
import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions'
import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree'
import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions'
import {
AI_VAULT_AGENTS,
type AiVaultAgent,
@@ -47,7 +49,9 @@ export default function AiVaultPanel(): React.JSX.Element {
const allWorktrees = useAllWorktrees()
const projectHostSetupProjection = useProjectHostSetupProjection()
const settings = useAppStore((s) => s.settings)
const agentCmdOverrides = settings?.agentCmdOverrides ?? {}
const agentCmdOverrides = settings?.agentCmdOverrides
const { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree } =
useAiVaultOriginalPaneActions()
const [query, setQuery] = useState('')
const [scope, setScope] = useState<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
const [sort, setSort] = useState<AiVaultSort>('updated')
@@ -101,6 +105,17 @@ export default function AiVaultPanel(): React.JSX.Element {
}).sessionProjectById,
[activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos, sessions]
)
const sessionWorktreeById = useAiVaultSessionWorktreeMap({
sessions,
worktrees: allWorktrees,
activeWorktreeId: activeWorktree?.id ?? null
})
const { buildResumeStartup, copyResumeCommand, handleResume } = useAiVaultSessionLaunchActions({
activeWorktree: activeWorktree ?? null,
allWorktrees,
repos,
agentCmdOverrides
})
const hasAllAgentsSelected = agents.length === AI_VAULT_AGENTS.length
const viewAdjustmentCount =
(hasAllAgentsSelected ? 0 : 1) +
@@ -169,41 +184,6 @@ export default function AiVaultPanel(): React.JSX.Element {
[filteredSessions, group, projectLabelByKey, sessionProjectById]
)
const buildResumeCommand = useCallback(
(session: AiVaultSession): string =>
buildAiVaultResumeCommandForWorktree({
state: useAppStore.getState(),
worktreeId: activeWorktree?.id ?? null,
session,
commandOverride: agentCmdOverrides[session.agent]
}),
[activeWorktree?.id, agentCmdOverrides, settings]
)
const buildResumeStartup = useCallback(
(session: AiVaultSession) =>
buildAiVaultResumeStartupForWorktree({
state: useAppStore.getState(),
worktreeId: activeWorktree?.id ?? null,
session,
commandOverride: agentCmdOverrides[session.agent]
}),
[activeWorktree?.id, agentCmdOverrides, settings]
)
const copyResumeCommand = useCallback(
async (session: AiVaultSession): Promise<void> => {
await window.api.ui.writeClipboardText(buildResumeCommand(session))
toast.success(
translate(
'auto.components.right.sidebar.AiVaultPanel.resumeCommandCopied',
'Resume command copied'
)
)
},
[buildResumeCommand]
)
const copyText = useCallback(async (text: string, label: string): Promise<void> => {
await window.api.ui.writeClipboardText(text)
toast.success(
@@ -213,40 +193,26 @@ export default function AiVaultPanel(): React.JSX.Element {
)
}, [])
const handleResume = useCallback(
(session: AiVaultSession): void => {
if (!activeWorktree) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming',
'Open a workspace before resuming a session.'
)
)
return
}
if (isRemoteWorktree) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.localWorkspacesOnly',
'Resume from history is only available in local workspaces.'
)
)
return
}
launchAiVaultSessionInNewTab({
agent: session.agent,
worktreeId: activeWorktree.id,
...buildResumeStartup(session)
})
toast.success(
translate(
'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued',
'{{value0}} session queued',
{ value0: agentLabel(session.agent) }
)
)
},
[activeWorktree, buildResumeStartup, isRemoteWorktree]
const getSessionResumeState = useCallback(
(session: AiVaultSession) =>
resolveAiVaultSessionResumeState({
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
activeWorktreeId: activeWorktree?.id ?? null,
worktrees: allWorktrees,
repos
}),
[activeWorktree?.id, allWorktrees, repos, sessionWorktreeById]
)
const getSessionResumeActions = useCallback(
(session: AiVaultSession) =>
resolveAiVaultSessionResumeActions({
worktreeInfo: sessionWorktreeById.get(session.id) ?? null,
activeWorktreeId: activeWorktree?.id ?? null,
worktrees: allWorktrees,
repos
}),
[activeWorktree?.id, allWorktrees, repos, sessionWorktreeById]
)
const setAgentEnabled = useCallback((agent: AiVaultAgent, enabled: boolean) => {
@@ -343,11 +309,16 @@ export default function AiVaultPanel(): React.JSX.Element {
sessionsCount={sessions.length}
filteredSessionsCount={filteredSessions.length}
error={error}
resumeDisabled={!activeWorktree || isRemoteWorktree}
buildResumeStartup={buildResumeStartup}
getSessionResumeState={getSessionResumeState}
getSessionResumeActions={getSessionResumeActions}
getOriginalPaneTarget={getOriginalPaneTarget}
getWorktreeInfo={(session) => sessionWorktreeById.get(session.id) ?? null}
onToggleGroup={toggleGroup}
onJumpToOriginalPane={jumpToOriginalPane}
onJumpToWorktree={jumpToWorktree}
onResume={handleResume}
onCopyResume={(session) => void copyResumeCommand(session)}
onCopyResume={(session, worktreeId) => void copyResumeCommand(session, worktreeId)}
onCopyId={(session) =>
void copyText(
session.sessionId,
@@ -1,33 +1,47 @@
import type React from 'react'
import { Copy, Play } from 'lucide-react'
import { FileJson, FolderGit2, MessageSquare, Play } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { AgentIcon } from '@/lib/agent-catalog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { agentLabel } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
import { recentSessionConversationTurns } from './ai-vault-session-display'
import { sessionDetailConversationTurns } from './ai-vault-session-display'
import {
aiVaultWorktreeCompactPath,
aiVaultWorktreeStatusLabel,
shouldShowAiVaultWorktreeStatusBadge,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
export function SessionInlineDetails({
id,
session,
onResume,
onCopyResume,
resumeDisabled
worktreeInfo,
resumeActions,
onResumeInWorktree,
onResumeInNewTab,
onOpenLog
}: {
id: string
session: AiVaultSession
onResume: () => void
onCopyResume: () => void
resumeDisabled: boolean
worktreeInfo: AiVaultSessionWorktreeInfo | null
resumeActions: {
worktree: { worktreeId: string | null; disabled: boolean }
newTab: { worktreeId: string | null; disabled: boolean }
}
onResumeInWorktree: () => void
onResumeInNewTab: () => void
onOpenLog: () => void
}): React.JSX.Element {
const updatedAt = session.updatedAt ?? session.modifiedAt
const recentTurns = recentSessionConversationTurns(session, 3)
const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId)
const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId)
const detailTurns = sessionDetailConversationTurns(session, 3)
const worktreeDisplay = worktreeInfo
return (
<div
id={id}
className="mt-2 rounded-md border border-sidebar-border bg-sidebar-accent/25 p-2.5"
className="mt-2 overflow-hidden rounded-lg border border-sidebar-border/80 bg-background/50 shadow-xs"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
@@ -36,83 +50,88 @@ export function SessionInlineDetails({
event.stopPropagation()
}}
>
<div className="space-y-2.5">
<SessionReceiptSection
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.originalAsk',
'Original ask'
)}
>
<div className="min-w-0 break-words text-[12px] font-medium leading-4 text-foreground [overflow-wrap:anywhere]">
{session.title}
</div>
</SessionReceiptSection>
<div className="space-y-3 p-3">
<SessionReceiptSection
icon={<MessageSquare className="size-3" />}
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns',
'Latest turns'
)}
>
{recentTurns.length > 0 ? (
<div className="grid gap-1.5">
{recentTurns.map((turn, index) => (
<div
{detailTurns.length > 0 ? (
<div className="space-y-1.5">
{detailTurns.map((turn, index) => (
<ConversationTurnCard
key={`${turn.timestamp ?? 'turn'}-${index}`}
className="grid min-w-0 grid-cols-[3.25rem_minmax(0,1fr)] gap-2 text-[11px] leading-4"
>
<span className="text-muted-foreground">{conversationRoleLabel(turn.role)}</span>
<span className="line-clamp-3 text-foreground/90">{turn.text}</span>
</div>
role={turn.role}
text={turn.text}
/>
))}
</div>
) : (
<div className="text-[11px] leading-4 text-muted-foreground">
{translate(
<SessionDetailEmptyState
message={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.noPreviewAvailable',
'No conversation preview available'
)}
</div>
/>
)}
</SessionReceiptSection>
<div className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-muted-foreground">
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={14} />
</span>
<span className="min-w-0 truncate">{agentLabel(session.agent)}</span>
<span className="shrink-0 text-muted-foreground/55">·</span>
<span className="shrink-0">
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.messageCount',
'{{value0}} msgs',
{ value0: session.messageCount }
{worktreeDisplay ? (
<SessionReceiptSection
icon={<FolderGit2 className="size-3" />}
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.worktree',
'Worktree'
)}
</span>
<span className="shrink-0 text-muted-foreground/55">·</span>
<SessionTime value={updatedAt} />
</div>
>
<WorktreeMetadataLines worktreeInfo={worktreeDisplay} />
</SessionReceiptSection>
) : null}
</div>
<div className="mt-2 grid gap-1">
<Button
type="button"
variant="secondary"
size="xs"
disabled={resumeDisabled}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onResume()
}}
className="h-7 justify-start px-2 text-[11px]"
>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInNewTab',
'Resume in New Tab'
)}
</Button>
<div className="flex flex-wrap items-center gap-1.5 border-t border-sidebar-border/80 bg-sidebar-accent/15 px-3 py-2">
{showResumeInWorktree ? (
<Button
type="button"
variant="default"
size="xs"
disabled={resumeActions.worktree.disabled}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onResumeInWorktree()
}}
className="h-7 shrink-0 px-2.5 text-[11px]"
>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
'Resume in Worktree'
)}
</Button>
) : null}
{showResumeInNewTab ? (
<Button
type="button"
variant={showResumeInWorktree ? 'secondary' : 'default'}
size="xs"
disabled={resumeActions.newTab.disabled}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onResumeInNewTab()
}}
className="h-7 shrink-0 px-2.5 text-[11px]"
>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)}
</Button>
) : null}
<Button
type="button"
variant="ghost"
@@ -120,15 +139,12 @@ export function SessionInlineDetails({
draggable={false}
onClick={(event) => {
event.stopPropagation()
onCopyResume()
onOpenLog()
}}
className="h-7 justify-start px-2 text-[11px]"
className="h-7 shrink-0 px-2.5 text-[11px] text-muted-foreground"
>
<Copy className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyResumeCommand',
'Copy Resume Command'
)}
<FileJson className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionDetails.viewLog', 'View Log')}
</Button>
</div>
</div>
@@ -136,18 +152,110 @@ export function SessionInlineDetails({
}
function SessionReceiptSection({
icon,
label,
children
}: {
icon: React.ReactNode
label: string
children: React.ReactNode
}): React.JSX.Element {
return (
<div className="grid gap-1">
<div className="text-[10px] font-semibold uppercase leading-3 text-muted-foreground">
{label}
<section className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<span className="text-muted-foreground/80">{icon}</span>
<span>{label}</span>
</div>
{children}
</section>
)
}
function ConversationTurnCard({
role,
text
}: {
role: AiVaultSession['previewMessages'][number]['role']
text: string
}): React.JSX.Element {
const isUserTurn = role === 'user'
return (
<div
className={cn(
'rounded-md border px-2.5 py-2',
isUserTurn
? 'border-border/70 bg-foreground/[0.04]'
: 'border-sidebar-border/70 bg-sidebar-accent/25'
)}
>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{conversationRoleLabel(role)}
</div>
<p className="line-clamp-4 text-[12px] leading-[1.35] text-foreground/90 [overflow-wrap:anywhere]">
{text}
</p>
</div>
)
}
function WorktreeMetadataLines({
worktreeInfo
}: {
worktreeInfo: AiVaultSessionWorktreeInfo
}): React.JSX.Element {
const compactPath = aiVaultWorktreeCompactPath(worktreeInfo.path)
const pathLine =
compactPath && compactPath !== worktreeInfo.label ? compactPath : worktreeInfo.path
const showPathLine = Boolean(pathLine) && pathLine !== worktreeInfo.label
return (
<div className="grid min-w-0 gap-1 text-[11px] leading-4">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
{shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? (
<>
<span className="shrink-0 text-[10px] font-medium uppercase tracking-[0.04em] text-muted-foreground">
{aiVaultWorktreeStatusLabel(worktreeInfo.status)}
</span>
<span className="shrink-0 text-muted-foreground/45">·</span>
</>
) : null}
<span className="min-w-0 text-[12px] font-medium leading-4 text-foreground">
{worktreeInfo.label}
</span>
</div>
{showPathLine ? (
<WorktreePathHint compactPath={pathLine} fullPath={worktreeInfo.path} />
) : null}
</div>
)
}
function WorktreePathHint({
compactPath,
fullPath
}: {
compactPath: string
fullPath: string
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 truncate font-mono text-[11px] leading-4 text-muted-foreground">
{compactPath}
</div>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-sm break-all font-mono text-xs">
{fullPath}
</TooltipContent>
</Tooltip>
)
}
function SessionDetailEmptyState({ message }: { message: string }): React.JSX.Element {
return (
<div className="rounded-md border border-dashed border-sidebar-border/80 px-2.5 py-2 text-[11px] leading-4 text-muted-foreground">
{message}
</div>
)
}
@@ -1,6 +1,6 @@
import { useCallback } from 'react'
import type React from 'react'
import { Copy, FileJson, FolderOpen, Play } from 'lucide-react'
import { Copy, FileJson, FolderOpen, LocateFixed, PanelTopOpen, Play } from 'lucide-react'
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import {
ContextMenu,
@@ -9,8 +9,13 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { Badge } from '@/components/ui/badge'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import { useRepoById } from '@/store/selectors'
import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
import {
AI_VAULT_SESSION_DRAG_START_EVENT,
writeAiVaultSessionDragData
@@ -22,14 +27,27 @@ import { translate } from '@/i18n/i18n'
import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails'
import { latestSessionConversationTurn } from './ai-vault-session-display'
import { SessionRowTrailingActions } from './SessionRowTrailingActions'
import type { AiVaultSessionResumeActions } from './ai-vault-session-resume'
import {
aiVaultWorktreeStatusLabel,
shouldShowAiVaultWorktreeStatusBadge,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
export function VaultSessionRow({
session,
resumeStartup,
worktreeInfo,
detailsExpanded,
resumeDisabled,
onToggleDetails,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
resumeLabel,
resumeActions,
onResumeInWorktree,
onResumeInNewTab,
onCopyResume,
onCopyId,
onCopyPath,
@@ -39,10 +57,17 @@ export function VaultSessionRow({
}: {
session: AiVaultSession
resumeStartup: AiVaultResumeStartup
worktreeInfo: AiVaultSessionWorktreeInfo | null
detailsExpanded: boolean
resumeDisabled: boolean
onToggleDetails: () => void
onJumpToOriginalPane?: () => void
onJumpToWorktree?: () => void
onResume: () => void
resumeLabel: string
resumeActions: AiVaultSessionResumeActions
onResumeInWorktree: () => void
onResumeInNewTab: () => void
onCopyResume: () => void
onCopyId: () => void
onCopyPath: () => void
@@ -80,7 +105,10 @@ export function VaultSessionRow({
<ContextMenu>
<ContextMenuTrigger asChild className="block w-full min-w-0">
<div
className="group/session-row flex min-h-[82px] w-full min-w-0 cursor-pointer flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55"
className={cn(
'group/session-row flex w-full min-w-0 cursor-pointer flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55',
!detailsExpanded && 'min-h-[98px]'
)}
onClick={() => {
onToggleDetails()
}}
@@ -89,7 +117,7 @@ export function VaultSessionRow({
<div
className={cn(
'min-w-0 text-[13px] font-medium leading-5 text-foreground',
detailsExpanded ? 'break-words [overflow-wrap:anywhere]' : 'line-clamp-1'
detailsExpanded ? 'line-clamp-2 [overflow-wrap:anywhere]' : 'line-clamp-1'
)}
>
{session.title}
@@ -100,7 +128,11 @@ export function VaultSessionRow({
detailsId={detailsId}
detailsTooltip={detailsTooltip}
resumeDisabled={resumeDisabled}
resumeLabel={resumeLabel}
worktreeInfo={worktreeInfo}
onToggleDetails={onToggleDetails}
onJumpToOriginalPane={onJumpToOriginalPane}
onJumpToWorktree={onJumpToWorktree}
onResume={onResume}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
@@ -111,29 +143,44 @@ export function VaultSessionRow({
onStartResumeDrag={startResumeDrag}
/>
</div>
<div className="mt-0.5 min-w-0 line-clamp-2 text-[12px] leading-4 text-muted-foreground">
{latestTurn ? (
<>
<span className="font-medium text-foreground/80">
{conversationRoleLabel(latestTurn.role)}
</span>
<span>: {latestTurn.text}</span>
</>
) : (
translate(
'auto.components.right.sidebar.AiVaultSessionRow.noPreviewAvailable',
'No conversation preview available'
)
)}
</div>
<SessionMetadata session={session} updatedAt={updatedAt} />
{detailsExpanded && worktreeInfo ? (
<div className="mt-1">
<SessionWorktreeLine worktreeInfo={worktreeInfo} />
</div>
) : null}
{!detailsExpanded ? (
<>
<div className="mt-0.5 min-w-0 line-clamp-2 text-[12px] leading-4 text-muted-foreground">
{latestTurn ? (
<>
<span className="font-medium text-foreground/80">
{conversationRoleLabel(latestTurn.role)}
</span>
<span>: {latestTurn.text}</span>
</>
) : (
translate(
'auto.components.right.sidebar.AiVaultSessionRow.noPreviewAvailable',
'No conversation preview available'
)
)}
</div>
<SessionMetadata
session={session}
updatedAt={updatedAt}
worktreeInfo={worktreeInfo}
/>
</>
) : null}
{detailsExpanded ? (
<SessionInlineDetails
id={detailsId}
session={session}
resumeDisabled={resumeDisabled}
onResume={onResume}
onCopyResume={onCopyResume}
worktreeInfo={worktreeInfo}
resumeActions={resumeActions}
onResumeInWorktree={onResumeInWorktree}
onResumeInNewTab={onResumeInNewTab}
onOpenLog={onOpenLog}
/>
) : null}
</div>
@@ -142,6 +189,9 @@ export function VaultSessionRow({
<SessionActionMenuItems
menuKind="context"
resumeDisabled={resumeDisabled}
resumeLabel={resumeLabel}
onJumpToOriginalPane={onJumpToOriginalPane}
onJumpToWorktree={onJumpToWorktree}
onResume={onResume}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
@@ -158,7 +208,10 @@ export function VaultSessionRow({
export function SessionActionMenuItems({
menuKind = 'dropdown',
resumeDisabled,
resumeLabel,
onResume,
onJumpToOriginalPane,
onJumpToWorktree,
onCopyResume,
onCopyId,
onCopyPath,
@@ -168,7 +221,10 @@ export function SessionActionMenuItems({
}: {
menuKind?: 'dropdown' | 'context'
resumeDisabled: boolean
resumeLabel: string
onResume: () => void
onJumpToOriginalPane?: () => void
onJumpToWorktree?: () => void
onCopyResume: () => void
onCopyId: () => void
onCopyPath: () => void
@@ -181,12 +237,25 @@ export function SessionActionMenuItems({
return (
<>
{onJumpToOriginalPane ? (
<Item onSelect={onJumpToOriginalPane}>
<LocateFixed className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
'Jump to Original Pane'
)}
</Item>
) : null}
<Item disabled={!onJumpToWorktree} onSelect={onJumpToWorktree}>
<PanelTopOpen className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree',
'Jump to Worktree'
)}
</Item>
<Item disabled={resumeDisabled} onSelect={onResume}>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)}
{resumeLabel}
</Item>
<Item onSelect={onCopyResume}>
<Copy className="size-3.5" />
@@ -231,26 +300,74 @@ function getSessionDetailsId(sessionId: string): string {
return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}`
}
function SessionMetadata({ session, updatedAt }: { session: AiVaultSession; updatedAt: string }) {
function SessionMetadata({
session,
updatedAt,
worktreeInfo
}: {
session: AiVaultSession
updatedAt: string
worktreeInfo: AiVaultSessionWorktreeInfo | null
}) {
return (
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-muted-foreground">
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={14} />
</span>
<span className="min-w-0 truncate">{agentLabel(session.agent)}</span>
<span className="shrink-0 rounded-sm border border-sidebar-border bg-sidebar-accent/45 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
'{{value0}} msgs',
{ value0: session.messageCount }
)}
</span>
<span className="shrink-0 text-muted-foreground/55">·</span>
<SessionTime value={updatedAt} />
<div className="mt-1 grid min-w-0 gap-0.5 text-[11px] leading-4 text-muted-foreground">
<div className="flex min-w-0 items-center gap-1.5">
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={14} />
</span>
<span className="min-w-0 truncate">{agentLabel(session.agent)}</span>
<span className="shrink-0 tabular-nums">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
'{{value0}} msgs',
{ value0: session.messageCount }
)}
</span>
<span className="shrink-0 text-muted-foreground/55">·</span>
<SessionTime value={updatedAt} />
</div>
{worktreeInfo ? <SessionWorktreeLine worktreeInfo={worktreeInfo} /> : null}
</div>
)
}
function SessionWorktreeLine({
worktreeInfo
}: {
worktreeInfo: AiVaultSessionWorktreeInfo
}): React.JSX.Element {
const repoId = worktreeInfo.worktreeId
? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null)
: null
const repo = useRepoById(repoId)
return (
<div className="flex min-w-0 items-center gap-1.5 pl-5">
{shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? (
<span className="shrink-0 rounded-sm border border-sidebar-border bg-sidebar-accent/45 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{worktreeStatusLabel(worktreeInfo.status)}
</span>
) : null}
<Badge
variant="outline"
className="h-5 max-w-full gap-1 border-border/70 bg-background px-1.5 py-0 text-[11px] font-medium"
title={worktreeInfo.label}
>
<RepoBadgeLabel
name={worktreeInfo.label}
color={resolveRepoBadgeColor(repo?.badgeColor)}
className="min-w-0 max-w-full"
badgeClassName="size-1.5"
/>
</Badge>
</div>
)
}
function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string {
return aiVaultWorktreeStatusLabel(status)
}
function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string {
if (role === 'user') {
return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You')
@@ -8,6 +8,16 @@ import { getActiveStickyHeaderIndexForScroll } from '../sidebar/worktree-list-vi
import { EmptyState, SessionLoadingState, VaultGroupHeader } from './AiVaultPanelControls'
import { VaultSessionRow } from './AiVaultSessionRow'
import type { AiVaultSessionGroup } from './ai-vault-session-filters'
import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane'
import {
aiVaultSessionResumeLabel,
type AiVaultSessionResumeActions,
type AiVaultSessionResumeState
} from './ai-vault-session-resume'
import {
canJumpToAiVaultSessionWorktree,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
import {
extractVaultVirtualRowIndexes,
getVaultStickyHeaderIndexes,
@@ -16,7 +26,7 @@ import {
} from './ai-vault-virtual-rows'
const VAULT_ROW_OVERSCAN = 8
const VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT = 360
const VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT = 420
type AiVaultListRow =
| { type: 'group'; group: AiVaultSessionGroup }
@@ -29,9 +39,14 @@ export function AiVaultSessionVirtualList({
sessionsCount,
filteredSessionsCount,
error,
resumeDisabled,
buildResumeStartup,
getOriginalPaneTarget,
getWorktreeInfo,
getSessionResumeState,
getSessionResumeActions,
onToggleGroup,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
onCopyResume,
onCopyId,
@@ -46,11 +61,16 @@ export function AiVaultSessionVirtualList({
sessionsCount: number
filteredSessionsCount: number
error: string | null
resumeDisabled: boolean
buildResumeStartup: (session: AiVaultSession) => AiVaultResumeStartup
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
onToggleGroup: (key: string) => void
onResume: (session: AiVaultSession) => void
onCopyResume: (session: AiVaultSession) => void
onJumpToOriginalPane: (session: AiVaultSession) => void
onJumpToWorktree: (worktreeId: string) => void
onResume: (session: AiVaultSession, worktreeId: string) => void
onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void
onCopyId: (session: AiVaultSession) => void
onCopyPath: (session: AiVaultSession) => void
onOpenLog: (session: AiVaultSession) => void
@@ -163,10 +183,15 @@ export function AiVaultSessionVirtualList({
measureElement={virtualizer.measureElement}
collapsedGroups={collapsedGroups}
expandedSessionIds={expandedSessionIds}
resumeDisabled={resumeDisabled}
buildResumeStartup={buildResumeStartup}
getOriginalPaneTarget={getOriginalPaneTarget}
getWorktreeInfo={getWorktreeInfo}
getSessionResumeState={getSessionResumeState}
getSessionResumeActions={getSessionResumeActions}
onToggleGroup={onToggleGroup}
onToggleSessionDetails={toggleSessionDetails}
onJumpToOriginalPane={onJumpToOriginalPane}
onJumpToWorktree={onJumpToWorktree}
onResume={onResume}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
@@ -190,10 +215,15 @@ function AiVaultVirtualRow({
measureElement,
collapsedGroups,
expandedSessionIds,
resumeDisabled,
buildResumeStartup,
getOriginalPaneTarget,
getWorktreeInfo,
getSessionResumeState,
getSessionResumeActions,
onToggleGroup,
onToggleSessionDetails,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
onCopyResume,
onCopyId,
@@ -209,12 +239,17 @@ function AiVaultVirtualRow({
measureElement: (node: Element | null) => void
collapsedGroups: ReadonlySet<string>
expandedSessionIds: ReadonlySet<string>
resumeDisabled: boolean
buildResumeStartup: (session: AiVaultSession) => AiVaultResumeStartup
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
onToggleGroup: (key: string) => void
onToggleSessionDetails: (sessionId: string) => void
onResume: (session: AiVaultSession) => void
onCopyResume: (session: AiVaultSession) => void
onJumpToOriginalPane: (session: AiVaultSession) => void
onJumpToWorktree: (worktreeId: string) => void
onResume: (session: AiVaultSession, worktreeId: string) => void
onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void
onCopyId: (session: AiVaultSession) => void
onCopyPath: (session: AiVaultSession) => void
onOpenLog: (session: AiVaultSession) => void
@@ -226,6 +261,14 @@ function AiVaultVirtualRow({
}
const isActiveStickyHeader = row.type === 'group' && activeStickyHeaderIndex === index
const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null
const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null
const worktreeJumpId = canJumpToAiVaultSessionWorktree(worktreeInfo)
? worktreeInfo?.worktreeId
: null
const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null
const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null
const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : ''
return (
<div
@@ -246,12 +289,38 @@ function AiVaultVirtualRow({
) : (
<VaultSessionRow
session={row.session}
resumeStartup={buildResumeStartup(row.session)}
resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)}
worktreeInfo={worktreeInfo}
detailsExpanded={expandedSessionIds.has(row.session.id)}
resumeDisabled={resumeDisabled}
resumeDisabled={resumeState?.blocked ?? true}
resumeLabel={resumeLabel}
resumeActions={
resumeActions ?? {
worktree: { worktreeId: null, disabled: true },
newTab: { worktreeId: null, disabled: true }
}
}
onToggleDetails={() => onToggleSessionDetails(row.session.id)}
onResume={() => onResume(row.session)}
onCopyResume={() => onCopyResume(row.session)}
onJumpToOriginalPane={
originalPaneTarget ? () => onJumpToOriginalPane(row.session) : undefined
}
onJumpToWorktree={worktreeJumpId ? () => onJumpToWorktree(worktreeJumpId) : undefined}
onResume={() => {
if (resumeState?.worktreeId) {
onResume(row.session, resumeState.worktreeId)
}
}}
onResumeInWorktree={() => {
if (resumeActions?.worktree.worktreeId) {
onResume(row.session, resumeActions.worktree.worktreeId)
}
}}
onResumeInNewTab={() => {
if (resumeActions?.newTab.worktreeId) {
onResume(row.session, resumeActions.newTab.worktreeId)
}
}}
onCopyResume={() => onCopyResume(row.session, resumeState?.worktreeId)}
onCopyId={() => onCopyId(row.session)}
onCopyPath={() => onCopyPath(row.session)}
onOpenLog={() => onOpenLog(row.session)}
@@ -1,6 +1,13 @@
import { useCallback, useEffect, useRef } from 'react'
import type React from 'react'
import { ChevronDown, GripVertical, MoreHorizontal, Play } from 'lucide-react'
import {
ChevronDown,
GripVertical,
LocateFixed,
MoreHorizontal,
PanelTopOpen,
Play
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
@@ -14,6 +21,10 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { agentLabel } from './ai-vault-session-filters'
import { translate } from '@/i18n/i18n'
import { SessionActionMenuItems } from './AiVaultSessionRow'
import {
aiVaultWorktreeJumpTooltip,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
// Why: hover-only actions live on the title row and collapse on hover-capable
// devices so the prompt keeps the full width until the row is hovered.
@@ -36,7 +47,11 @@ export function SessionRowTrailingActions({
detailsId,
detailsTooltip,
resumeDisabled,
resumeLabel,
worktreeInfo,
onToggleDetails,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
onCopyResume,
onCopyId,
@@ -51,7 +66,11 @@ export function SessionRowTrailingActions({
detailsId: string
detailsTooltip: string
resumeDisabled: boolean
resumeLabel: string
worktreeInfo: AiVaultSessionWorktreeInfo | null
onToggleDetails: () => void
onJumpToOriginalPane?: () => void
onJumpToWorktree?: () => void
onResume: () => void
onCopyResume: () => void
onCopyId: () => void
@@ -77,6 +96,8 @@ export function SessionRowTrailingActions({
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
}, [])
const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo)
// Cleanup drag state on unmount if a drag was in progress
useEffect(() => {
return () => {
@@ -126,17 +147,53 @@ export function SessionRowTrailingActions({
)}
</TooltipContent>
</Tooltip>
{onJumpToOriginalPane ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
'Jump to Original Pane'
)}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onJumpToOriginalPane()
}}
data-testid="ai-vault-session-jump-original-pane"
className="can-hover:pointer-events-none group-hover/session-row:pointer-events-auto group-focus-within/session-row:pointer-events-auto focus-visible:pointer-events-auto"
>
<LocateFixed className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane',
'Jump to Original Pane'
)}
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<WorktreeJumpTooltipTrigger
disabled={!onJumpToWorktree}
ariaLabel={jumpToWorktreeTooltip}
onJumpToWorktree={onJumpToWorktree}
/>
<TooltipContent side="top" sideOffset={4}>
{jumpToWorktreeTooltip}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeAgentSession',
'Resume {{value0}} session',
{ value0: agentLabel(session.agent) }
)}
aria-label={resumeLabel}
disabled={resumeDisabled}
draggable={false}
onClick={(event) => {
@@ -153,10 +210,7 @@ export function SessionRowTrailingActions({
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)}
{resumeLabel}
</TooltipContent>
</Tooltip>
</div>
@@ -219,7 +273,10 @@ export function SessionRowTrailingActions({
<DropdownMenuContent align="end">
<SessionActionMenuItems
resumeDisabled={resumeDisabled}
resumeLabel={resumeLabel}
onResume={onResume}
onJumpToOriginalPane={onJumpToOriginalPane}
onJumpToWorktree={onJumpToWorktree}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
onCopyPath={onCopyPath}
@@ -232,3 +289,50 @@ export function SessionRowTrailingActions({
</div>
)
}
function WorktreeJumpTooltipTrigger({
disabled,
ariaLabel,
onJumpToWorktree
}: {
disabled: boolean
ariaLabel: string
onJumpToWorktree?: () => void
}): React.JSX.Element {
const button = (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={ariaLabel}
disabled={disabled}
draggable={false}
onClick={(event) => {
event.stopPropagation()
onJumpToWorktree?.()
}}
data-testid="ai-vault-session-jump-worktree"
className={cn(
!disabled &&
'can-hover:pointer-events-none group-hover/session-row:pointer-events-auto group-focus-within/session-row:pointer-events-auto focus-visible:pointer-events-auto'
)}
>
<PanelTopOpen className="size-3.5" />
</Button>
)
if (!disabled) {
return <TooltipTrigger asChild>{button}</TooltipTrigger>
}
return (
<TooltipTrigger asChild>
<span
className="inline-flex can-hover:pointer-events-none group-hover/session-row:pointer-events-auto group-focus-within/session-row:pointer-events-auto"
onClick={(event) => event.stopPropagation()}
>
{button}
</span>
</TooltipTrigger>
)
}
@@ -6501,7 +6501,9 @@ export function CommitArea({
aria-describedby={describedBy || undefined}
// Why: reserve right padding so typed text does not slide under the
// absolute-positioned Generate icon in the top-right corner.
className={`mt-0.5 min-h-14 w-full resize-none rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 ${
// Why: match Input surface tokens and pin disabled:border-input so
// Chromium's UA disabled styles don't wash out the field outline.
className={`mt-0.5 min-h-14 w-full resize-none appearance-none rounded-md border border-input bg-background shadow-xs px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:border-input disabled:bg-background disabled:text-foreground disabled:shadow-xs dark:bg-input/30 dark:disabled:bg-input/30 ${
showGenerate ? 'pr-8' : ''
}`}
/>
@@ -0,0 +1,74 @@
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { toast } from 'sonner'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
import { findOriginalAiVaultSessionPane } from './ai-vault-original-pane'
export function useAiVaultOriginalPaneActions(): {
getOriginalPaneTarget: (
session: AiVaultSession
) => ReturnType<typeof findOriginalAiVaultSessionPane>
jumpToOriginalPane: (session: AiVaultSession) => void
jumpToWorktree: (worktreeId: string) => void
} {
const originalPaneLookupState = useAppStore(
useShallow((s) => ({
agentStatusByPaneKey: s.agentStatusByPaneKey,
retainedAgentsByPaneKey: s.retainedAgentsByPaneKey,
sleepingAgentSessionsByPaneKey: s.sleepingAgentSessionsByPaneKey,
tabsByWorktree: s.tabsByWorktree,
terminalLayoutsByTabId: s.terminalLayoutsByTabId
}))
)
const getOriginalPaneTarget = useCallback(
(session: AiVaultSession) => findOriginalAiVaultSessionPane(originalPaneLookupState, session),
[originalPaneLookupState]
)
const jumpToOriginalPane = useCallback((session: AiVaultSession): void => {
const target = findOriginalAiVaultSessionPane(useAppStore.getState(), session)
if (!target) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.originalPaneUnavailable',
'Original pane is no longer available.'
)
)
return
}
if (!activateAndRevealWorktree(target.worktreeId)) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.worktreeUnavailable',
'Worktree is no longer available.'
)
)
return
}
const state = useAppStore.getState()
state.setActiveTabType('terminal')
activateTabAndFocusPane(target.tabId, target.leafId, {
flashFocusedPane: true,
scrollToBottomIfOutputSinceLastView: true
})
}, [])
const jumpToWorktree = useCallback((worktreeId: string): void => {
if (!activateAndRevealWorktree(worktreeId)) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.worktreeUnavailable',
'Worktree is no longer available.'
)
)
}
}, [])
return { getOriginalPaneTarget, jumpToOriginalPane, jumpToWorktree }
}
@@ -0,0 +1,213 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { findOriginalAiVaultSessionPane } from './ai-vault-original-pane'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const baseSession: AiVaultSession = {
id: 'codex:session-1',
agent: 'codex',
sessionId: 'session-1',
title: 'Fix the pane focus',
cwd: '/repo',
branch: null,
model: null,
filePath: '/home/ada/.codex/session-1.jsonl',
codexHome: null,
createdAt: null,
updatedAt: '2026-06-24T10:00:00.000Z',
modifiedAt: '2026-06-24T10:00:00.000Z',
messageCount: 2,
totalTokens: 42,
previewMessages: [],
resumeCommand: "codex resume 'session-1'"
}
function makeTab(id = 'tab-1', worktreeId = 'wt-1') {
return {
id,
ptyId: null,
worktreeId,
title: 'Agent',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
function makeLayout(leafId = LEAF_ID) {
return {
root: { type: 'leaf' as const, leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: 'pty-1' }
}
}
function makeState(overrides: Record<string, unknown> = {}) {
return {
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
tabsByWorktree: { 'wt-1': [makeTab()] },
terminalLayoutsByTabId: { 'tab-1': makeLayout() },
...overrides
} as never
}
function makeEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
const paneKey = makePaneKey('tab-1', LEAF_ID)
return {
state: 'working',
prompt: 'continue',
updatedAt: 1,
stateStartedAt: 1,
agentType: 'codex',
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
stateHistory: [],
providerSession: { key: 'session_id', id: 'session-1' },
...overrides
}
}
function makeSleepingRecord(
overrides: Partial<SleepingAgentSessionRecord> = {}
): SleepingAgentSessionRecord {
return {
paneKey: makePaneKey('tab-1', LEAF_ID),
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'session-1' },
prompt: 'continue',
state: 'working',
capturedAt: 1,
updatedAt: 1,
origin: 'live',
...overrides
}
}
describe('findOriginalAiVaultSessionPane', () => {
it('finds a live pane with a matching provider session', () => {
const entry = makeEntry()
const target = findOriginalAiVaultSessionPane(
makeState({ agentStatusByPaneKey: { [entry.paneKey]: entry } }),
baseSession
)
expect(target).toEqual({
paneKey: entry.paneKey,
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: LEAF_ID
})
})
it('finds a unique live pane by prompt when provider session is not known yet', () => {
const entry = makeEntry({
providerSession: undefined,
prompt: 'Fix the pane focus'
})
const target = findOriginalAiVaultSessionPane(
makeState({ agentStatusByPaneKey: { [entry.paneKey]: entry } }),
baseSession
)
expect(target?.paneKey).toBe(entry.paneKey)
})
it('does not prompt-match when multiple panes are plausible', () => {
const firstPaneKey = makePaneKey('tab-1', LEAF_ID)
const secondPaneKey = makePaneKey('tab-2', OTHER_LEAF_ID)
const first = makeEntry({ paneKey: firstPaneKey, providerSession: undefined })
const second = makeEntry({
paneKey: secondPaneKey,
tabId: 'tab-2',
providerSession: undefined
})
const target = findOriginalAiVaultSessionPane(
makeState({
agentStatusByPaneKey: {
[first.paneKey]: first,
[second.paneKey]: second
},
tabsByWorktree: { 'wt-1': [makeTab('tab-1'), makeTab('tab-2')] },
terminalLayoutsByTabId: {
'tab-1': makeLayout(LEAF_ID),
'tab-2': makeLayout(OTHER_LEAF_ID)
}
}),
baseSession
)
expect(target).toBeNull()
})
it('does not return a target when the original leaf is gone', () => {
const entry = makeEntry()
const target = findOriginalAiVaultSessionPane(
makeState({
agentStatusByPaneKey: { [entry.paneKey]: entry },
terminalLayoutsByTabId: { 'tab-1': makeLayout(OTHER_LEAF_ID) }
}),
baseSession
)
expect(target).toBeNull()
})
it('finds a retained completed pane when the tab and layout still exist', () => {
const entry = makeEntry({ state: 'done' })
const target = findOriginalAiVaultSessionPane(
makeState({
retainedAgentsByPaneKey: {
[entry.paneKey]: {
entry,
worktreeId: 'wt-1',
tab: makeTab(),
agentType: 'codex',
startedAt: 1
}
}
}),
baseSession
)
expect(target?.leafId).toBe(LEAF_ID)
})
it('finds a preserved sleeping pane from a matching session record', () => {
const record = makeSleepingRecord()
const target = findOriginalAiVaultSessionPane(
makeState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } }),
baseSession
)
expect(target?.paneKey).toBe(record.paneKey)
})
it('resolves legacy numeric pane keys through the current tab layout', () => {
const record = makeSleepingRecord({ paneKey: 'tab-1:1' })
const target = findOriginalAiVaultSessionPane(
makeState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } }),
baseSession
)
expect(target?.leafId).toBe(LEAF_ID)
})
})
@@ -0,0 +1,262 @@
import type { AppState } from '@/store/types'
import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types'
export type AiVaultOriginalPaneTarget = {
paneKey: string
worktreeId: string
tabId: string
leafId: string
}
type OriginalPaneState = Pick<
AppState,
| 'agentStatusByPaneKey'
| 'retainedAgentsByPaneKey'
| 'sleepingAgentSessionsByPaneKey'
| 'tabsByWorktree'
| 'terminalLayoutsByTabId'
>
function agentMatches(session: AiVaultSession, agent: string | undefined): boolean {
return agent === session.agent
}
function providerSessionMatches(session: AiVaultSession, providerSessionId: string | undefined) {
return providerSessionId === session.sessionId
}
function normalizeMatchText(value: string | null | undefined): string {
return value?.trim().replace(/\s+/g, ' ').toLowerCase() ?? ''
}
function longEnoughForPrefixMatch(value: string): boolean {
return value.length >= 24
}
function textMatchesSessionPrompt(sessionText: string, candidateText: string): boolean {
if (!sessionText || !candidateText) {
return false
}
if (sessionText === candidateText) {
return true
}
return (
longEnoughForPrefixMatch(sessionText) &&
longEnoughForPrefixMatch(candidateText) &&
(sessionText.startsWith(candidateText) || candidateText.startsWith(sessionText))
)
}
function sessionPromptCandidates(session: AiVaultSession): string[] {
const candidates = new Set<string>()
const title = normalizeMatchText(session.title)
if (title) {
candidates.add(title)
}
for (const message of session.previewMessages) {
if (message.role !== 'user') {
continue
}
const text = normalizeMatchText(message.text)
if (text) {
candidates.add(text)
}
}
return [...candidates]
}
function entryPromptCandidates(entry: {
prompt?: string
stateHistory?: readonly { prompt: string }[]
}): string[] {
const candidates = new Set<string>()
const prompt = normalizeMatchText(entry.prompt)
if (prompt) {
candidates.add(prompt)
}
for (const historyEntry of entry.stateHistory ?? []) {
const text = normalizeMatchText(historyEntry.prompt)
if (text) {
candidates.add(text)
}
}
return [...candidates]
}
function promptsMatchSession(
session: AiVaultSession,
entry: Parameters<typeof entryPromptCandidates>[0]
) {
const sessionCandidates = sessionPromptCandidates(session)
if (sessionCandidates.length === 0) {
return false
}
const entryCandidates = entryPromptCandidates(entry)
return sessionCandidates.some((sessionText) =>
entryCandidates.some((entryText) => textMatchesSessionPrompt(sessionText, entryText))
)
}
function layoutHasLeaf(node: TerminalPaneLayoutNode | null | undefined, leafId: string): boolean {
if (!node) {
return false
}
if (node.type === 'leaf') {
return node.leafId === leafId
}
return layoutHasLeaf(node.first, leafId) || layoutHasLeaf(node.second, leafId)
}
function hasAvailableLeaf(layout: TerminalLayoutSnapshot | undefined, leafId: string): boolean {
return layoutHasLeaf(layout?.root, leafId) || Boolean(layout?.ptyIdsByLeafId?.[leafId])
}
function getTabOwnerWorktreeId(
state: OriginalPaneState,
tabId: string,
worktreeIdHint?: string
): string | null {
if (
worktreeIdHint &&
(state.tabsByWorktree[worktreeIdHint] ?? []).some((tab) => tab.id === tabId)
) {
return worktreeIdHint
}
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
if (tabs.some((tab) => tab.id === tabId)) {
return worktreeId
}
}
return null
}
function resolveOriginalPaneTarget(args: {
state: OriginalPaneState
paneKey: string
worktreeIdHint?: string
tabIdHint?: string
}): AiVaultOriginalPaneTarget | null {
const { state, paneKey, worktreeIdHint, tabIdHint } = args
const stable = parsePaneKey(paneKey)
if (stable) {
if (tabIdHint && tabIdHint !== stable.tabId) {
return null
}
const worktreeId = getTabOwnerWorktreeId(state, stable.tabId, worktreeIdHint)
if (
!worktreeId ||
!hasAvailableLeaf(state.terminalLayoutsByTabId[stable.tabId], stable.leafId)
) {
return null
}
return { paneKey, worktreeId, tabId: stable.tabId, leafId: stable.leafId }
}
const legacy = parseLegacyNumericPaneKey(paneKey)
if (!legacy || (tabIdHint && tabIdHint !== legacy.tabId)) {
return null
}
const worktreeId = getTabOwnerWorktreeId(state, legacy.tabId, worktreeIdHint)
if (!worktreeId) {
return null
}
const layout = state.terminalLayoutsByTabId[legacy.tabId]
const leafId = resolveRuntimePaneTitleLeafId(layout, legacy.numericPaneId)
if (!leafId || !hasAvailableLeaf(layout, leafId)) {
return null
}
return { paneKey, worktreeId, tabId: legacy.tabId, leafId }
}
export function findOriginalAiVaultSessionPane(
state: OriginalPaneState,
session: AiVaultSession
): AiVaultOriginalPaneTarget | null {
const promptMatchedTargets: AiVaultOriginalPaneTarget[] = []
for (const entry of Object.values(state.agentStatusByPaneKey)) {
if (
agentMatches(session, entry.agentType) &&
providerSessionMatches(session, entry.providerSession?.id)
) {
const target = resolveOriginalPaneTarget({
state,
paneKey: entry.paneKey,
worktreeIdHint: entry.worktreeId,
tabIdHint: entry.tabId
})
if (target) {
return target
}
}
if (
agentMatches(session, entry.agentType) &&
entry.providerSession === undefined &&
promptsMatchSession(session, entry)
) {
const target = resolveOriginalPaneTarget({
state,
paneKey: entry.paneKey,
worktreeIdHint: entry.worktreeId,
tabIdHint: entry.tabId
})
if (target) {
promptMatchedTargets.push(target)
}
}
}
for (const retained of Object.values(state.retainedAgentsByPaneKey)) {
if (
agentMatches(session, retained.agentType) &&
providerSessionMatches(session, retained.entry.providerSession?.id)
) {
const target = resolveOriginalPaneTarget({
state,
paneKey: retained.entry.paneKey,
worktreeIdHint: retained.worktreeId,
tabIdHint: retained.entry.tabId ?? retained.tab.id
})
if (target) {
return target
}
}
if (
agentMatches(session, retained.agentType) &&
retained.entry.providerSession === undefined &&
promptsMatchSession(session, retained.entry)
) {
const target = resolveOriginalPaneTarget({
state,
paneKey: retained.entry.paneKey,
worktreeIdHint: retained.worktreeId,
tabIdHint: retained.entry.tabId ?? retained.tab.id
})
if (target) {
promptMatchedTargets.push(target)
}
}
}
for (const record of Object.values(state.sleepingAgentSessionsByPaneKey)) {
if (
agentMatches(session, record.agent) &&
providerSessionMatches(session, record.providerSession.id)
) {
const target = resolveOriginalPaneTarget({
state,
paneKey: record.paneKey,
worktreeIdHint: record.worktreeId,
tabIdHint: record.tabId
})
if (target) {
return target
}
}
}
return promptMatchedTargets.length === 1 ? promptMatchedTargets[0] : null
}
@@ -3,6 +3,7 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import {
latestSessionConversationTurn,
recentSessionConversationTurns,
sessionDetailConversationTurns,
sessionPreviewSearchText
} from './ai-vault-session-display'
@@ -70,4 +71,22 @@ describe('ai vault session display', () => {
})
).toBe('tool-only transcript')
})
it('drops title-matching turns and adjacent duplicates from detail turns', () => {
const session: AiVaultSession = {
...baseSession,
title: 'Fix the flaky golden tests',
previewMessages: [
{ role: 'user', text: 'Fix the flaky golden tests', timestamp: null },
{ role: 'assistant', text: 'I updated the fixture ordering', timestamp: null },
{ role: 'assistant', text: 'I updated the fixture ordering', timestamp: null },
{ role: 'assistant', text: 'Added a regression test', timestamp: null }
]
}
expect(sessionDetailConversationTurns(session, 3).map((turn) => turn.text)).toEqual([
'I updated the fixture ordering',
'Added a regression test'
])
})
})
@@ -28,6 +28,58 @@ export function recentSessionConversationTurns(
return displayableSessionPreviewMessages(session).slice(-limit).map(toDisplayTurn)
}
export function sessionDetailConversationTurns(
session: AiVaultSession,
limit: number
): AiVaultSessionDisplayTurn[] {
if (limit <= 0) {
return []
}
const turns = displayableSessionPreviewMessages(session)
.map(toDisplayTurn)
.filter((turn) => !turnTextMatchesSessionTitle(session.title, turn.text))
return dedupeAdjacentConversationTurns(turns).slice(-limit)
}
function turnTextMatchesSessionTitle(title: string, turnText: string): boolean {
const sessionText = normalizeSessionDisplayText(title)
const candidateText = normalizeSessionDisplayText(turnText)
if (!sessionText || !candidateText) {
return false
}
if (sessionText === candidateText) {
return true
}
const longEnough = sessionText.length >= 24 && candidateText.length >= 24
return (
longEnough && (sessionText.startsWith(candidateText) || candidateText.startsWith(sessionText))
)
}
function dedupeAdjacentConversationTurns(
turns: AiVaultSessionDisplayTurn[]
): AiVaultSessionDisplayTurn[] {
const deduped: AiVaultSessionDisplayTurn[] = []
for (const turn of turns) {
const previous = deduped.at(-1)
if (
previous &&
previous.role === turn.role &&
normalizeSessionDisplayText(previous.text) === normalizeSessionDisplayText(turn.text)
) {
continue
}
deduped.push(turn)
}
return deduped
}
function normalizeSessionDisplayText(value: string): string {
return value.trim().replace(/\s+/g, ' ').toLowerCase()
}
export function sessionPreviewSearchText(session: AiVaultSession): string {
return displayableSessionPreviewMessages(session)
.map((message) => message.text)
@@ -0,0 +1,113 @@
import { useCallback } from 'react'
import { toast } from 'sonner'
import {
buildAiVaultResumeCommandForWorktree,
buildAiVaultResumeStartupForWorktree,
type AiVaultResumeStartup
} from '@/lib/ai-vault-resume-command'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Repo, Worktree } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import { agentLabel } from './ai-vault-session-filters'
export function useAiVaultSessionLaunchActions({
activeWorktree,
allWorktrees,
repos,
agentCmdOverrides
}: {
activeWorktree: Worktree | null
allWorktrees: readonly Worktree[]
repos: readonly Repo[]
agentCmdOverrides?: Partial<Record<AiVaultAgent, string | null>>
}): {
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
copyResumeCommand: (session: AiVaultSession, worktreeId?: string | null) => Promise<void>
handleResume: (session: AiVaultSession, targetWorktreeId?: string) => void
} {
const buildResumeCommand = useCallback(
(session: AiVaultSession, worktreeId?: string | null): string =>
buildAiVaultResumeCommandForWorktree({
state: useAppStore.getState(),
worktreeId: worktreeId ?? activeWorktree?.id ?? null,
session,
commandOverride: agentCmdOverrides?.[session.agent]
}),
[activeWorktree?.id, agentCmdOverrides]
)
const buildResumeStartup = useCallback(
(session: AiVaultSession, worktreeId?: string | null) =>
buildAiVaultResumeStartupForWorktree({
state: useAppStore.getState(),
worktreeId: worktreeId ?? activeWorktree?.id ?? null,
session,
commandOverride: agentCmdOverrides?.[session.agent]
}),
[activeWorktree?.id, agentCmdOverrides]
)
const copyResumeCommand = useCallback(
async (session: AiVaultSession, worktreeId?: string | null): Promise<void> => {
await window.api.ui.writeClipboardText(buildResumeCommand(session, worktreeId))
toast.success(
translate(
'auto.components.right.sidebar.AiVaultPanel.resumeCommandCopied',
'Resume command copied'
)
)
},
[buildResumeCommand]
)
const handleResume = useCallback(
(session: AiVaultSession, targetWorktreeId?: string): void => {
const worktree =
(targetWorktreeId
? allWorktrees.find((candidate) => candidate.id === targetWorktreeId)
: null) ?? activeWorktree
if (!worktree) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming',
'Open a workspace before resuming a session.'
)
)
return
}
const worktreeRepo = repos.find((repo) => repo.id === worktree.repoId)
if (worktreeRepo?.connectionId) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.localWorkspacesOnly',
'Resume from history is only available in local workspaces.'
)
)
return
}
launchAiVaultSessionInNewTab({
agent: session.agent,
worktreeId: worktree.id,
...buildResumeStartup(session, worktree.id)
})
if (useAppStore.getState().activeWorktreeId !== worktree.id) {
activateAndRevealWorktree(worktree.id)
}
toast.success(
translate(
'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued',
'{{value0}} session queued',
{ value0: agentLabel(session.agent) }
)
)
},
[activeWorktree, allWorktrees, buildResumeStartup, repos]
)
return { buildResumeStartup, copyResumeCommand, handleResume }
}
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest'
import type { Repo, Worktree } from '../../../../shared/types'
import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree'
import {
aiVaultSessionResumeLabel,
resolveAiVaultSessionResumeActions,
resolveAiVaultSessionResumeState
} from './ai-vault-session-resume'
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'repo-1::/repo/orca',
repoId: 'repo-1',
displayName: 'orca',
path: '/repo/orca',
head: 'abc123',
branch: 'main',
isBare: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
isMainWorktree: false,
...overrides
}
}
function makeWorktreeInfo(
status: AiVaultSessionWorktreeInfo['status']
): AiVaultSessionWorktreeInfo {
return {
status,
label: 'orca',
path: '/repo/orca',
...(status === 'unavailable' ? {} : { worktreeId: 'repo-1::/repo/orca' })
}
}
describe('resolveAiVaultSessionResumeState', () => {
it('prefers the session worktree over the active workspace', () => {
expect(
resolveAiVaultSessionResumeState({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-1::/repo/other',
worktrees: [
makeWorktree(),
makeWorktree({ id: 'repo-1::/repo/other', path: '/repo/other' })
],
repos: [{ id: 'repo-1' } as Repo]
})
).toEqual({
blocked: false,
worktreeId: 'repo-1::/repo/orca',
usesSessionWorktree: true
})
})
it('falls back to the active workspace when the session worktree is unavailable', () => {
expect(
resolveAiVaultSessionResumeState({
worktreeInfo: makeWorktreeInfo('archived'),
activeWorktreeId: 'repo-1::/repo/orca',
worktrees: [makeWorktree()],
repos: [{ id: 'repo-1' } as Repo]
})
).toEqual({
blocked: false,
worktreeId: 'repo-1::/repo/orca',
usesSessionWorktree: false
})
})
it('blocks remote targets and missing worktrees', () => {
expect(
resolveAiVaultSessionResumeState({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: null,
worktrees: [],
repos: [{ id: 'repo-1', connectionId: 'ssh-1' } as Repo]
})
).toEqual({
blocked: true,
worktreeId: null,
usesSessionWorktree: false
})
})
it('uses a local session worktree when the active workspace is remote', () => {
expect(
resolveAiVaultSessionResumeState({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-2::/remote/orca',
worktrees: [
makeWorktree(),
makeWorktree({
id: 'repo-2::/remote/orca',
repoId: 'repo-2',
path: '/remote/orca'
})
],
repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo]
})
).toEqual({
blocked: false,
worktreeId: 'repo-1::/repo/orca',
usesSessionWorktree: true
})
})
})
describe('resolveAiVaultSessionResumeActions', () => {
it('exposes separate session-worktree and active-workspace targets', () => {
expect(
resolveAiVaultSessionResumeActions({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-1::/repo/other',
worktrees: [
makeWorktree(),
makeWorktree({ id: 'repo-1::/repo/other', path: '/repo/other' })
],
repos: [{ id: 'repo-1' } as Repo]
})
).toEqual({
worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false },
newTab: { worktreeId: 'repo-1::/repo/other', disabled: false }
})
})
it('disables only the remote active-workspace action when the session worktree is local', () => {
expect(
resolveAiVaultSessionResumeActions({
worktreeInfo: makeWorktreeInfo('active'),
activeWorktreeId: 'repo-2::/remote/orca',
worktrees: [
makeWorktree(),
makeWorktree({
id: 'repo-2::/remote/orca',
repoId: 'repo-2',
path: '/remote/orca'
})
],
repos: [{ id: 'repo-1' } as Repo, { id: 'repo-2', connectionId: 'ssh-1' } as Repo]
})
).toEqual({
worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false },
newTab: { worktreeId: 'repo-2::/remote/orca', disabled: true }
})
})
it('does not expose the active workspace as a duplicate new-tab target', () => {
expect(
resolveAiVaultSessionResumeActions({
worktreeInfo: makeWorktreeInfo('current'),
activeWorktreeId: 'repo-1::/repo/orca',
worktrees: [makeWorktree()],
repos: [{ id: 'repo-1' } as Repo]
})
).toEqual({
worktree: { worktreeId: 'repo-1::/repo/orca', disabled: false },
newTab: { worktreeId: null, disabled: true }
})
})
})
describe('aiVaultSessionResumeLabel', () => {
it('names the session worktree action distinctly from the active-workspace fallback', () => {
expect(aiVaultSessionResumeLabel({ usesSessionWorktree: true })).toBe('Resume in Worktree')
expect(aiVaultSessionResumeLabel({ usesSessionWorktree: false })).toBe('Resume in New Tab')
})
})
@@ -0,0 +1,140 @@
import type { Repo, Worktree } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import {
canJumpToAiVaultSessionWorktree,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
export type AiVaultSessionResumeState = {
blocked: boolean
worktreeId: string | null
usesSessionWorktree: boolean
}
export type AiVaultSessionResumeAction = {
worktreeId: string | null
disabled: boolean
}
export type AiVaultSessionResumeActions = {
worktree: AiVaultSessionResumeAction
newTab: AiVaultSessionResumeAction
}
export function resolveAiVaultSessionResumeState(args: {
worktreeInfo: AiVaultSessionWorktreeInfo | null
activeWorktreeId: string | null
worktrees: readonly Worktree[]
repos: readonly Repo[]
}): AiVaultSessionResumeState {
const sessionWorktreeId =
canJumpToAiVaultSessionWorktree(args.worktreeInfo) && args.worktreeInfo?.worktreeId
? args.worktreeInfo.worktreeId
: null
const candidateWorktreeIds = [
sessionWorktreeId,
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
? args.activeWorktreeId
: null
].filter((value): value is string => Boolean(value))
for (const worktreeId of candidateWorktreeIds) {
const worktree = args.worktrees.find((candidate) => candidate.id === worktreeId)
if (!worktree) {
continue
}
const repo = args.repos.find((candidate) => candidate.id === worktree.repoId)
if (repo?.connectionId) {
continue
}
return {
blocked: false,
worktreeId,
usesSessionWorktree: worktreeId === sessionWorktreeId
}
}
return {
blocked: true,
worktreeId: null,
usesSessionWorktree: false
}
}
export function resolveAiVaultSessionResumeActions(args: {
worktreeInfo: AiVaultSessionWorktreeInfo | null
activeWorktreeId: string | null
worktrees: readonly Worktree[]
repos: readonly Repo[]
}): AiVaultSessionResumeActions {
const sessionWorktreeId =
canJumpToAiVaultSessionWorktree(args.worktreeInfo) && args.worktreeInfo?.worktreeId
? args.worktreeInfo.worktreeId
: null
const sessionTargetId = resolveLocalResumeWorktreeId({
worktreeId: sessionWorktreeId,
worktrees: args.worktrees,
repos: args.repos
})
const activeTargetId = resolveLocalResumeWorktreeId({
worktreeId:
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
? args.activeWorktreeId
: null,
worktrees: args.worktrees,
repos: args.repos
})
return {
worktree: {
worktreeId: sessionWorktreeId,
disabled: !sessionTargetId
},
newTab: {
worktreeId:
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
? args.activeWorktreeId
: null,
disabled: !activeTargetId
}
}
}
function resolveLocalResumeWorktreeId(args: {
worktreeId: string | null
worktrees: readonly Worktree[]
repos: readonly Repo[]
}): string | null {
if (!args.worktreeId) {
return null
}
const worktree = args.worktrees.find((candidate) => candidate.id === args.worktreeId)
if (!worktree) {
return null
}
const repo = args.repos.find((candidate) => candidate.id === worktree.repoId)
if (repo?.connectionId) {
return null
}
return args.worktreeId
}
export function aiVaultSessionResumeLabel(
state: Pick<AiVaultSessionResumeState, 'usesSessionWorktree'>
): string {
if (state.usesSessionWorktree) {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
'Resume in Worktree'
)
}
return translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)
}
@@ -0,0 +1,229 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Worktree } from '../../../../shared/types'
import {
aiVaultWorktreeCompactPath,
aiVaultWorktreeJumpTooltip,
canJumpToAiVaultSessionWorktree,
extractWorktreePathFromSessionTitle,
resolveAiVaultSessionWorktreeDisplay,
resolveAiVaultSessionWorktreeInfo,
shouldShowAiVaultWorktreeStatusBadge,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
const baseSession: AiVaultSession = {
id: 'codex:session-1',
agent: 'codex',
sessionId: 'session-1',
title: 'Find the pane',
cwd: '/repo/orca/src',
branch: null,
model: null,
filePath: '/home/ada/.codex/session-1.jsonl',
codexHome: null,
createdAt: null,
updatedAt: '2026-06-24T10:00:00.000Z',
modifiedAt: '2026-06-24T10:00:00.000Z',
messageCount: 2,
totalTokens: 42,
previewMessages: [],
resumeCommand: "codex resume 'session-1'"
}
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
const worktree: Worktree = {
id: 'repo-1::/repo/orca',
repoId: 'repo-1',
displayName: 'orca',
path: '/repo/orca',
head: 'abc123',
branch: 'main',
isBare: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
isMainWorktree: false
}
return { ...worktree, ...overrides }
}
describe('resolveAiVaultSessionWorktreeInfo', () => {
it('marks the selected owning worktree as current', () => {
const worktree = makeWorktree()
expect(
resolveAiVaultSessionWorktreeInfo({
session: baseSession,
worktrees: [worktree],
activeWorktreeId: worktree.id
})
).toMatchObject({
status: 'current',
label: 'orca',
path: '/repo/orca'
})
})
it('marks a known non-selected worktree as active', () => {
const worktree = makeWorktree()
expect(
resolveAiVaultSessionWorktreeInfo({
session: baseSession,
worktrees: [worktree],
activeWorktreeId: 'other'
})?.status
).toBe('active')
})
it('uses prior worktree paths to identify renamed active worktrees', () => {
const worktree = makeWorktree({
id: 'repo-1::/repo/orca-renamed',
path: '/repo/orca-renamed',
priorWorktreeIds: ['repo-1::/repo/orca']
})
expect(
resolveAiVaultSessionWorktreeInfo({
session: baseSession,
worktrees: [worktree],
activeWorktreeId: null
})
).toMatchObject({
status: 'active',
label: 'orca',
path: '/repo/orca'
})
})
it('falls back to unavailable when no known worktree owns the transcript cwd', () => {
expect(
resolveAiVaultSessionWorktreeInfo({
session: baseSession,
worktrees: [],
activeWorktreeId: null
})
).toMatchObject({
status: 'unavailable',
label: 'orca/src',
path: '/repo/orca/src'
})
})
it('matches WSL UNC worktree paths to Linux transcript cwd values', () => {
const worktree = makeWorktree({
path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca'
})
expect(
resolveAiVaultSessionWorktreeInfo({
session: { ...baseSession, cwd: '/home/ada/orca/src' },
worktrees: [worktree],
activeWorktreeId: null
})
).toMatchObject({
status: 'active',
label: 'orca',
path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\orca'
})
})
})
describe('canJumpToAiVaultSessionWorktree', () => {
it('allows current and active worktree targets', () => {
expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('current'))).toBe(true)
expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('active'))).toBe(true)
})
it('disables jump targets that are not active worktrees', () => {
expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('archived'))).toBe(false)
expect(canJumpToAiVaultSessionWorktree(makeWorktreeInfo('unavailable'))).toBe(false)
expect(canJumpToAiVaultSessionWorktree(null)).toBe(false)
})
})
describe('extractWorktreePathFromSessionTitle', () => {
it('reads worktree paths embedded in session titles', () => {
expect(
extractWorktreePathFromSessionTitle(
'Inspect PR #6229 - Worktree: /Users/ada/projects/orca/fix-tabs'
)
).toBe('/Users/ada/projects/orca/fix-tabs')
expect(extractWorktreePathFromSessionTitle('Worktree: /tmp/orca-worker')).toBe(
'/tmp/orca-worker'
)
})
})
describe('resolveAiVaultSessionWorktreeDisplay', () => {
it('falls back to title and branch when cwd is missing', () => {
expect(
resolveAiVaultSessionWorktreeDisplay({
session: {
...baseSession,
cwd: null,
branch: null,
title: 'Fix tabs - Worktree: /Users/ada/projects/orca/fix-tabs'
},
worktrees: [makeWorktree()],
activeWorktreeId: null
})?.path
).toBe('/Users/ada/projects/orca/fix-tabs')
expect(
resolveAiVaultSessionWorktreeDisplay({
session: { ...baseSession, cwd: null, branch: 'chinese-translation-improvement' },
worktrees: [makeWorktree()],
activeWorktreeId: null
})?.label
).toBe('chinese-translation-improvement')
})
})
describe('aiVaultWorktreeCompactPath', () => {
it('keeps the last two path segments for dense detail rows', () => {
expect(aiVaultWorktreeCompactPath('/Users/ada/projects/orca/improve-agent-session')).toBe(
'orca/improve-agent-session'
)
})
})
describe('shouldShowAiVaultWorktreeStatusBadge', () => {
it('hides the generic active badge but keeps meaningful states', () => {
expect(shouldShowAiVaultWorktreeStatusBadge('active')).toBe(false)
expect(shouldShowAiVaultWorktreeStatusBadge('current')).toBe(true)
expect(shouldShowAiVaultWorktreeStatusBadge('archived')).toBe(true)
expect(shouldShowAiVaultWorktreeStatusBadge('unavailable')).toBe(true)
})
})
describe('aiVaultWorktreeJumpTooltip', () => {
it('explains active jump targets and disabled states', () => {
expect(aiVaultWorktreeJumpTooltip(makeWorktreeInfo('active'))).toBe('Jump to Worktree')
expect(aiVaultWorktreeJumpTooltip(makeWorktreeInfo('archived'))).toBe(
'This session is in an archived worktree.'
)
expect(aiVaultWorktreeJumpTooltip(makeWorktreeInfo('unavailable'))).toBe(
'No active worktree matches this session.'
)
expect(aiVaultWorktreeJumpTooltip(null)).toBe('No worktree was recorded for this session.')
})
})
function makeWorktreeInfo(
status: AiVaultSessionWorktreeInfo['status']
): AiVaultSessionWorktreeInfo {
return {
status,
label: 'orca',
path: '/repo/orca',
...(status === 'unavailable' ? {} : { worktreeId: 'repo-1::/repo/orca' })
}
}
@@ -0,0 +1,289 @@
import { useMemo } from 'react'
import { parseWslUncPath } from '../../../../shared/wsl-paths'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
import {
isPathInsideOrEqual,
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
normalizeRuntimePathSeparators
} from '../../../../shared/cross-platform-path'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Worktree } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable'
export type AiVaultSessionWorktreeInfo = {
status: AiVaultSessionWorktreeStatus
label: string
path: string
worktreeId?: string
}
type WorktreeCandidate = {
worktree: Worktree
path: string
status: Exclude<AiVaultSessionWorktreeStatus, 'current'>
source: 'current-path' | 'prior-path'
}
export function resolveAiVaultSessionWorktreeInfo({
session,
worktrees,
activeWorktreeId
}: {
session: AiVaultSession
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): AiVaultSessionWorktreeInfo | null {
if (!session.cwd) {
return null
}
const candidates = buildWorktreeCandidates(worktrees)
.filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!))
.sort(compareWorktreeCandidates)
const best = candidates[0]
if (!best) {
return {
status: 'unavailable',
label: compactPathLabel(session.cwd),
path: session.cwd
}
}
const status =
best.worktree.id === activeWorktreeId
? 'current'
: best.worktree.isArchived
? 'archived'
: best.status
return {
status,
label: best.worktree.displayName || compactPathLabel(best.path),
path: best.path,
worktreeId: best.worktree.id
}
}
export function extractWorktreePathFromSessionTitle(title: string): string | null {
const trimmed = title.trim()
if (!trimmed) {
return null
}
const suffixMatch = trimmed.match(/\s-\s*Worktree:\s*(.+)$/i)
if (suffixMatch?.[1]) {
return suffixMatch[1].trim()
}
const inlineMatch = trimmed.match(/\bWorktree:\s*(.+)$/i)
return inlineMatch?.[1]?.trim() ?? null
}
export function resolveAiVaultSessionWorktreeDisplay(args: {
session: AiVaultSession
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): AiVaultSessionWorktreeInfo | null {
const resolved = resolveAiVaultSessionWorktreeInfo(args)
if (resolved) {
return resolved
}
const cwd = args.session.cwd?.trim()
if (cwd) {
return unavailableWorktreeInfo(cwd)
}
const titlePath = extractWorktreePathFromSessionTitle(args.session.title)
if (titlePath) {
return unavailableWorktreeInfo(titlePath)
}
const branch = args.session.branch?.trim()
if (branch) {
return {
status: 'unavailable',
label: branch,
path: branch
}
}
return null
}
export function useAiVaultSessionWorktreeMap({
sessions,
worktrees,
activeWorktreeId
}: {
sessions: readonly AiVaultSession[]
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): ReadonlyMap<string, AiVaultSessionWorktreeInfo> {
return useMemo(
() =>
new Map(
sessions.flatMap((session) => {
const worktreeInfo = resolveAiVaultSessionWorktreeDisplay({
session,
worktrees,
activeWorktreeId
})
return worktreeInfo ? [[session.id, worktreeInfo] as const] : []
})
),
[activeWorktreeId, sessions, worktrees]
)
}
export function canJumpToAiVaultSessionWorktree(
worktreeInfo: AiVaultSessionWorktreeInfo | null
): boolean {
return Boolean(
worktreeInfo?.worktreeId &&
worktreeInfo.status !== 'archived' &&
worktreeInfo.status !== 'unavailable'
)
}
export function aiVaultWorktreeJumpTooltip(
worktreeInfo: AiVaultSessionWorktreeInfo | null
): string {
if (canJumpToAiVaultSessionWorktree(worktreeInfo)) {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.jumpToWorktree',
'Jump to Worktree'
)
}
if (!worktreeInfo) {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.noRecordedWorktree',
'No worktree was recorded for this session.'
)
}
if (worktreeInfo.status === 'archived') {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.archivedJumpUnavailable',
'This session is in an archived worktree.'
)
}
if (worktreeInfo.status === 'unavailable') {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeMatch',
'No active worktree matches this session.'
)
}
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.noActiveWorktreeTarget',
'No active worktree is available.'
)
}
function buildWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] {
const candidates: WorktreeCandidate[] = []
for (const worktree of worktrees) {
if (hasUsablePath(worktree.path)) {
candidates.push({
worktree,
path: worktree.path,
status: worktree.isArchived ? 'archived' : 'active',
source: 'current-path'
})
}
for (const priorWorktreeId of worktree.priorWorktreeIds ?? []) {
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
if (!parsed || parsed.repoId !== worktree.repoId || !hasUsablePath(parsed.worktreePath)) {
continue
}
candidates.push({
worktree,
path: parsed.worktreePath,
status: worktree.isArchived ? 'archived' : 'active',
source: 'prior-path'
})
}
}
return candidates
}
function hasUsablePath(pathValue: string): boolean {
const trimmed = pathValue.trim()
return Boolean(trimmed && isRuntimePathAbsolute(trimmed))
}
function isSessionInWorktreePath(worktreePath: string, sessionCwd: string): boolean {
if (isPathInsideOrEqual(worktreePath, sessionCwd)) {
return true
}
const wslPath = parseWslUncPath(worktreePath)
return wslPath ? isPathInsideOrEqual(wslPath.linuxPath, sessionCwd) : false
}
function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandidate): number {
const lengthDifference =
normalizeRuntimePathForComparison(right.path).length -
normalizeRuntimePathForComparison(left.path).length
if (lengthDifference !== 0) {
return lengthDifference
}
if (left.source === right.source) {
return 0
}
return left.source === 'current-path' ? -1 : 1
}
export function aiVaultWorktreeCompactPath(pathValue: string): string {
const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean)
if (parts.length >= 2) {
return parts.slice(-2).join('/')
}
return parts[0] ?? pathValue
}
export function shouldShowAiVaultWorktreeStatusBadge(
status: AiVaultSessionWorktreeStatus
): boolean {
// Why: "active" repeats the branch label without adding scan value in dense rows.
return status !== 'active'
}
function unavailableWorktreeInfo(pathValue: string): AiVaultSessionWorktreeInfo {
return {
status: 'unavailable',
label: compactPathLabel(pathValue),
path: pathValue
}
}
function compactPathLabel(pathValue: string): string {
return aiVaultWorktreeCompactPath(pathValue)
}
export function aiVaultWorktreeStatusLabel(status: AiVaultSessionWorktreeStatus): string {
if (status === 'current') {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.currentWorktree',
'Current worktree'
)
}
if (status === 'active') {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.activeWorktree',
'Active worktree'
)
}
if (status === 'archived') {
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.archivedWorktree',
'Archived worktree'
)
}
return translate(
'auto.components.right.sidebar.AiVaultSessionWorktree.unavailableWorktree',
'Unavailable worktree'
)
}
@@ -6,7 +6,7 @@ import {
} from '../sidebar/worktree-list-virtual-rows'
export const VAULT_GROUP_HEADER_ROW_HEIGHT = 32
export const VAULT_SESSION_ROW_HEIGHT = 82
export const VAULT_SESSION_ROW_HEIGHT = 98
export type VaultVirtualRow = { type: 'group' | 'session' }
@@ -9,7 +9,7 @@ export { getMobileSettingsPaneSearchEntries }
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
const ORCA_ANDROID_APK_URL =
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.15/app-release.apk'
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk'
export function MobileSettingsPane(): React.JSX.Element {
return (
+23 -3
View File
@@ -9182,7 +9182,9 @@
"noAgentSessionsFound": "No agent sessions found",
"noSessionsMatchFilters": "No sessions match the current filters",
"sessionId": "Session ID",
"logPath": "Log path"
"logPath": "Log path",
"originalPaneUnavailable": "Original pane is no longer available.",
"worktreeUnavailable": "Worktree is no longer available."
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
@@ -9235,6 +9237,8 @@
"resumeCommand": "Resume command",
"sessionActions": "{{value0}} session actions",
"resumeInNewTab": "Resume in New Tab",
"resumeInWorktree": "Resume in Worktree",
"viewLog": "View Log",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
@@ -9258,7 +9262,10 @@
"agentRole": "Agent",
"toolRole": "Tool",
"systemRole": "System",
"sessionRole": "Session"
"sessionRole": "Session",
"jumpToOriginalPane": "Jump to Original Pane",
"worktree": "Worktree",
"jumpToWorktree": "Jump to Worktree"
},
"AiVaultSessionRow": {
"noPreviewAvailable": "No conversation preview available",
@@ -9282,7 +9289,9 @@
"agentRole": "Agent",
"toolRole": "Tool",
"systemRole": "System",
"sessionRole": "Session"
"sessionRole": "Session",
"jumpToOriginalPane": "Jump to Original Pane",
"jumpToWorktree": "Jump to Worktree"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "Find files",
@@ -9326,6 +9335,17 @@
"copyCommand": "Copy command"
}
}
},
"AiVaultSessionWorktree": {
"currentWorktree": "Current worktree",
"activeWorktree": "Active worktree",
"archivedWorktree": "Archived worktree",
"unavailableWorktree": "Unavailable worktree",
"jumpToWorktree": "Jump to Worktree",
"noRecordedWorktree": "No worktree was recorded for this session.",
"archivedJumpUnavailable": "This session is in an archived worktree.",
"noActiveWorktreeMatch": "No active worktree matches this session.",
"noActiveWorktreeTarget": "No active worktree is available."
}
}
},
+74 -54
View File
@@ -9182,7 +9182,9 @@
"sessionId": "ID de sesión",
"logPath": "Ruta del registro",
"agents": "Agentes",
"sessionsShownCompact": "{{value0}} mostradas"
"sessionsShownCompact": "{{value0}} mostradas",
"originalPaneUnavailable": "Original pane is no longer available.",
"worktreeUnavailable": "Worktree is no longer available."
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
@@ -9214,41 +9216,43 @@
"project": "Proyecto"
},
"AiVaultSessionDetails": {
"updated": "Updated",
"created": "Created",
"workingDir": "Working dir",
"unknownLocation": "Unknown location",
"updated": "Actualizado",
"created": "Creado",
"workingDir": "Dir. de trabajo",
"unknownLocation": "Ubicación desconocida",
"branch": "Rama",
"model": "Modelo",
"usage": "Usage",
"usageValue": "{{value0}} msgs{{value1}}",
"usage": "Uso",
"usageValue": "{{value0}} msjs{{value1}}",
"tokenSuffix": " · {{value0}} tok",
"session": "Session",
"copyDetailValue": "Copy {{value0}}",
"latestLog": "Latest log",
"noReadablePreview": "No readable message preview in this transcript.",
"resumeCommand": "Resume command",
"sessionActions": "{{value0}} session actions",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"unknownTime": "Unknown time",
"session": "Sesión",
"copyDetailValue": "Copiar {{value0}}",
"latestLog": "Último registro",
"noReadablePreview": "No hay vista previa de mensajes legible en esta transcripción.",
"resumeCommand": "Comando para reanudar",
"sessionActions": "Acciones de sesión {{value0}}",
"resumeInNewTab": "Reanudar en nueva pestaña",
"resumeInWorktree": "Reanudar en árbol de trabajo",
"viewLog": "Ver registro",
"copyResumeCommand": "Copiar comando para reanudar",
"openLog": "Abrir registro",
"revealLog": "Mostrar registro",
"openWorkingDirectory": "Abrir directorio de trabajo",
"copySessionId": "Copiar ID de sesión",
"copyLogPath": "Copiar ruta de registro",
"unknownTime": "Hora desconocida",
"unknown": "Desconocido",
"user": "User",
"assistant": "Assistant",
"tool": "Tool",
"system": "System",
"log": "Log",
"justNow": "Just now",
"minutesAgo": "{{value0}}m ago",
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago",
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago",
"user": "Usuario",
"assistant": "Asistente",
"tool": "Herramienta",
"system": "Sistema",
"log": "Registro",
"justNow": "Ahora mismo",
"minutesAgo": "hace {{value0}}m",
"hoursAgo": "hace {{value0}}h",
"daysAgo": "hace {{value0}}d",
"monthsAgo": "hace {{value0}}mo",
"yearsAgo": "hace {{value0}}a",
"sessionId": "ID de sesión",
"originalAsk": "Solicitud original",
"latestTurns": "Últimos turnos",
@@ -9258,18 +9262,21 @@
"agentRole": "Agente",
"toolRole": "Herramienta",
"systemRole": "Sistema",
"sessionRole": "Sesión"
"sessionRole": "Sesión",
"jumpToOriginalPane": "Saltar al panel original",
"worktree": "Árbol de trabajo",
"jumpToWorktree": "Saltar al árbol de trabajo"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"resumeAgentSession": "Reanudar sesión {{value0}}",
"resumeInNewTab": "Reanudar en nueva pestaña",
"copyResumeCommand": "Copiar comando para reanudar",
"openLog": "Abrir registro",
"revealLog": "Mostrar registro",
"openWorkingDirectory": "Abrir directorio de trabajo",
"copySessionId": "Copiar ID de sesión",
"copyLogPath": "Copiar ruta de registro",
"messageCount": "{{value0}} msjs",
"tokenCount": "{{value0}} tok",
"toggleSessionDetails": "Detalles de sesión de {{value0}}",
"hideDetails": "Ocultar detalles",
@@ -9282,7 +9289,9 @@
"agentRole": "Agente",
"toolRole": "Herramienta",
"systemRole": "Sistema",
"sessionRole": "Sesión"
"sessionRole": "Sesión",
"jumpToOriginalPane": "Saltar al panel original",
"jumpToWorktree": "Saltar al árbol de trabajo"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "Buscar archivos",
@@ -9312,20 +9321,31 @@
"pull": {
"policy": {
"notice": {
"merge": "Merge",
"mergeDescription": "Create a merge commit when local and remote both changed.",
"rebase": "Rebase",
"rebaseDescription": "Replay local commits on top of the remote branch.",
"fastForwardOnly": "Fast-forward only",
"fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
"title": "Pull needs a policy",
"diverged": "Diverged",
"body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
"copyAria": "Copy {{value0}} pull policy command",
"copied": "Copied",
"merge": "Fusionar (Merge)",
"mergeDescription": "Crear un commit de fusión cuando tanto el repositorio local como el remoto hayan cambiado.",
"rebase": "Reorganizar (Rebase)",
"rebaseDescription": "Volver a aplicar los commits locales sobre la rama remota.",
"fastForwardOnly": "Solo avance rápido (Fast-forward)",
"fastForwardOnlyDescription": "Solo realizar pull cuando no se requiera fusión ni rebase.",
"title": "El pull requiere una política",
"diverged": "Divergido",
"body": "Esta rama tiene commits locales y remotos. Ejecute un comando en este árbol de trabajo o en el host SSH, luego intente Pull o Sincronizar de nuevo.",
"copyAria": "Copiar el comando de política de pull {{value0}}",
"copied": "Copiado",
"copyCommand": "Copiar comando"
}
}
},
"AiVaultSessionWorktree": {
"currentWorktree": "Árbol de trabajo actual",
"activeWorktree": "Árbol de trabajo activo",
"archivedWorktree": "Árbol de trabajo archivado",
"unavailableWorktree": "Árbol de trabajo no disponible",
"jumpToWorktree": "Saltar al árbol de trabajo",
"noRecordedWorktree": "No se registró ningún árbol de trabajo para esta sesión.",
"archivedJumpUnavailable": "Esta sesión se encuentra en un árbol de trabajo archivado.",
"noActiveWorktreeMatch": "Ningún árbol de trabajo activo coincide con esta sesión.",
"noActiveWorktreeTarget": "No hay ningún árbol de trabajo activo disponible."
}
}
},
+75 -55
View File
@@ -9182,7 +9182,9 @@
"sessionId": "セッション ID",
"logPath": "ログパス",
"agents": "エージェント",
"sessionsShownCompact": "{{value0}} 件表示"
"sessionsShownCompact": "{{value0}} 件表示",
"originalPaneUnavailable": "元のペインは利用できなくなりました。",
"worktreeUnavailable": "ワークツリーは利用できなくなりました。"
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
@@ -9214,41 +9216,43 @@
"project": "プロジェクト"
},
"AiVaultSessionDetails": {
"updated": "Updated",
"created": "Created",
"workingDir": "Working dir",
"unknownLocation": "Unknown location",
"updated": "更新日時",
"created": "作成日時",
"workingDir": "作業ディレクトリ",
"unknownLocation": "不明な場所",
"branch": "ブランチ",
"model": "モデル",
"usage": "Usage",
"usageValue": "{{value0}} msgs{{value1}}",
"usage": "使用量",
"usageValue": "{{value0}} 件のメッセージ{{value1}}",
"tokenSuffix": " · {{value0}} tok",
"session": "Session",
"copyDetailValue": "Copy {{value0}}",
"latestLog": "Latest log",
"noReadablePreview": "No readable message preview in this transcript.",
"resumeCommand": "Resume command",
"sessionActions": "{{value0}} session actions",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"unknownTime": "Unknown time",
"session": "セッション",
"copyDetailValue": "{{value0}}をコピー",
"latestLog": "最新のログ",
"noReadablePreview": "この履歴には読み取り可能なメッセージプレビューがありません。",
"resumeCommand": "再開コマンド",
"sessionActions": "{{value0}} 件のセッション操作",
"resumeInNewTab": "新しいタブで再開",
"resumeInWorktree": "ワークツリーで再開",
"viewLog": "ログを表示",
"copyResumeCommand": "再開コマンドをコピー",
"openLog": "ログを開く",
"revealLog": "ログを表示",
"openWorkingDirectory": "作業ディレクトリを開く",
"copySessionId": "セッション ID をコピー",
"copyLogPath": "ログパスをコピー",
"unknownTime": "不明な時間",
"unknown": "不明",
"user": "User",
"assistant": "Assistant",
"tool": "Tool",
"system": "System",
"log": "Log",
"justNow": "Just now",
"minutesAgo": "{{value0}}m ago",
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago",
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago",
"user": "ユーザー",
"assistant": "アシスタント",
"tool": "ツール",
"system": "システム",
"log": "ログ",
"justNow": "たった今",
"minutesAgo": "{{value0}} 分前",
"hoursAgo": "{{value0}} 時間前",
"daysAgo": "{{value0}} 日前",
"monthsAgo": "{{value0}} か月前",
"yearsAgo": "{{value0}} 年前",
"sessionId": "セッション ID",
"originalAsk": "最初の依頼",
"latestTurns": "最新のやり取り",
@@ -9258,19 +9262,22 @@
"agentRole": "エージェント",
"toolRole": "ツール",
"systemRole": "システム",
"sessionRole": "セッション"
"sessionRole": "セッション",
"jumpToOriginalPane": "元のペインにジャンプ",
"worktree": "ワークツリー",
"jumpToWorktree": "ワークツリーにジャンプ"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"tokenCount": "{{value0}} tok",
"resumeAgentSession": "{{value0}} セッションを再開",
"resumeInNewTab": "新しいタブで再開",
"copyResumeCommand": "再開コマンドをコピー",
"openLog": "ログを開く",
"revealLog": "ログを表示",
"openWorkingDirectory": "作業ディレクトリを開く",
"copySessionId": "セッション ID をコピー",
"copyLogPath": "ログパスをコピー",
"messageCount": "{{value0}} 件のメッセージ",
"tokenCount": "{{value0}} トークン",
"toggleSessionDetails": "{{value0}} のセッション詳細",
"hideDetails": "詳細を非表示",
"showDetails": "詳細を表示",
@@ -9282,7 +9289,9 @@
"agentRole": "エージェント",
"toolRole": "ツール",
"systemRole": "システム",
"sessionRole": "セッション"
"sessionRole": "セッション",
"jumpToOriginalPane": "元のペインにジャンプ",
"jumpToWorktree": "ワークツリーにジャンプ"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "ファイルを検索",
@@ -9312,20 +9321,31 @@
"pull": {
"policy": {
"notice": {
"merge": "Merge",
"mergeDescription": "Create a merge commit when local and remote both changed.",
"rebase": "Rebase",
"rebaseDescription": "Replay local commits on top of the remote branch.",
"fastForwardOnly": "Fast-forward only",
"fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
"title": "Pull needs a policy",
"diverged": "Diverged",
"body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
"copyAria": "Copy {{value0}} pull policy command",
"copied": "Copied",
"merge": "マージ (Merge)",
"mergeDescription": "ローカルとリモートの両方が変更された場合にマージコミットを作成します。",
"rebase": "リベース (Rebase)",
"rebaseDescription": "ローカルのコミットをリモートブランチの上に再適用します。",
"fastForwardOnly": "ファストフォワードのみ",
"fastForwardOnlyDescription": "マージやリベースが必要ない場合のみプルします。",
"title": "プルのポリシー設定が必要です",
"diverged": "分岐が分かれています",
"body": "このブランチにはローカルとリモートのコミットがあります。このワークツリーまたは SSH ホストでコマンドを 1 つ実行してから、プルまたは同期を再試行してください。",
"copyAria": "{{value0}} のプルポリシーコマンドをコピー",
"copied": "コピーしました",
"copyCommand": "コマンドをコピー"
}
}
},
"AiVaultSessionWorktree": {
"currentWorktree": "現在のワークツリー",
"activeWorktree": "アクティブなワークツリー",
"archivedWorktree": "アーカイブされたワークツリー",
"unavailableWorktree": "利用不可なワークツリー",
"jumpToWorktree": "ワークツリーにジャンプ",
"noRecordedWorktree": "このセッションに記録されたワークツリーはありません。",
"archivedJumpUnavailable": "このセッションはアーカイブされたワークツリーにあります。",
"noActiveWorktreeMatch": "このセッションに一致するアクティブなワークツリーはありません。",
"noActiveWorktreeTarget": "利用可能なアクティブなワークツリーはありません。"
}
}
},
+28 -8
View File
@@ -9182,7 +9182,9 @@
"sessionId": "세션 ID",
"logPath": "로그 경로",
"agents": "에이전트",
"sessionsShownCompact": "{{value0}} 표시됨"
"sessionsShownCompact": "{{value0}} 표시됨",
"originalPaneUnavailable": "원래 창은 더 이상 사용할 수 없습니다.",
"worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다."
},
"AiVaultPanelControls": {
"scanningSessions": "세션 스캔 중",
@@ -9230,6 +9232,8 @@
"resumeCommand": "재개 명령",
"sessionActions": "{{value0}} 세션 작업",
"resumeInNewTab": "새 탭에서 재개",
"resumeInWorktree": "워크트리에서 재개",
"viewLog": "로그 보기",
"copyResumeCommand": "재개 명령 복사",
"openLog": "로그 열기",
"revealLog": "로그 위치 열기",
@@ -9238,10 +9242,10 @@
"copyLogPath": "로그 경로 복사",
"unknownTime": "알 수 없는 시간",
"unknown": "알 수 없음",
"user": "User",
"assistant": "Assistant",
"tool": "Tool",
"system": "System",
"user": "사용자",
"assistant": "어시스턴트",
"tool": "도구",
"system": "시스템",
"log": "로그",
"justNow": "방금 전",
"minutesAgo": "{{value0}}분 전",
@@ -9258,7 +9262,10 @@
"agentRole": "에이전트",
"toolRole": "도구",
"systemRole": "시스템",
"sessionRole": "세션"
"sessionRole": "세션",
"jumpToOriginalPane": "원래 창으로 이동",
"worktree": "작업 트리",
"jumpToWorktree": "작업 트리로 이동"
},
"AiVaultSessionRow": {
"resumeAgentSession": "{{value0}} 세션 재개",
@@ -9282,7 +9289,9 @@
"agentRole": "에이전트",
"toolRole": "도구",
"systemRole": "시스템",
"sessionRole": "세션"
"sessionRole": "세션",
"jumpToOriginalPane": "원래 창으로 이동",
"jumpToWorktree": "작업 트리로 이동"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "파일 찾기",
@@ -9322,10 +9331,21 @@
"diverged": "Diverged",
"body": "이 브랜치에는 로컬 commits과 원격 commits이 모두 있습니다. 이 워크트리 또는 SSH 호스트에서 명령 하나를 실행한 다음 풀 또는 동기화를 다시 시도하세요.",
"copyAria": "{{value0}} pull 정책 명령 복사",
"copied": "Copied",
"copied": "복사됨",
"copyCommand": "명령 복사"
}
}
},
"AiVaultSessionWorktree": {
"currentWorktree": "현재 작업 트리",
"activeWorktree": "활성 작업 트리",
"archivedWorktree": "보관된 작업 트리",
"unavailableWorktree": "사용할 수 없는 작업 트리",
"jumpToWorktree": "작업 트리로 이동",
"noRecordedWorktree": "이 세션에 기록된 작업 트리가 없습니다.",
"archivedJumpUnavailable": "이 세션은 보관된 작업 트리에 있습니다.",
"noActiveWorktreeMatch": "이 세션과 일치하는 활성 작업 트리가 없습니다.",
"noActiveWorktreeTarget": "사용 가능한 활성 작업 트리가 없습니다."
}
}
},
+32 -12
View File
@@ -9182,7 +9182,9 @@
"sessionId": "会话 ID",
"logPath": "日志路径",
"agents": "智能体",
"sessionsShownCompact": "已显示 {{value0}} 个"
"sessionsShownCompact": "已显示 {{value0}} 个",
"originalPaneUnavailable": "Original pane is no longer available.",
"worktreeUnavailable": "Worktree is no longer available."
},
"AiVaultPanelControls": {
"scanningSessions": "正在扫描会话",
@@ -9230,6 +9232,8 @@
"resumeCommand": "恢复命令",
"sessionActions": "{{value0}} 会话操作",
"resumeInNewTab": "在新标签页中恢复",
"resumeInWorktree": "在工作树中恢复",
"viewLog": "查看日志",
"copyResumeCommand": "复制恢复命令",
"openLog": "打开日志",
"revealLog": "显示日志",
@@ -9258,7 +9262,10 @@
"agentRole": "智能体",
"toolRole": "工具",
"systemRole": "系统",
"sessionRole": "会话"
"sessionRole": "会话",
"jumpToOriginalPane": "跳转到原始窗格",
"worktree": "工作树",
"jumpToWorktree": "跳转到工作树"
},
"AiVaultSessionRow": {
"resumeAgentSession": "恢复 {{value0}} 会话",
@@ -9282,7 +9289,9 @@
"agentRole": "智能体",
"toolRole": "工具",
"systemRole": "系统",
"sessionRole": "会话"
"sessionRole": "会话",
"jumpToOriginalPane": "跳转到原始窗格",
"jumpToWorktree": "跳转到工作树"
},
"FileExplorerNameFilter": {
"26fb73c6e3": "查找文件",
@@ -9312,20 +9321,31 @@
"pull": {
"policy": {
"notice": {
"merge": "合并",
"mergeDescription": "当本地和远程都有更改时创建合并提交。",
"rebase": "变基",
"rebaseDescription": "在远程分支之上重放本地提交。",
"fastForwardOnly": "仅快进",
"fastForwardOnlyDescription": "仅在不需要合并或变基时拉取。",
"merge": "合并 (Merge)",
"mergeDescription": "当本地和远程都发生更改时,创建一个合并提交。",
"rebase": "变基 (Rebase)",
"rebaseDescription": "在远程分支之上重新应用本地提交。",
"fastForwardOnly": "仅快进 (Fast-forward)",
"fastForwardOnlyDescription": "仅在不需要合并或变基时进行拉取。",
"title": "拉取需要策略",
"diverged": "已分叉",
"body": "此分支同时存在本地和远程提交。请在此工作树或 SSH 主机中运行一个命令,然后再次尝试拉取或同步。",
"copyAria": "Copy {{value0}} pull policy command",
"diverged": "分支已分叉",
"body": "此分支具有本地和远程提交。在此工作树或 SSH 主机上运行一个命令,然后重新尝试拉取或同步。",
"copyAria": "复制 {{value0}} 拉取策略命令",
"copied": "已复制",
"copyCommand": "复制命令"
}
}
},
"AiVaultSessionWorktree": {
"currentWorktree": "当前工作树",
"activeWorktree": "活动工作树",
"archivedWorktree": "已归档工作树",
"unavailableWorktree": "不可用的工作树",
"jumpToWorktree": "跳转到工作树",
"noRecordedWorktree": "此会话没有记录工作树。",
"archivedJumpUnavailable": "此会话位于已归档的工作树中。",
"noActiveWorktreeMatch": "没有与此会话匹配的活动工作树。",
"noActiveWorktreeTarget": "没有可用的活动工作树。"
}
}
},