From 8dbe6bf372da2fa98844e6192c8d3b3f9b1534bc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:25:10 -0700 Subject: [PATCH] Improve agent history indentation and layout (#7412) * Hide redundant current worktree info in workspace vault scope When the AI Vault is scoped to the current workspace, showing the "Current worktree" status line or badge is redundant since the sessions are already filtered to this workspace. - Add helper to hide the worktree line and status badge when the vault is in 'workspace' scope and status is 'current'. - Improve alignment and metadata layout in session rows using CSS grid. - Update tests to verify worktree line and badge visibility rules. * Indent AI vault session worktree line Add left padding to the session worktree line to improve its visual indentation inside the AI vault session row. --- src/main/ipc/repos-remote.test.ts | 27 ++-- .../components/right-sidebar/AiVaultPanel.tsx | 1 + .../right-sidebar/AiVaultSessionDetails.tsx | 15 ++- .../right-sidebar/AiVaultSessionRow.tsx | 116 +++-------------- .../AiVaultSessionVirtualList.tsx | 11 +- .../ai-vault-session-row-display.tsx | 115 ++++++++++++++++ .../ai-vault-session-worktree-affordances.ts | 123 ++++++++++++++++++ .../ai-vault-session-worktree.test.ts | 22 ++++ .../ai-vault-session-worktree.ts | 107 ++------------- 9 files changed, 321 insertions(+), 216 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 2a9a66df94d..bd2541c7ebd 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -2413,13 +2413,13 @@ describe('repos:getBaseRefDefault envelope', () => { // independent of which Promise in the Promise.all resolves first. type ExecResponse = { stdout: string; stderr: string } type ExecRule = { - match: (argv: string[]) => boolean + matches: (argv: string[]) => boolean respond: () => Promise } const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise) => { return (argv: string[]) => { for (const rule of rules) { - if (rule.match(argv)) { + if (rule.matches(argv)) { return rule.respond() } } @@ -2438,17 +2438,17 @@ describe('repos:getBaseRefDefault envelope', () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ { - match: isSymbolicRef, + matches: isSymbolicRef, respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) }, // The origin/HEAD target is verified before it is trusted, so the // symbolic-ref result must also resolve via rev-parse. { - match: isRevParseFor('refs/remotes/origin/main'), + matches: isRevParseFor('refs/remotes/origin/main'), respond: () => Promise.resolve({ stdout: '', stderr: '' }) }, { - match: isRemoteList, + matches: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' }) } ]) @@ -2474,17 +2474,17 @@ describe('repos:getBaseRefDefault envelope', () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ { - match: isSymbolicRef, + matches: isSymbolicRef, respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' }) }, // The origin/HEAD target is verified before it is trusted, so the // symbolic-ref result must also resolve via rev-parse. { - match: isRevParseFor('refs/remotes/origin/main'), + matches: isRevParseFor('refs/remotes/origin/main'), respond: () => Promise.resolve({ stdout: '', stderr: '' }) }, { - match: isRemoteList, + matches: isRemoteList, respond: () => Promise.reject(new Error('relay exec failed')) } ]) @@ -2512,18 +2512,21 @@ describe('repos:getBaseRefDefault envelope', () => { mockGitProvider.exec = vi.fn().mockImplementation( dispatchExec([ // symbolic-ref rejects (no origin/HEAD on the remote) - { match: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) }, + { matches: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) }, // probe 1: refs/remotes/origin/main — rejects { - match: isRevParseFor('refs/remotes/origin/main'), + matches: isRevParseFor('refs/remotes/origin/main'), respond: () => Promise.reject(new Error('missing')) }, // probe 2: refs/remotes/origin/master — succeeds { - match: isRevParseFor('refs/remotes/origin/master'), + matches: isRevParseFor('refs/remotes/origin/master'), respond: () => Promise.resolve({ stdout: 'abc123\n', stderr: '' }) }, - { match: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' }) } + { + matches: isRemoteList, + respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' }) + } ]) ) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 3fd7ae88809..8442e73f54e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -355,6 +355,7 @@ export default function AiVaultPanel(): React.JSX.Element { sessionsCount={sessions.length} filteredSessionsCount={filteredSessions.length} error={error} + vaultScope={scope} buildResumeStartup={buildResumeStartup} getSessionResumeState={getSessionResumeState} getSessionResumeActions={getSessionResumeActions} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index d5a538b44fd..85f5b2a5759 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -3,13 +3,14 @@ import { FileJson, FolderGit2, MessageSquare, Play } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { sessionDetailConversationTurns } from './ai-vault-session-display' import { aiVaultWorktreeCompactPath, aiVaultWorktreeStatusLabel, shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' @@ -17,6 +18,7 @@ export function SessionInlineDetails({ id, session, worktreeInfo, + vaultScope, resumeActions, onResumeInWorktree, onResumeInNewTab, @@ -25,6 +27,7 @@ export function SessionInlineDetails({ id: string session: AiVaultSession worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope resumeActions: { worktree: { worktreeId: string | null; disabled: boolean } newTab: { worktreeId: string | null; disabled: boolean } @@ -78,7 +81,7 @@ export function SessionInlineDetails({ )} - {worktreeDisplay ? ( + {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { vaultScope }) ? ( } label={translate( @@ -86,7 +89,7 @@ export function SessionInlineDetails({ 'Worktree' )} > - + ) : null} @@ -202,9 +205,11 @@ function ConversationTurnCard({ } function WorktreeMetadataLines({ - worktreeInfo + worktreeInfo, + vaultScope }: { worktreeInfo: AiVaultSessionWorktreeInfo + vaultScope: AiVaultScope }): React.JSX.Element { const compactPath = aiVaultWorktreeCompactPath(worktreeInfo.path) const pathLine = @@ -214,7 +219,7 @@ function WorktreeMetadataLines({ return (
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? ( + {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? ( <> {aiVaultWorktreeStatusLabel(worktreeInfo.status)} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 0826eed9509..b0d88016327 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,37 +1,36 @@ import { useCallback } from 'react' import type React from 'react' import { ContextMenu, ContextMenuContent, 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_END_EVENT, AI_VAULT_SESSION_DRAG_START_EVENT, writeAiVaultSessionDragData } from '@/lib/ai-vault-session-drag' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' -import { agentLabel } from './ai-vault-session-filters' import { translate } from '@/i18n/i18n' -import { SessionInlineDetails, SessionTime } from './AiVaultSessionDetails' +import { SessionInlineDetails } from './AiVaultSessionDetails' import { latestSessionConversationTurn } from './ai-vault-session-display' import { SessionActionMenuItems } from './AiVaultSessionActionMenuItems' import { SessionRowTrailingActions } from './SessionRowTrailingActions' import type { AiVaultSessionResumeActions } from './ai-vault-session-resume' import { - aiVaultWorktreeStatusLabel, - shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' +import { + conversationRoleLabel, + getSessionDetailsId, + SessionMetadata, + SessionWorktreeLine +} from './ai-vault-session-row-display' export function VaultSessionRow({ session, resumeStartup, worktreeInfo, + vaultScope, detailsExpanded, resumeDisabled, onToggleDetails, @@ -53,6 +52,7 @@ export function VaultSessionRow({ session: AiVaultSession resumeStartup: AiVaultResumeStartup worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope detailsExpanded: boolean resumeDisabled: boolean onToggleDetails: () => void @@ -154,9 +154,9 @@ export function VaultSessionRow({ onOpenCwd={onOpenCwd} />
- {detailsExpanded && worktreeInfo ? ( + {detailsExpanded && shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? (
- +
) : null} {!detailsExpanded ? ( @@ -180,6 +180,7 @@ export function VaultSessionRow({ session={session} updatedAt={updatedAt} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} /> ) : null} @@ -188,6 +189,7 @@ export function VaultSessionRow({ id={detailsId} session={session} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} resumeActions={resumeActions} onResumeInWorktree={onResumeInWorktree} onResumeInNewTab={onResumeInNewTab} @@ -216,91 +218,3 @@ export function VaultSessionRow({ ) } - -function getSessionDetailsId(sessionId: string): string { - return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` -} - -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 } - )} - - · - -
- {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') - } - if (role === 'assistant') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.agentRole', 'Agent') - } - if (role === 'tool') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.toolRole', 'Tool') - } - if (role === 'system') { - return translate('auto.components.right.sidebar.AiVaultSessionRow.systemRole', 'System') - } - return translate('auto.components.right.sidebar.AiVaultSessionRow.sessionRole', 'Session') -} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 3d2bdde0384..a8de4d84313 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -1,6 +1,6 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { useCallback, useMemo, useRef, useState } from 'react' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -41,6 +41,7 @@ export function AiVaultSessionVirtualList({ sessionsCount, filteredSessionsCount, error, + vaultScope, buildResumeStartup, getOriginalPaneTarget, getWorktreeInfo, @@ -63,6 +64,7 @@ export function AiVaultSessionVirtualList({ sessionsCount: number filteredSessionsCount: number error: string | null + vaultScope: AiVaultScope buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null @@ -185,6 +187,7 @@ export function AiVaultSessionVirtualList({ measureElement={virtualizer.measureElement} collapsedGroups={collapsedGroups} expandedSessionIds={expandedSessionIds} + vaultScope={vaultScope} buildResumeStartup={buildResumeStartup} getOriginalPaneTarget={getOriginalPaneTarget} getWorktreeInfo={getWorktreeInfo} @@ -217,6 +220,7 @@ function AiVaultVirtualRow({ measureElement, collapsedGroups, expandedSessionIds, + vaultScope, buildResumeStartup, getOriginalPaneTarget, getWorktreeInfo, @@ -241,6 +245,7 @@ function AiVaultVirtualRow({ measureElement: (node: Element | null) => void collapsedGroups: ReadonlySet expandedSessionIds: ReadonlySet + vaultScope: AiVaultScope buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null @@ -266,8 +271,7 @@ function AiVaultVirtualRow({ const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null // Why: omit the jump affordance when the session already lives in the - // worktree on screen — jumping there is a no-op the "Current worktree" badge - // already conveys. + // worktree on screen — jumping there is a no-op. const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo) const worktreeJumpId = showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo) @@ -300,6 +304,7 @@ function AiVaultVirtualRow({ session={row.session} resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)} worktreeInfo={worktreeInfo} + vaultScope={vaultScope} detailsExpanded={expandedSessionIds.has(row.session.id)} resumeDisabled={resumeState?.blocked ?? true} resumeLabel={resumeLabel} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx new file mode 100644 index 00000000000..1d3972a6fac --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx @@ -0,0 +1,115 @@ +import type React from 'react' +import { Badge } from '@/components/ui/badge' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { AgentIcon } from '@/lib/agent-catalog' +import { useRepoById } from '@/store/selectors' +import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' +import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import { SessionTime } from './AiVaultSessionDetails' +import { agentLabel } from './ai-vault-session-filters' +import { + aiVaultWorktreeStatusLabel, + shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, + type AiVaultSessionWorktreeInfo +} from './ai-vault-session-worktree' + +export function getSessionDetailsId(sessionId: string): string { + return `ai-vault-session-details-${sessionId.replace(/[^A-Za-z0-9_-]/g, '-')}` +} + +export function SessionMetadata({ + session, + updatedAt, + worktreeInfo, + vaultScope +}: { + session: AiVaultSession + updatedAt: string + worktreeInfo: AiVaultSessionWorktreeInfo | null + vaultScope: AiVaultScope +}) { + return ( +
+ + + +
+ {agentLabel(session.agent)} + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', + '{{value0}} msgs', + { value0: session.messageCount } + )} + + · + +
+ {shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? ( +
+ +
+ ) : null} +
+ ) +} + +export function SessionWorktreeLine({ + worktreeInfo, + vaultScope +}: { + worktreeInfo: AiVaultSessionWorktreeInfo + vaultScope: AiVaultScope +}): React.JSX.Element { + const repoId = worktreeInfo.worktreeId + ? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null) + : null + const repo = useRepoById(repoId) + + return ( +
+ {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? ( + + {worktreeStatusLabel(worktreeInfo.status)} + + ) : null} + + + +
+ ) +} + +function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string { + return aiVaultWorktreeStatusLabel(status) +} + +export function conversationRoleLabel( + role: AiVaultSession['previewMessages'][number]['role'] +): string { + if (role === 'user') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You') + } + if (role === 'assistant') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.agentRole', 'Agent') + } + if (role === 'tool') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.toolRole', 'Tool') + } + if (role === 'system') { + return translate('auto.components.right.sidebar.AiVaultSessionRow.systemRole', 'System') + } + return translate('auto.components.right.sidebar.AiVaultSessionRow.sessionRole', 'Session') +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts new file mode 100644 index 00000000000..6551d961bd9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-affordances.ts @@ -0,0 +1,123 @@ +import { normalizeRuntimePathSeparators } from '../../../../shared/cross-platform-path' +import type { AiVaultScope } from '../../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import type { + AiVaultSessionWorktreeInfo, + AiVaultSessionWorktreeStatus +} from './ai-vault-session-worktree' + +export function canJumpToAiVaultSessionWorktree( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): boolean { + return Boolean( + worktreeInfo?.worktreeId && + worktreeInfo.status !== 'archived' && + worktreeInfo.status !== 'unavailable' + ) +} + +// Why: a session in the worktree you're already viewing has nowhere to jump, +// so we hide the affordance rather than offering a self-jump (the "Current +// worktree" badge already signals where it lives). +export function isAiVaultSessionInCurrentWorktree( + worktreeInfo: AiVaultSessionWorktreeInfo | null +): boolean { + return worktreeInfo?.status === 'current' +} + +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.' + ) +} + +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 shouldShowAiVaultSessionWorktreeLine( + worktreeInfo: AiVaultSessionWorktreeInfo | null, + options?: { vaultScope?: AiVaultScope } +): worktreeInfo is AiVaultSessionWorktreeInfo { + if (!worktreeInfo) { + return false + } + // Why: workspace scope already limits history to the active workspace; the + // worktree row adds no value when the session lives in the worktree on screen. + if (options?.vaultScope === 'workspace' && worktreeInfo.status === 'current') { + return false + } + return true +} + +export function shouldShowAiVaultWorktreeStatusBadge( + status: AiVaultSessionWorktreeStatus, + options?: { vaultScope?: AiVaultScope } +): boolean { + // Why: "active" repeats the branch label without adding scan value in dense rows. + if (status === 'active') { + return false + } + // Why: workspace scope already filters to the active workspace, so "Current + // worktree" is redundant in the default history view. + if (status === 'current' && options?.vaultScope === 'workspace') { + return false + } + return true +} + +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-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index 58687977a33..56ba2f246f7 100644 --- 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 @@ -10,6 +10,7 @@ import { resolveAiVaultSessionWorktreeDisplay, resolveAiVaultSessionWorktreeInfo, shouldShowAiVaultWorktreeStatusBadge, + shouldShowAiVaultSessionWorktreeLine, type AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree' @@ -269,6 +270,21 @@ describe('aiVaultWorktreeCompactPath', () => { }) }) +describe('shouldShowAiVaultSessionWorktreeLine', () => { + it('hides the worktree row for the current worktree in workspace scope', () => { + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('current'), { vaultScope: 'workspace' }) + ).toBe(false) + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('current'), { vaultScope: 'all' }) + ).toBe(true) + expect( + shouldShowAiVaultSessionWorktreeLine(makeWorktreeInfo('active'), { vaultScope: 'workspace' }) + ).toBe(true) + expect(shouldShowAiVaultSessionWorktreeLine(null, { vaultScope: 'workspace' })).toBe(false) + }) +}) + describe('shouldShowAiVaultWorktreeStatusBadge', () => { it('hides the generic active badge but keeps meaningful states', () => { expect(shouldShowAiVaultWorktreeStatusBadge('active')).toBe(false) @@ -276,6 +292,12 @@ describe('shouldShowAiVaultWorktreeStatusBadge', () => { expect(shouldShowAiVaultWorktreeStatusBadge('archived')).toBe(true) expect(shouldShowAiVaultWorktreeStatusBadge('unavailable')).toBe(true) }) + + it('hides the current badge in workspace scope', () => { + expect(shouldShowAiVaultWorktreeStatusBadge('current', { vaultScope: 'workspace' })).toBe(false) + expect(shouldShowAiVaultWorktreeStatusBadge('current', { vaultScope: 'all' })).toBe(true) + expect(shouldShowAiVaultWorktreeStatusBadge('archived', { vaultScope: 'workspace' })).toBe(true) + }) }) describe('aiVaultWorktreeJumpTooltip', () => { 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 index 31222bec1b5..670e2deecc1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -10,12 +10,21 @@ import { import { isPathInsideOrEqual, isRuntimePathAbsolute, - normalizeRuntimePathForComparison, - normalizeRuntimePathSeparators + normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { Repo, Worktree } from '../../../../shared/types' -import { translate } from '@/i18n/i18n' +import { aiVaultWorktreeCompactPath } from './ai-vault-session-worktree-affordances' + +export { + aiVaultWorktreeCompactPath, + aiVaultWorktreeJumpTooltip, + aiVaultWorktreeStatusLabel, + canJumpToAiVaultSessionWorktree, + isAiVaultSessionInCurrentWorktree, + shouldShowAiVaultSessionWorktreeLine, + shouldShowAiVaultWorktreeStatusBadge +} from './ai-vault-session-worktree-affordances' export type AiVaultSessionWorktreeStatus = 'current' | 'active' | 'archived' | 'unavailable' @@ -155,58 +164,6 @@ export function useAiVaultSessionWorktreeMap({ ) } -export function canJumpToAiVaultSessionWorktree( - worktreeInfo: AiVaultSessionWorktreeInfo | null -): boolean { - return Boolean( - worktreeInfo?.worktreeId && - worktreeInfo.status !== 'archived' && - worktreeInfo.status !== 'unavailable' - ) -} - -// Why: a session in the worktree you're already viewing has nowhere to jump, -// so we hide the affordance rather than offering a self-jump (the "Current -// worktree" badge already signals where it lives). -export function isAiVaultSessionInCurrentWorktree( - worktreeInfo: AiVaultSessionWorktreeInfo | null -): boolean { - return worktreeInfo?.status === 'current' -} - -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[], repos: readonly Pick[] @@ -270,21 +227,6 @@ function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandi 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', @@ -296,28 +238,3 @@ function unavailableWorktreeInfo(pathValue: string): AiVaultSessionWorktreeInfo 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' - ) -}