mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
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:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "利用可能なアクティブなワークツリーはありません。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "사용 가능한 활성 작업 트리가 없습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "没有可用的活动工作树。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user