mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user