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.
This commit is contained in:
Jinjing
2026-07-05 00:25:10 -07:00
committed by GitHub
parent 9886eb507a
commit 8dbe6bf372
9 changed files with 321 additions and 216 deletions
+15 -12
View File
@@ -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<ExecResponse>
}
const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise<ExecResponse>) => {
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: '' })
}
])
)
@@ -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}
@@ -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({
)}
</SessionReceiptSection>
{worktreeDisplay ? (
{shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { vaultScope }) ? (
<SessionReceiptSection
icon={<FolderGit2 className="size-3" />}
label={translate(
@@ -86,7 +89,7 @@ export function SessionInlineDetails({
'Worktree'
)}
>
<WorktreeMetadataLines worktreeInfo={worktreeDisplay} />
<WorktreeMetadataLines worktreeInfo={worktreeDisplay} vaultScope={vaultScope} />
</SessionReceiptSection>
) : null}
</div>
@@ -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 (
<div className="grid min-w-0 gap-1 text-[11px] leading-4">
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
{shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? (
{shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? (
<>
<span className="shrink-0 text-[10px] font-medium uppercase tracking-[0.04em] text-muted-foreground">
{aiVaultWorktreeStatusLabel(worktreeInfo.status)}
@@ -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}
/>
</div>
{detailsExpanded && worktreeInfo ? (
{detailsExpanded && shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? (
<div className="mt-1">
<SessionWorktreeLine worktreeInfo={worktreeInfo} />
<SessionWorktreeLine worktreeInfo={worktreeInfo} vaultScope={vaultScope} />
</div>
) : 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({
</ContextMenu>
)
}
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 (
<div className="mt-1 grid min-w-0 gap-0.5 text-[11px] leading-4 text-muted-foreground">
<div className="flex min-w-0 items-center gap-1.5">
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={14} />
</span>
<span className="min-w-0 truncate">{agentLabel(session.agent)}</span>
<span className="shrink-0 tabular-nums">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
'{{value0}} msgs',
{ value0: session.messageCount }
)}
</span>
<span className="shrink-0 text-muted-foreground/55">·</span>
<SessionTime value={updatedAt} />
</div>
{worktreeInfo ? <SessionWorktreeLine worktreeInfo={worktreeInfo} /> : null}
</div>
)
}
function SessionWorktreeLine({
worktreeInfo
}: {
worktreeInfo: AiVaultSessionWorktreeInfo
}): React.JSX.Element {
const repoId = worktreeInfo.worktreeId
? (splitWorktreeIdForFilesystem(worktreeInfo.worktreeId)?.repoId ?? null)
: null
const repo = useRepoById(repoId)
return (
<div className="flex min-w-0 items-center gap-1.5 pl-5">
{shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status) ? (
<span className="shrink-0 rounded-sm border border-sidebar-border bg-sidebar-accent/45 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{worktreeStatusLabel(worktreeInfo.status)}
</span>
) : null}
<Badge
variant="outline"
className="h-5 max-w-full gap-1 border-border/70 bg-background px-1.5 py-0 text-[11px] font-medium"
title={worktreeInfo.label}
>
<RepoBadgeLabel
name={worktreeInfo.label}
color={resolveRepoBadgeColor(repo?.badgeColor)}
className="min-w-0 max-w-full"
badgeClassName="size-1.5"
/>
</Badge>
</div>
)
}
function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string {
return aiVaultWorktreeStatusLabel(status)
}
function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string {
if (role === 'user') {
return translate('auto.components.right.sidebar.AiVaultSessionRow.userRole', 'You')
}
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')
}
@@ -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<string>
expandedSessionIds: ReadonlySet<string>
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}
@@ -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 (
<div className="mt-1 grid min-w-0 grid-cols-[auto_minmax(0,1fr)] gap-x-1.5 gap-y-0.5 text-[11px] leading-4 text-muted-foreground">
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={14} />
</span>
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate">{agentLabel(session.agent)}</span>
<span className="shrink-0 tabular-nums">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
'{{value0}} msgs',
{ value0: session.messageCount }
)}
</span>
<span className="shrink-0 text-muted-foreground/55">·</span>
<SessionTime value={updatedAt} />
</div>
{shouldShowAiVaultSessionWorktreeLine(worktreeInfo, { vaultScope }) ? (
<div className="col-span-2 min-w-0">
<SessionWorktreeLine worktreeInfo={worktreeInfo} vaultScope={vaultScope} />
</div>
) : null}
</div>
)
}
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 (
<div className="flex min-w-0 flex-wrap items-center gap-1.5 pl-5">
{shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? (
<span className="shrink-0 rounded-sm border border-sidebar-border bg-sidebar-accent/45 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{worktreeStatusLabel(worktreeInfo.status)}
</span>
) : null}
<Badge
variant="outline"
className="h-5 max-w-full gap-1 border-border/70 bg-background px-1.5 py-0 text-[11px] font-medium"
title={worktreeInfo.label}
>
<RepoBadgeLabel
name={worktreeInfo.label}
color={resolveRepoBadgeColor(repo?.badgeColor)}
className="min-w-0 max-w-full"
badgeClassName="size-1.5"
/>
</Badge>
</div>
)
}
function worktreeStatusLabel(status: AiVaultSessionWorktreeInfo['status']): string {
return aiVaultWorktreeStatusLabel(status)
}
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')
}
@@ -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'
)
}
@@ -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', () => {
@@ -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<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
@@ -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'
)
}