From f55154143bf0273219a70f31437af3652db99bb9 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:17:57 -0700 Subject: [PATCH 1/4] Update mobile Android APK links Point mobile Android APK links at the published mobile-android-v0.0.16 release. --- README.md | 4 ++-- docs/readme/README.es.md | 4 ++-- docs/readme/README.ja.md | 4 ++-- docs/readme/README.ko.md | 4 ++-- docs/readme/README.zh-CN.md | 4 ++-- src/renderer/src/components/mobile/mobile-platform-copy.ts | 2 +- src/renderer/src/components/settings/MobileSettingsPane.tsx | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e72cdfd1ee1..eea0b479af1 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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) --- diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 4ef58db2856..cb78aa38095 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -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) @@ -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) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 6f5e62ae72f..e2d93abd1f8 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -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) @@ -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) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index f4526d4d89c..4a63f512b9f 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -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) @@ -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) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 5babfc4b531..b3e97dd77cf 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -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) @@ -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) --- diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index a5a3d5660ed..1539a590535 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -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' } } diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index b5a5d850c1a..016409893a8 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -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 ( From 9f0770da2b770392890a8553ad54d0abcc941499 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:28:13 -0700 Subject: [PATCH 2/4] Enhance AI Vault sessions with worktree resume and jump actions (#6338) - Add support for resuming agent sessions in their corresponding worktree instead of defaulting exclusively to the active workspace. - Implement "Jump to Original Pane" and "Jump to Worktree" actions to quickly navigate to the session's workspace or terminal pane. - Display worktree metadata and statuses (current, active, archived, or unavailable) on session cards and inline details. - Refactor launch actions, original pane lookup, and worktree mapping into dedicated modules with comprehensive unit tests. - Update internationalization files for all supported locales. --- .../components/right-sidebar/AiVaultPanel.tsx | 127 +++----- .../right-sidebar/AiVaultSessionDetails.tsx | 270 +++++++++++----- .../right-sidebar/AiVaultSessionRow.tsx | 199 +++++++++--- .../AiVaultSessionVirtualList.tsx | 101 +++++- .../SessionRowTrailingActions.tsx | 124 +++++++- .../ai-vault-original-pane-actions.ts | 74 +++++ .../ai-vault-original-pane.test.ts | 213 +++++++++++++ .../right-sidebar/ai-vault-original-pane.ts | 262 ++++++++++++++++ .../ai-vault-session-display.test.ts | 19 ++ .../right-sidebar/ai-vault-session-display.ts | 52 ++++ .../ai-vault-session-launch-actions.ts | 113 +++++++ .../ai-vault-session-resume.test.ts | 175 +++++++++++ .../right-sidebar/ai-vault-session-resume.ts | 140 +++++++++ .../ai-vault-session-worktree.test.ts | 229 ++++++++++++++ .../ai-vault-session-worktree.ts | 289 ++++++++++++++++++ .../right-sidebar/ai-vault-virtual-rows.ts | 2 +- src/renderer/src/i18n/locales/en.json | 26 +- src/renderer/src/i18n/locales/es.json | 128 ++++---- src/renderer/src/i18n/locales/ja.json | 130 ++++---- src/renderer/src/i18n/locales/ko.json | 36 ++- src/renderer/src/i18n/locales/zh.json | 44 ++- .../lib/project-host-workspace-target.test.ts | 5 +- 22 files changed, 2395 insertions(+), 363 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 77036fb5eac..5b00eaace39 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -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(DEFAULT_AI_VAULT_SCOPE) const [sort, setSort] = useState('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 => { - 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 => { 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, diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 387bc877f7b..58923a53479 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -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 (
event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} @@ -36,83 +50,88 @@ export function SessionInlineDetails({ event.stopPropagation() }} > -
- -
- {session.title} -
-
- +
} label={translate( 'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns', 'Latest turns' )} > - {recentTurns.length > 0 ? ( -
- {recentTurns.map((turn, index) => ( -
0 ? ( +
+ {detailTurns.map((turn, index) => ( + - {conversationRoleLabel(turn.role)} - {turn.text} -
+ role={turn.role} + text={turn.text} + /> ))}
) : ( -
- {translate( + + /> )} -
- - - - {agentLabel(session.agent)} - · - - {translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.messageCount', - '{{value0}} msgs', - { value0: session.messageCount } + {worktreeDisplay ? ( + } + label={translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.worktree', + 'Worktree' )} - - · - -
+ > + + + ) : null}
-
- +
+ {showResumeInWorktree ? ( + + ) : null} + {showResumeInNewTab ? ( + + ) : null}
@@ -136,18 +152,110 @@ export function SessionInlineDetails({ } function SessionReceiptSection({ + icon, label, children }: { + icon: React.ReactNode label: string children: React.ReactNode }): React.JSX.Element { return ( -
-
- {label} +
+
+ {icon} + {label}
{children} +
+ ) +} + +function ConversationTurnCard({ + role, + text +}: { + role: AiVaultSession['previewMessages'][number]['role'] + text: string +}): React.JSX.Element { + const isUserTurn = role === 'user' + + return ( +
+
+ {conversationRoleLabel(role)} +
+

+ {text} +

+
+ ) +} + +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 ( +
+
+ {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( + <> + + {aiVaultWorktreeStatusLabel(worktreeInfo.status)} + + · + + ) : null} + + {worktreeInfo.label} + +
+ {showPathLine ? ( + + ) : null} +
+ ) +} + +function WorktreePathHint({ + compactPath, + fullPath +}: { + compactPath: string + fullPath: string +}): React.JSX.Element { + return ( + + +
+ {compactPath} +
+
+ + {fullPath} + +
+ ) +} + +function SessionDetailEmptyState({ message }: { message: string }): React.JSX.Element { + return ( +
+ {message}
) } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index e63479ee269..eab39c0f2d3 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -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({
{ onToggleDetails() }} @@ -89,7 +117,7 @@ export function VaultSessionRow({
{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} />
-
- {latestTurn ? ( - <> - - {conversationRoleLabel(latestTurn.role)} - - : {latestTurn.text} - - ) : ( - translate( - 'auto.components.right.sidebar.AiVaultSessionRow.noPreviewAvailable', - 'No conversation preview available' - ) - )} -
- + {detailsExpanded && worktreeInfo ? ( +
+ +
+ ) : null} + {!detailsExpanded ? ( + <> +
+ {latestTurn ? ( + <> + + {conversationRoleLabel(latestTurn.role)} + + : {latestTurn.text} + + ) : ( + translate( + 'auto.components.right.sidebar.AiVaultSessionRow.noPreviewAvailable', + 'No conversation preview available' + ) + )} +
+ + + ) : null} {detailsExpanded ? ( ) : null}
@@ -142,6 +189,9 @@ export function VaultSessionRow({ void + onJumpToOriginalPane?: () => void + onJumpToWorktree?: () => void onCopyResume: () => void onCopyId: () => void onCopyPath: () => void @@ -181,12 +237,25 @@ export function SessionActionMenuItems({ return ( <> + {onJumpToOriginalPane ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', + 'Jump to Original Pane' + )} + + ) : null} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToWorktree', + 'Jump to Worktree' + )} + - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab', - 'Resume in New Tab' - )} + {resumeLabel} @@ -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 ( -
- - - - {agentLabel(session.agent)} - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', - '{{value0}} msgs', - { value0: session.messageCount } - )} - - · - +
+
+ + + + {agentLabel(session.agent)} + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', + '{{value0}} msgs', + { value0: session.messageCount } + )} + + · + +
+ {worktreeInfo ? : null}
) } +function SessionWorktreeLine({ + worktreeInfo +}: { + worktreeInfo: AiVaultSessionWorktreeInfo +}): React.JSX.Element { + const repoId = worktreeInfo.worktreeId + ? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null) + : null + const repo = useRepoById(repoId) + + return ( +
+ {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( + + {worktreeStatusLabel(worktreeInfo.status)} + + ) : null} + + + +
+ ) +} + +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') diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 9a9cfdf7a9b..bac36ba8aa8 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -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 expandedSessionIds: ReadonlySet - 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 (
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)} diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 144f1a3d98f..f0552b3fab3 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -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({ )} + {onJumpToOriginalPane ? ( + + + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.jumpToOriginalPane', + 'Jump to Original Pane' + )} + + + ) : null} + + + + {jumpToWorktreeTooltip} + + - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab', - 'Resume in New Tab' - )} + {resumeLabel}
@@ -219,7 +273,10 @@ export function SessionRowTrailingActions({ ) } + +function WorktreeJumpTooltipTrigger({ + disabled, + ariaLabel, + onJumpToWorktree +}: { + disabled: boolean + ariaLabel: string + onJumpToWorktree?: () => void +}): React.JSX.Element { + const button = ( + + ) + + if (!disabled) { + return {button} + } + + return ( + + event.stopPropagation()} + > + {button} + + + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts new file mode 100644 index 00000000000..8e6ebf5f947 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts @@ -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 + 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 } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts new file mode 100644 index 00000000000..cec8a30a270 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts @@ -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 = {}) { + return { + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + tabsByWorktree: { 'wt-1': [makeTab()] }, + terminalLayoutsByTabId: { 'tab-1': makeLayout() }, + ...overrides + } as never +} + +function makeEntry(overrides: Partial = {}): 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 { + 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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts new file mode 100644 index 00000000000..a878789891c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts @@ -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() + 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() + 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[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 +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts index 38d83f3efca..b341558c628 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts @@ -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' + ]) + }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts index 4a4940543f1..a4078616810 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts @@ -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) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts new file mode 100644 index 00000000000..a2d074b6b34 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -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> +}): { + buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup + copyResumeCommand: (session: AiVaultSession, worktreeId?: string | null) => Promise + 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 => { + 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 } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts new file mode 100644 index 00000000000..9c9e86b2990 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -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 { + 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') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts new file mode 100644 index 00000000000..32481998af0 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -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 +): 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' + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts new file mode 100644 index 00000000000..d1adbbb9a94 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -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 { + 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' }) + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts new file mode 100644 index 00000000000..142cfe164d6 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -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 + 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 { + 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' + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-virtual-rows.ts b/src/renderer/src/components/right-sidebar/ai-vault-virtual-rows.ts index 32b9d9bb47e..c92e3f1e64e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-virtual-rows.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-virtual-rows.ts @@ -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' } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 66aed969e3b..7dcc1c33231 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9170,7 +9170,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", @@ -9223,6 +9225,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", @@ -9246,7 +9250,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", @@ -9270,7 +9277,9 @@ "agentRole": "Agent", "toolRole": "Tool", "systemRole": "System", - "sessionRole": "Session" + "sessionRole": "Session", + "jumpToOriginalPane": "Jump to Original Pane", + "jumpToWorktree": "Jump to Worktree" }, "FileExplorerNameFilter": { "26fb73c6e3": "Find files", @@ -9314,6 +9323,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." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e91a3329eb8..232615452b7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9170,7 +9170,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", @@ -9202,41 +9204,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", @@ -9246,18 +9250,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", @@ -9270,7 +9277,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", @@ -9300,20 +9309,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." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6e9e5e0ffd4..b992fa00f60 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9170,7 +9170,9 @@ "sessionId": "セッション ID", "logPath": "ログパス", "agents": "エージェント", - "sessionsShownCompact": "{{value0}} 件表示" + "sessionsShownCompact": "{{value0}} 件表示", + "originalPaneUnavailable": "元のペインは利用できなくなりました。", + "worktreeUnavailable": "ワークツリーは利用できなくなりました。" }, "AiVaultPanelControls": { "scanningSessions": "Scanning sessions", @@ -9202,41 +9204,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": "最新のやり取り", @@ -9246,19 +9250,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": "詳細を表示", @@ -9270,7 +9277,9 @@ "agentRole": "エージェント", "toolRole": "ツール", "systemRole": "システム", - "sessionRole": "セッション" + "sessionRole": "セッション", + "jumpToOriginalPane": "元のペインにジャンプ", + "jumpToWorktree": "ワークツリーにジャンプ" }, "FileExplorerNameFilter": { "26fb73c6e3": "ファイルを検索", @@ -9300,20 +9309,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": "利用可能なアクティブなワークツリーはありません。" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e18f3c89a00..8181b7c7f23 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9170,7 +9170,9 @@ "sessionId": "세션 ID", "logPath": "로그 경로", "agents": "에이전트", - "sessionsShownCompact": "{{value0}} 표시됨" + "sessionsShownCompact": "{{value0}} 표시됨", + "originalPaneUnavailable": "원래 창은 더 이상 사용할 수 없습니다.", + "worktreeUnavailable": "워크트리는 더 이상 사용할 수 없습니다." }, "AiVaultPanelControls": { "scanningSessions": "세션 스캔 중", @@ -9218,6 +9220,8 @@ "resumeCommand": "재개 명령", "sessionActions": "{{value0}} 세션 작업", "resumeInNewTab": "새 탭에서 재개", + "resumeInWorktree": "워크트리에서 재개", + "viewLog": "로그 보기", "copyResumeCommand": "재개 명령 복사", "openLog": "로그 열기", "revealLog": "로그 위치 열기", @@ -9226,10 +9230,10 @@ "copyLogPath": "로그 경로 복사", "unknownTime": "알 수 없는 시간", "unknown": "알 수 없음", - "user": "User", - "assistant": "Assistant", - "tool": "Tool", - "system": "System", + "user": "사용자", + "assistant": "어시스턴트", + "tool": "도구", + "system": "시스템", "log": "로그", "justNow": "방금 전", "minutesAgo": "{{value0}}분 전", @@ -9246,7 +9250,10 @@ "agentRole": "에이전트", "toolRole": "도구", "systemRole": "시스템", - "sessionRole": "세션" + "sessionRole": "세션", + "jumpToOriginalPane": "원래 창으로 이동", + "worktree": "작업 트리", + "jumpToWorktree": "작업 트리로 이동" }, "AiVaultSessionRow": { "resumeAgentSession": "{{value0}} 세션 재개", @@ -9270,7 +9277,9 @@ "agentRole": "에이전트", "toolRole": "도구", "systemRole": "시스템", - "sessionRole": "세션" + "sessionRole": "세션", + "jumpToOriginalPane": "원래 창으로 이동", + "jumpToWorktree": "작업 트리로 이동" }, "FileExplorerNameFilter": { "26fb73c6e3": "파일 찾기", @@ -9310,10 +9319,21 @@ "diverged": "Diverged", "body": "이 브랜치에는 로컬 commits과 원격 commits이 모두 있습니다. 이 워크트리 또는 SSH 호스트에서 명령 하나를 실행한 다음 풀 또는 동기화를 다시 시도하세요.", "copyAria": "{{value0}} pull 정책 명령 복사", - "copied": "Copied", + "copied": "복사됨", "copyCommand": "명령 복사" } } + }, + "AiVaultSessionWorktree": { + "currentWorktree": "현재 작업 트리", + "activeWorktree": "활성 작업 트리", + "archivedWorktree": "보관된 작업 트리", + "unavailableWorktree": "사용할 수 없는 작업 트리", + "jumpToWorktree": "작업 트리로 이동", + "noRecordedWorktree": "이 세션에 기록된 작업 트리가 없습니다.", + "archivedJumpUnavailable": "이 세션은 보관된 작업 트리에 있습니다.", + "noActiveWorktreeMatch": "이 세션과 일치하는 활성 작업 트리가 없습니다.", + "noActiveWorktreeTarget": "사용 가능한 활성 작업 트리가 없습니다." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 81bceae9d7d..00f7b5bc129 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9170,7 +9170,9 @@ "sessionId": "会话 ID", "logPath": "日志路径", "agents": "智能体", - "sessionsShownCompact": "已显示 {{value0}} 个" + "sessionsShownCompact": "已显示 {{value0}} 个", + "originalPaneUnavailable": "Original pane is no longer available.", + "worktreeUnavailable": "Worktree is no longer available." }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", @@ -9218,6 +9220,8 @@ "resumeCommand": "恢复命令", "sessionActions": "{{value0}} 会话操作", "resumeInNewTab": "在新标签页中恢复", + "resumeInWorktree": "在工作树中恢复", + "viewLog": "查看日志", "copyResumeCommand": "复制恢复命令", "openLog": "打开日志", "revealLog": "显示日志", @@ -9246,7 +9250,10 @@ "agentRole": "智能体", "toolRole": "工具", "systemRole": "系统", - "sessionRole": "会话" + "sessionRole": "会话", + "jumpToOriginalPane": "跳转到原始窗格", + "worktree": "工作树", + "jumpToWorktree": "跳转到工作树" }, "AiVaultSessionRow": { "resumeAgentSession": "恢复 {{value0}} 会话", @@ -9270,7 +9277,9 @@ "agentRole": "智能体", "toolRole": "工具", "systemRole": "系统", - "sessionRole": "会话" + "sessionRole": "会话", + "jumpToOriginalPane": "跳转到原始窗格", + "jumpToWorktree": "跳转到工作树" }, "FileExplorerNameFilter": { "26fb73c6e3": "查找文件", @@ -9300,20 +9309,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": "没有可用的活动工作树。" } } }, diff --git a/src/renderer/src/lib/project-host-workspace-target.test.ts b/src/renderer/src/lib/project-host-workspace-target.test.ts index 778b2ae30d8..e26eb4d4108 100644 --- a/src/renderer/src/lib/project-host-workspace-target.test.ts +++ b/src/renderer/src/lib/project-host-workspace-target.test.ts @@ -207,10 +207,7 @@ describe('project-host workspace target resolution', () => { // current host is given only as a preference must resolve to that project's // own host instead of returning '' (the silent no-op the dropdown showed). describe('cross-host project selection', () => { - const repos = [ - makeRepo('local-repo'), - makeRepo('remote-repo', { connectionId: 'remote-1' }) - ] + const repos = [makeRepo('local-repo'), makeRepo('remote-repo', { connectionId: 'remote-1' })] const projects = [ makeProject('repo:local-repo', ['local-repo']), makeProject('repo:remote-repo', ['remote-repo']) From f864fc5bc68afb2a8d213ea74df55480b145a2d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:30:08 +0000 Subject: [PATCH 3/4] release: v1.4.98-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8a40788458a..0375d9786ed 100644 --- a/package.json +++ b/package.json @@ -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", From 35c3f86c82edee4a419d1dc7be7cec05c8a954ef Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:18:31 -0700 Subject: [PATCH 4/4] Style commit text area to match input tokens and fix disabled state (#6339) * Style commit text area to match input tokens and fix disabled state - Update borders and shadows to match standard input tokens. - Style the disabled state explicitly to prevent Chromium's user-agent styles from washing out the field outline. - Add dark mode background styling overrides. * Improve timeline items assertions in work-item-details test Extract timelineItems to a local variable and add an explicit null check to prevent TypeScript compilation errors when calling the .at method. --- src/main/github/work-item-details.test.ts | 10 +++++++--- .../src/components/right-sidebar/SourceControl.tsx | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 5343c00813f..a36ac97e0fa 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -283,9 +283,13 @@ describe('getWorkItemDetails', () => { call[0].includes('repos/acme/widgets/issues/923/timeline?per_page=100&page=7') ) ).toBe(false) - 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' }) + const timelineItems = details?.timelineItems + expect(timelineItems).toHaveLength(300) + if (!timelineItems) { + throw new Error('Expected timeline items to be present') + } + 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 () => { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 45da0012ddd..74fa804f48b 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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' : '' }`} />