Group agent session history by project (#5432)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-23 00:27:21 -07:00 committed by GitHub
parent edf98f6578
commit 5311ab3867
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1280 additions and 70 deletions

View File

@ -3,13 +3,25 @@ import { toast } from 'sonner'
import { buildAiVaultResumeCommandForWorktree } from '@/lib/ai-vault-resume-command'
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import {
useActiveRepo,
useActiveWorktree,
useAllWorktrees,
useProjectHostSetupProjection,
useRepoById,
useRepos
} from '@/store/selectors'
import {
agentLabel,
deriveAiVaultWorkspaceScopePaths,
filterAiVaultSessions,
groupAiVaultSessions
} from './ai-vault-session-filters'
import {
normalizeAiVaultScopeForContext,
shouldRestoreAiVaultProjectScope
} from './ai-vault-scope-state'
import { buildAiVaultProjectContext } from './ai-vault-session-projects'
import {
AI_VAULT_AGENTS,
type AiVaultAgent,
@ -28,13 +40,16 @@ const SESSION_LIMIT = 500
export default function AiVaultPanel(): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const activeRepo = useActiveRepo()
const activeWorktreeRepo = useRepoById(activeWorktree?.repoId ?? null)
const repos = useRepos()
const allWorktrees = useAllWorktrees()
const projectHostSetupProjection = useProjectHostSetupProjection()
const agentCmdOverrides = useAppStore((s) => s.settings?.agentCmdOverrides ?? {})
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const [query, setQuery] = useState('')
const [scope, setScope] = useState<AiVaultScope>('workspace')
const [scope, setScope] = useState<AiVaultScope>('project')
const [sort, setSort] = useState<AiVaultSort>('updated')
const [group, setGroup] = useState<AiVaultGroup>('folder')
const [group, setGroup] = useState<AiVaultGroup>('project')
const [hideEmptySessions, setHideEmptySessions] = useState(true)
const [agents, setAgents] = useState<AiVaultAgent[]>([...AI_VAULT_AGENTS])
const [sessions, setSessions] = useState<AiVaultSession[]>([])
@ -45,27 +60,61 @@ export default function AiVaultPanel(): React.JSX.Element {
const refreshIdRef = useRef(0)
const refreshInFlightRef = useRef(false)
const mountedRef = useRef(true)
const userChangedScopeRef = useRef(false)
const isRemoteWorktree = Boolean(activeRepo?.connectionId)
const isRemoteWorktree = Boolean(activeWorktreeRepo?.connectionId)
const activeWorktreePath = activeWorktree?.path ?? null
// Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos.
const liveWorktrees = useMemo(() => Object.values(worktreesByRepo).flat(), [worktreesByRepo])
const activeWorktreePaths = useMemo(
() => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, liveWorktrees),
[activeWorktree, liveWorktrees]
() => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, allWorktrees),
[activeWorktree, allWorktrees]
)
const projectContext = useMemo(
() =>
buildAiVaultProjectContext({
repos,
worktrees: allWorktrees,
projectHostSetupProjection,
activeRepo,
activeWorktree,
sessions
}),
[activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos, sessions]
)
const activeProjectKey = projectContext.activeProjectKey
const projectLabelByKey = projectContext.projectLabelByKey
const sessionProjectById = projectContext.sessionProjectById
const hasAllAgentsSelected = agents.length === AI_VAULT_AGENTS.length
const viewAdjustmentCount =
(hasAllAgentsSelected ? 0 : 1) +
(sort === 'updated' ? 0 : 1) +
(group === 'folder' ? 0 : 1) +
(group === 'project' ? 0 : 1) +
(hideEmptySessions ? 0 : 1)
// Project scope depends on active project context, but should come back after
// transient context loss unless the user intentionally chose another scope.
useEffect(() => {
if (!activeWorktreePath && scope === 'workspace') {
setScope('all')
const normalizedScope = normalizeAiVaultScopeForContext({
scope,
activeProjectKey,
activeWorktreePath
})
if (normalizedScope !== scope) {
setScope(normalizedScope)
}
}, [activeWorktreePath, scope])
}, [activeProjectKey, activeWorktreePath, scope])
useEffect(() => {
if (
shouldRestoreAiVaultProjectScope({
scope,
activeProjectKey,
userChangedScope: userChangedScopeRef.current
})
) {
setScope('project')
}
}, [activeProjectKey, scope])
const refresh = useCallback(async (args: { force?: boolean } = {}): Promise<void> => {
if (refreshInFlightRef.current) {
@ -120,14 +169,32 @@ export default function AiVaultPanel(): React.JSX.Element {
scope,
sort,
activeWorktreePaths,
activeProjectKey,
sessionProjectById,
projectLabelByKey,
hideEmptySessions
}),
[activeWorktreePaths, agents, hideEmptySessions, query, scope, sessions, sort]
[
activeProjectKey,
activeWorktreePaths,
agents,
hideEmptySessions,
projectLabelByKey,
query,
scope,
sessionProjectById,
sessions,
sort
]
)
const groups = useMemo(
() => groupAiVaultSessions(filteredSessions, group),
[filteredSessions, group]
() =>
groupAiVaultSessions(filteredSessions, group, {
sessionProjectById,
projectLabelByKey
}),
[filteredSessions, group, projectLabelByKey, sessionProjectById]
)
const buildResumeCommand = useCallback(
@ -212,10 +279,15 @@ export default function AiVaultPanel(): React.JSX.Element {
const resetViewOptions = useCallback(() => {
setAgents([...AI_VAULT_AGENTS])
setSort('updated')
setGroup('folder')
setGroup('project')
setHideEmptySessions(true)
}, [])
const handleScopeChange = useCallback((nextScope: AiVaultScope) => {
userChangedScopeRef.current = nextScope !== 'project'
setScope(nextScope)
}, [])
const toggleGroup = useCallback((key: string) => {
setCollapsedGroups((current) => {
const next = new Set(current)
@ -237,6 +309,7 @@ export default function AiVaultPanel(): React.JSX.Element {
sessionCount={sessions.length}
hasScanResult={Boolean(scanResult)}
activeWorktreePath={activeWorktreePath}
activeProjectKey={activeProjectKey}
scope={scope}
agents={agents}
sort={sort}
@ -244,7 +317,7 @@ export default function AiVaultPanel(): React.JSX.Element {
hideEmptySessions={hideEmptySessions}
adjustmentCount={viewAdjustmentCount}
onQueryChange={setQuery}
onScopeChange={setScope}
onScopeChange={handleScopeChange}
onAgentEnabledChange={setAgentEnabled}
onSortChange={setSort}
onGroupChange={setGroup}

View File

@ -6,7 +6,8 @@ import {
Clock3,
FolderOpen,
ListFilter,
LoaderCircle
LoaderCircle,
PanelsTopLeft
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
@ -36,7 +37,7 @@ import { translate } from '@/i18n/i18n'
const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0'
const VAULT_SCOPE_TOGGLE_ITEM_CLASS =
'h-6 min-h-6 min-w-0 border border-transparent bg-transparent px-1.5 text-[10px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground @max-[300px]/ai-vault:px-1'
'h-7 min-h-7 min-w-0 flex-1 basis-0 shrink border border-transparent bg-transparent px-2.5 text-[11px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground @max-[300px]/ai-vault:px-1.5'
export function VaultGroupHeader({
group,
@ -99,15 +100,21 @@ export function SessionLoadingState(): React.JSX.Element {
export function VaultScopeSwitch({
scope,
workspaceAvailable,
projectAvailable,
onScopeChange
}: {
scope: AiVaultScope
workspaceAvailable: boolean
projectAvailable: boolean
onScopeChange: (scope: AiVaultScope) => void
}): React.JSX.Element {
const worktreeLabel = translate(
'auto.components.right.sidebar.AiVaultPanelControls.worktreeScope',
'Worktree'
const workspaceLabel = translate(
'auto.components.right.sidebar.AiVaultPanelControls.workspaceScope',
'Workspace'
)
const projectLabel = translate(
'auto.components.right.sidebar.AiVaultPanelControls.projectScope',
'Project'
)
const allLabel = translate('auto.components.right.sidebar.AiVaultPanelControls.allScope', 'All')
@ -116,12 +123,12 @@ export function VaultScopeSwitch({
type="single"
value={scope}
onValueChange={(value) => {
if (value === 'workspace' || value === 'all') {
if (value === 'workspace' || value === 'project' || value === 'all') {
onScopeChange(value)
}
}}
variant="outline"
className="h-6 shrink-0 rounded-md border border-sidebar-border bg-sidebar-accent/35 shadow-xs"
className="h-7 w-full rounded-md border border-sidebar-border bg-sidebar-accent/35 shadow-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultPanelControls.scopeAriaLabel',
'Session History scope: {{value0}}',
@ -129,25 +136,37 @@ export function VaultScopeSwitch({
value0:
scope === 'workspace'
? translate(
'auto.components.right.sidebar.AiVaultPanelControls.currentWorktreeLower',
'current worktree'
)
: translate(
'auto.components.right.sidebar.AiVaultPanelControls.allSessionsLower',
'all sessions'
'auto.components.right.sidebar.AiVaultPanelControls.currentWorkspaceLower',
'current workspace'
)
: scope === 'project'
? translate(
'auto.components.right.sidebar.AiVaultPanelControls.currentProjectLower',
'current project'
)
: translate(
'auto.components.right.sidebar.AiVaultPanelControls.allSessionsLower',
'all sessions'
)
}
)}
>
<ToggleGroupItem value="all" className={VAULT_SCOPE_TOGGLE_ITEM_CLASS}>
{allLabel}
</ToggleGroupItem>
<ToggleGroupItem
value="workspace"
disabled={!workspaceAvailable}
className={VAULT_SCOPE_TOGGLE_ITEM_CLASS}
>
{worktreeLabel}
{workspaceLabel}
</ToggleGroupItem>
<ToggleGroupItem
value="project"
disabled={!projectAvailable}
className={VAULT_SCOPE_TOGGLE_ITEM_CLASS}
>
{projectLabel}
</ToggleGroupItem>
<ToggleGroupItem value="all" className={VAULT_SCOPE_TOGGLE_ITEM_CLASS}>
{allLabel}
</ToggleGroupItem>
</ToggleGroup>
)
@ -253,6 +272,10 @@ export function VaultViewMenu({
value={group}
onValueChange={(value) => onGroupChange(value as AiVaultGroup)}
>
<DropdownMenuRadioItem value="project">
<PanelsTopLeft className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.project', 'Project')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="folder">
<FolderOpen className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.folder', 'Folder')}

View File

@ -16,6 +16,7 @@ type AiVaultPanelHeaderProps = {
sessionCount: number
hasScanResult: boolean
activeWorktreePath: string | null
activeProjectKey: string | null
scope: AiVaultScope
agents: readonly AiVaultAgent[]
sort: AiVaultSort
@ -39,6 +40,7 @@ export function AiVaultPanelHeader({
sessionCount,
hasScanResult,
activeWorktreePath,
activeProjectKey,
scope,
agents,
sort,
@ -56,7 +58,7 @@ export function AiVaultPanelHeader({
}: AiVaultPanelHeaderProps): React.JSX.Element {
return (
<div className="shrink-0 border-b border-sidebar-border px-2.5 py-2">
<div className="flex items-center gap-1.5 @max-[300px]/ai-vault:items-start">
<div className="flex items-center gap-1.5">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-foreground">
{/* Why: below 300px the header competes with fixed controls, so compact copy prevents overlap. */}
@ -97,11 +99,6 @@ export function AiVaultPanelHeader({
</div>
</div>
<div className="flex shrink-0 items-center gap-1 @max-[300px]/ai-vault:gap-0.5">
<VaultScopeSwitch
scope={scope}
workspaceAvailable={Boolean(activeWorktreePath)}
onScopeChange={onScopeChange}
/>
<VaultViewMenu
agents={agents}
sort={sort}
@ -136,6 +133,15 @@ export function AiVaultPanelHeader({
</div>
</div>
<div className="mt-2">
<VaultScopeSwitch
scope={scope}
workspaceAvailable={Boolean(activeWorktreePath)}
projectAvailable={Boolean(activeProjectKey)}
onScopeChange={onScopeChange}
/>
</div>
<div className="mt-2 flex h-8 items-center gap-1.5 rounded-md border border-sidebar-border bg-input/50 px-2 focus-within:border-sidebar-ring focus-within:ring-[2px] focus-within:ring-sidebar-ring/30">
<Search className="size-3.5 shrink-0 text-muted-foreground" />
<input

View File

@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
normalizeAiVaultScopeForContext,
shouldRestoreAiVaultProjectScope
} from './ai-vault-scope-state'
describe('normalizeAiVaultScopeForContext', () => {
it('falls back from project to all when no active project is available', () => {
expect(
normalizeAiVaultScopeForContext({
scope: 'project',
activeProjectKey: null,
activeWorktreePath: '/repo'
})
).toBe('all')
})
it('falls back from workspace to all when no active workspace path is available', () => {
expect(
normalizeAiVaultScopeForContext({
scope: 'workspace',
activeProjectKey: 'project:orca',
activeWorktreePath: null
})
).toBe('all')
})
it('keeps available project and workspace scopes selected', () => {
expect(
normalizeAiVaultScopeForContext({
scope: 'project',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo'
})
).toBe('project')
expect(
normalizeAiVaultScopeForContext({
scope: 'workspace',
activeProjectKey: null,
activeWorktreePath: '/repo'
})
).toBe('workspace')
})
})
describe('shouldRestoreAiVaultProjectScope', () => {
it('restores project after automatic fallback when a project becomes available', () => {
expect(
shouldRestoreAiVaultProjectScope({
scope: 'all',
activeProjectKey: 'project:orca',
userChangedScope: false
})
).toBe(true)
})
it('does not restore project after the user manually changed scope', () => {
expect(
shouldRestoreAiVaultProjectScope({
scope: 'all',
activeProjectKey: 'project:orca',
userChangedScope: true
})
).toBe(false)
})
})

View File

@ -0,0 +1,23 @@
import type { AiVaultScope } from '../../../../shared/ai-vault-types'
export function normalizeAiVaultScopeForContext(args: {
scope: AiVaultScope
activeProjectKey: string | null
activeWorktreePath: string | null
}): AiVaultScope {
if (args.scope === 'project' && !args.activeProjectKey) {
return 'all'
}
if (args.scope === 'workspace' && !args.activeWorktreePath) {
return 'all'
}
return args.scope
}
export function shouldRestoreAiVaultProjectScope(args: {
scope: AiVaultScope
activeProjectKey: string | null
userChangedScope: boolean
}): boolean {
return Boolean(args.activeProjectKey && args.scope === 'all' && !args.userChangedScope)
}

View File

@ -251,6 +251,62 @@ describe('filterAiVaultSessions', () => {
})
).toEqual([])
})
it('filters project scope by the resolved active project key', () => {
const projectSession = { ...baseSession, id: 'claude:project', cwd: '/repo/project' }
const otherSession = { ...baseSession, id: 'claude:other', cwd: '/repo/other' }
const sessionProjectById = new Map([
[projectSession.id, { kind: 'repo' as const, key: 'project:orca', label: 'Orca' }],
[otherSession.id, { kind: 'repo' as const, key: 'project:other', label: 'Other' }]
])
expect(
filterAiVaultSessions([projectSession, otherSession], {
query: '',
agents: ['claude'],
scope: 'project',
sort: 'updated',
activeWorktreePaths: [],
activeProjectKey: 'project:orca',
sessionProjectById,
hideEmptySessions: true
}).map((session) => session.id)
).toEqual(['claude:project'])
})
it('does not show all sessions for project scope without an active project key', () => {
expect(
filterAiVaultSessions([baseSession], {
query: '',
agents: ['claude'],
scope: 'project',
sort: 'updated',
activeWorktreePaths: [],
activeProjectKey: null,
hideEmptySessions: true
})
).toEqual([])
})
it('matches repo: queries against resolved project labels before folder fallback', () => {
const sessionProjectById = new Map([
[baseSession.id, { kind: 'repo' as const, key: 'project:orca', label: 'Canonical Orca' }]
])
const projectLabelByKey = new Map([['project:orca', 'Canonical Orca']])
expect(
filterAiVaultSessions([baseSession], {
query: 'repo:canonical',
agents: ['claude'],
scope: 'all',
sort: 'updated',
activeWorktreePaths: [],
sessionProjectById,
projectLabelByKey,
hideEmptySessions: true
}).map((session) => session.id)
).toEqual(['claude:1'])
})
})
describe('deriveAiVaultWorkspaceScopePaths', () => {
@ -384,6 +440,33 @@ describe('groupAiVaultSessions', () => {
'Codex'
])
})
it('groups sibling worktree sessions by project label when resolved', () => {
const sessions: AiVaultSession[] = [
{ ...baseSession, id: 'claude:1', cwd: '/repo/main' },
{ ...baseSession, id: 'codex:2', agent: 'codex', cwd: '/repo/worktree' }
]
const sessionProjectById = new Map(
sessions.map((session) => [
session.id,
{ kind: 'repo' as const, key: 'project:orca', label: 'Orca' }
])
)
const projectLabelByKey = new Map([['project:orca', 'Canonical Orca']])
expect(
groupAiVaultSessions(sessions, 'project', {
sessionProjectById,
projectLabelByKey
})
).toEqual([{ key: 'project:orca', label: 'Canonical Orca', sessions }])
})
it('falls back to folder grouping when project metadata is unavailable', () => {
expect(groupAiVaultSessions([baseSession], 'project')).toEqual([
{ key: '/users/ada/repo/app', label: 'repo/app', sessions: [baseSession] }
])
})
})
describe('parseVaultQuery', () => {

View File

@ -17,6 +17,7 @@ import { aiVaultAgentLabel } from '../../../../shared/ai-vault-types'
import type { Worktree } from '../../../../shared/types'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
import { sessionPreviewSearchText } from './ai-vault-session-display'
import type { AiVaultSessionProject } from './ai-vault-session-projects'
export type AiVaultSessionFilterState = {
query: string
@ -24,6 +25,9 @@ export type AiVaultSessionFilterState = {
scope: AiVaultScope
sort: AiVaultSort
activeWorktreePaths: readonly string[]
activeProjectKey?: string | null
sessionProjectById?: ReadonlyMap<string, AiVaultSessionProject>
projectLabelByKey?: ReadonlyMap<string, string>
hideEmptySessions: boolean
}
@ -78,7 +82,15 @@ export function filterAiVaultSessions(
return false
}
}
return matchesQuery(session, parsedQuery)
if (filters.scope === 'project') {
if (!filters.activeProjectKey) {
return false
}
if (filters.sessionProjectById?.get(session.id)?.key !== filters.activeProjectKey) {
return false
}
}
return matchesQuery(session, parsedQuery, filters)
})
.sort((left, right) => compareSessions(left, right, filters.sort))
}
@ -110,13 +122,16 @@ export function deriveAiVaultWorkspaceScopePaths(
export function groupAiVaultSessions(
sessions: readonly AiVaultSession[],
group: AiVaultGroup
group: AiVaultGroup,
options: {
sessionProjectById?: ReadonlyMap<string, AiVaultSessionProject>
projectLabelByKey?: ReadonlyMap<string, string>
} = {}
): AiVaultSessionGroup[] {
const groups = new Map<string, AiVaultSessionGroup>()
for (const session of sessions) {
const key = group === 'agent' ? session.agent : getFolderGroupKey(session.cwd)
const label = group === 'agent' ? agentLabel(session.agent) : folderLabel(session.cwd)
const { key, label } = getGroupIdentity(session, group, options)
const existing = groups.get(key)
if (existing) {
existing.sessions.push(session)
@ -170,7 +185,11 @@ export function parseVaultQuery(query: string): ParsedQuery {
return { terms, repoTerms, pathTerms }
}
function matchesQuery(session: AiVaultSession, parsed: ParsedQuery): boolean {
function matchesQuery(
session: AiVaultSession,
parsed: ParsedQuery,
filters: Pick<AiVaultSessionFilterState, 'sessionProjectById' | 'projectLabelByKey'>
): boolean {
const searchable = [
session.title,
session.sessionId,
@ -189,7 +208,12 @@ function matchesQuery(session: AiVaultSession, parsed: ParsedQuery): boolean {
return false
}
const repoLabel = folderLabel(session.cwd).toLowerCase()
const sessionProject = filters.sessionProjectById?.get(session.id)
const repoLabel = (
sessionProject?.kind === 'repo'
? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label)
: folderLabel(session.cwd)
).toLowerCase()
if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) {
return false
}
@ -210,6 +234,32 @@ function compareSessions(left: AiVaultSession, right: AiVaultSession, sort: AiVa
return rightTime - leftTime
}
function getGroupIdentity(
session: AiVaultSession,
group: AiVaultGroup,
options: {
sessionProjectById?: ReadonlyMap<string, AiVaultSessionProject>
projectLabelByKey?: ReadonlyMap<string, string>
}
): Pick<AiVaultSessionGroup, 'key' | 'label'> {
if (group === 'agent') {
return { key: session.agent, label: agentLabel(session.agent) }
}
if (group === 'project') {
const sessionProject = options.sessionProjectById?.get(session.id)
if (sessionProject) {
return {
key: sessionProject.key,
label:
options.projectLabelByKey?.get(sessionProject.key) ||
sessionProject.label ||
folderLabel(session.cwd)
}
}
}
return { key: getFolderGroupKey(session.cwd), label: folderLabel(session.cwd) }
}
function getFolderGroupKey(pathValue: string | null): string {
return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown'
}

View File

@ -0,0 +1,506 @@
import { describe, expect, it } from 'vitest'
import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
import { buildAiVaultProjectContext, toAiVaultProjectKey } from './ai-vault-session-projects'
const baseSession: AiVaultSession = {
id: 'claude:1',
agent: 'claude',
sessionId: 'session-1',
title: 'Implement project history',
cwd: '/Users/ada/orca',
branch: 'feature/history',
model: 'claude-sonnet-4-5',
filePath: '/Users/ada/.claude/projects/session-1.jsonl',
codexHome: null,
createdAt: '2026-05-01T10:00:00.000Z',
updatedAt: '2026-05-01T10:10:00.000Z',
modifiedAt: '2026-05-01T10:10:00.000Z',
messageCount: 4,
totalTokens: 1200,
previewMessages: [],
resumeCommand: "cd '/Users/ada/orca' && claude --resume 'session-1'"
}
describe('toAiVaultProjectKey', () => {
it('does not double-wrap compatibility repo project ids', () => {
expect(toAiVaultProjectKey('project-1', 'repo-1')).toBe('project:project-1')
expect(toAiVaultProjectKey('repo:repo-1', 'repo-1')).toBe('repo:repo-1')
expect(toAiVaultProjectKey(null, 'repo-1')).toBe('repo:repo-1')
})
})
describe('buildAiVaultProjectContext', () => {
it('uses durable worktree project ids before repo fallback', () => {
const repo = makeRepo({ id: 'repo-1', displayName: 'Legacy Repo', path: '/Users/ada/orca' })
const project = makeProject({ id: 'project-1', displayName: 'Canonical Orca' })
const worktree = makeWorktree({
id: 'wt-1',
repoId: repo.id,
projectId: project.id,
path: '/Users/ada/orca'
})
const context = buildAiVaultProjectContext({
repos: [repo],
worktrees: [worktree],
projectHostSetupProjection: makeProjection({
projects: [project],
setups: [makeSetup({ repoId: repo.id, projectId: project.id, path: repo.path })]
}),
activeRepo: repo,
activeWorktree: worktree,
sessions: [baseSession]
})
expect(context.activeProjectKey).toBe('project:project-1')
expect(context.sessionProjectById.get(baseSession.id)).toMatchObject({
kind: 'repo',
key: 'project:project-1',
label: 'Canonical Orca'
})
})
it('normalizes compatibility project ids to repo keys', () => {
const repo = makeRepo({ id: 'repo-1', displayName: 'Orca', path: '/Users/ada/orca' })
const worktree = makeWorktree({
id: 'wt-1',
repoId: repo.id,
projectId: 'repo:repo-1',
path: '/Users/ada/orca'
})
const context = buildAiVaultProjectContext({
repos: [repo],
worktrees: [worktree],
projectHostSetupProjection: makeProjection({
projects: [makeProject({ id: 'repo:repo-1', displayName: 'Compatibility Orca' })],
setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-1', path: repo.path })]
}),
activeRepo: repo,
activeWorktree: worktree,
sessions: [baseSession]
})
expect(context.activeProjectKey).toBe('repo:repo-1')
expect(context.sessionProjectById.get(baseSession.id)?.key).toBe('repo:repo-1')
expect(context.projectLabelByKey.get('repo:repo-1')).toBe('Orca')
})
it('falls back to repo ids for legacy records without project metadata', () => {
const repo = makeRepo({ id: 'repo-legacy', displayName: 'Legacy', path: '/repo/legacy' })
const session = makeSession({ id: 'codex:legacy', cwd: '/repo/legacy/src' })
const context = buildAiVaultProjectContext({
repos: [repo],
worktrees: [],
projectHostSetupProjection: makeProjection({ projects: [], setups: [] }),
activeRepo: repo,
activeWorktree: null,
sessions: [session]
})
expect(context.activeProjectKey).toBe('repo:repo-legacy')
expect(context.sessionProjectById.get(session.id)).toMatchObject({
kind: 'repo',
key: 'repo:repo-legacy',
label: 'Legacy'
})
})
it('inherits setup project ids for legacy worktrees without project metadata', () => {
const repo = makeRepo({ id: 'repo-1', displayName: 'Orca Repo', path: '/repo/orca' })
const worktree = makeWorktree({
id: 'wt-legacy',
repoId: repo.id,
path: '/repo/orca'
})
const session = makeSession({ id: 'claude:legacy-worktree', cwd: '/repo/orca/src' })
const context = buildAiVaultProjectContext({
repos: [repo],
worktrees: [worktree],
projectHostSetupProjection: makeProjection({
projects: [makeProject({ id: 'github:stablyai/orca', displayName: 'Canonical Orca' })],
setups: [
makeSetup({
repoId: repo.id,
projectId: 'github:stablyai/orca',
path: repo.path
})
]
}),
activeRepo: repo,
activeWorktree: worktree,
sessions: [session]
})
expect(context.activeProjectKey).toBe('project:github:stablyai/orca')
expect(context.sessionProjectById.get(session.id)).toMatchObject({
kind: 'repo',
key: 'project:github:stablyai/orca',
label: 'Canonical Orca'
})
})
it('uses active worktree setup project ids when active repo is unavailable', () => {
const repo = makeRepo({ id: 'repo-1', displayName: 'Orca Repo', path: '/repo/orca' })
const worktree = makeWorktree({
id: 'wt-restored',
repoId: repo.id,
path: '/repo/orca'
})
const session = makeSession({ id: 'claude:restored', cwd: '/repo/orca/src' })
const context = buildAiVaultProjectContext({
repos: [repo],
worktrees: [worktree],
projectHostSetupProjection: makeProjection({
projects: [makeProject({ id: 'github:stablyai/orca', displayName: 'Canonical Orca' })],
setups: [
makeSetup({
repoId: repo.id,
projectId: 'github:stablyai/orca',
path: repo.path
})
]
}),
activeRepo: null,
activeWorktree: worktree,
sessions: [session]
})
expect(context.activeProjectKey).toBe('project:github:stablyai/orca')
expect(context.sessionProjectById.get(session.id)?.key).toBe('project:github:stablyai/orca')
})
it('inherits setup host ids for legacy worktrees without host metadata', () => {
const repo = makeRepo({ id: 'repo-1', displayName: 'Runtime Repo', path: '/runtime/orca' })
const worktree = makeWorktree({
id: 'wt-runtime',
repoId: repo.id,
path: '/runtime/orca'
})
const session = makeSession({ id: 'claude:runtime-worktree', cwd: '/runtime/orca/src' })
const context = buildAiVaultProjectContext({
repos: [repo],
worktrees: [worktree],
projectHostSetupProjection: makeProjection({
projects: [makeProject({ id: 'project-runtime', displayName: 'Runtime Project' })],
setups: [
makeSetup({
repoId: repo.id,
projectId: 'project-runtime',
path: repo.path,
hostId: 'runtime:preview'
})
]
}),
activeRepo: repo,
activeWorktree: worktree,
sessions: [session]
})
expect(context.sessionProjectById.get(session.id)).toMatchObject({
kind: 'repo',
key: 'project:project-runtime',
hostKey: 'runtime:preview'
})
})
it('keeps project labels canonical instead of using first matched session order', () => {
const repoA = makeRepo({ id: 'repo-a', displayName: 'Fork Checkout', path: '/work/fork' })
const repoB = makeRepo({ id: 'repo-b', displayName: 'Main Checkout', path: '/work/main' })
const project = makeProject({ id: 'project-1', displayName: 'Canonical Project' })
const firstSession = makeSession({ id: 'claude:first', cwd: '/work/fork/src' })
const secondSession = makeSession({ id: 'codex:second', cwd: '/work/main/src' })
const context = buildAiVaultProjectContext({
repos: [repoA, repoB],
worktrees: [],
projectHostSetupProjection: makeProjection({
projects: [project],
setups: [
makeSetup({ repoId: repoA.id, projectId: project.id, path: repoA.path }),
makeSetup({ repoId: repoB.id, projectId: project.id, path: repoB.path })
]
}),
activeRepo: repoA,
activeWorktree: null,
sessions: [firstSession, secondSession]
})
expect(context.projectLabelByKey.get('project:project-1')).toBe('Canonical Project')
expect(context.sessionProjectById.get(firstSession.id)?.label).toBe('Canonical Project')
expect(context.sessionProjectById.get(secondSession.id)?.label).toBe('Canonical Project')
})
it('chooses the most specific nested path and lets worktrees win equal-length ties', () => {
const repo = makeRepo({ id: 'repo-root', displayName: 'Root', path: '/repo' })
const childRepo = makeRepo({ id: 'repo-child', displayName: 'Child Repo', path: '/repo/pkg' })
const worktree = makeWorktree({
id: 'wt-child',
repoId: childRepo.id,
projectId: 'child-project',
path: '/repo/pkg'
})
const session = makeSession({ id: 'claude:nested', cwd: '/repo/pkg/src' })
const context = buildAiVaultProjectContext({
repos: [repo, childRepo],
worktrees: [worktree],
projectHostSetupProjection: makeProjection({
projects: [makeProject({ id: 'child-project', displayName: 'Child Project' })],
setups: [
makeSetup({ repoId: repo.id, projectId: 'root-project', path: repo.path }),
makeSetup({ repoId: childRepo.id, projectId: 'setup-child', path: childRepo.path })
]
}),
activeRepo: childRepo,
activeWorktree: worktree,
sessions: [session]
})
expect(context.sessionProjectById.get(session.id)).toMatchObject({
key: 'project:child-project',
label: 'Child Project'
})
})
it('matches Windows paths case-insensitively across separators', () => {
const repo = makeRepo({
id: 'repo-win',
displayName: 'Windows Repo',
path: 'C:\\Users\\Ada\\Repo'
})
const session = makeSession({ id: 'claude:win', cwd: 'c:/users/ada/repo/src' })
const context = buildAiVaultProjectContext({
repos: [repo],
worktrees: [],
projectHostSetupProjection: makeProjection({
projects: [],
setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-win', path: repo.path })]
}),
activeRepo: repo,
activeWorktree: null,
sessions: [session]
})
expect(context.sessionProjectById.get(session.id)?.key).toBe('repo:repo-win')
})
it('falls back to folder when a hostless session matches multiple host buckets', () => {
const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' })
const sshRepo = makeRepo({
id: 'ssh',
displayName: 'SSH',
path: '/srv/orca',
connectionId: 'target-1'
})
const session = makeSession({ id: 'claude:ambiguous', cwd: '/srv/orca/src' })
const context = buildAiVaultProjectContext({
repos: [localRepo, sshRepo],
worktrees: [],
projectHostSetupProjection: makeProjection({
projects: [],
setups: [
makeSetup({ repoId: localRepo.id, projectId: 'repo:local', path: localRepo.path }),
makeSetup({
repoId: sshRepo.id,
projectId: 'repo:ssh',
path: sshRepo.path,
hostId: 'ssh:target-1',
connectionId: 'target-1'
})
]
}),
activeRepo: localRepo,
activeWorktree: null,
sessions: [session]
})
expect(context.sessionProjectById.get(session.id)).toMatchObject({
kind: 'folder',
key: 'folder:/srv/orca/src',
label: 'orca/src'
})
})
it('uses ProjectHostSetup host ids when detecting ambiguous host buckets', () => {
const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' })
const runtimeRepo = makeRepo({ id: 'runtime', displayName: 'Runtime', path: '/srv/orca' })
const session = makeSession({ id: 'claude:runtime-ambiguous', cwd: '/srv/orca/src' })
const context = buildAiVaultProjectContext({
repos: [localRepo, runtimeRepo],
worktrees: [],
projectHostSetupProjection: makeProjection({
projects: [],
setups: [
makeSetup({ repoId: localRepo.id, projectId: 'repo:local', path: localRepo.path }),
makeSetup({
repoId: runtimeRepo.id,
projectId: 'repo:runtime',
path: runtimeRepo.path,
hostId: 'runtime:preview'
})
]
}),
activeRepo: localRepo,
activeWorktree: null,
sessions: [session]
})
expect(context.sessionProjectById.get(session.id)).toMatchObject({
kind: 'folder',
key: 'folder:/srv/orca/src',
label: 'orca/src'
})
})
it('ignores blank setup paths instead of treating them as catch-all candidates', () => {
const repo = makeRepo({ id: 'repo-1', displayName: 'Repo', path: '/repo' })
const session = makeSession({ id: 'claude:outside', cwd: '/outside/path' })
const context = buildAiVaultProjectContext({
repos: [repo],
worktrees: [],
projectHostSetupProjection: makeProjection({
projects: [],
setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-1', path: '' })]
}),
activeRepo: repo,
activeWorktree: null,
sessions: [session]
})
expect(context.sessionProjectById.get(session.id)).toMatchObject({
kind: 'folder',
key: 'folder:/outside/path',
label: 'outside/path'
})
})
it('uses repo fallback candidates when a setup for the repo has a blank path', () => {
const repo = makeRepo({ id: 'repo-1', displayName: 'Repo', path: '/repo' })
const session = makeSession({ id: 'claude:inside-repo', cwd: '/repo/src' })
const context = buildAiVaultProjectContext({
repos: [repo],
worktrees: [],
projectHostSetupProjection: makeProjection({
projects: [],
setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-1', path: '' })]
}),
activeRepo: repo,
activeWorktree: null,
sessions: [session]
})
expect(context.sessionProjectById.get(session.id)).toMatchObject({
kind: 'repo',
key: 'repo:repo-1',
label: 'Repo'
})
})
it('maps null cwd sessions to unknown', () => {
const repo = makeRepo({ id: 'repo-1', displayName: 'Orca', path: '/repo' })
const session = makeSession({ id: 'claude:unknown', cwd: null })
const context = buildAiVaultProjectContext({
repos: [repo],
worktrees: [],
projectHostSetupProjection: makeProjection({ projects: [], setups: [] }),
activeRepo: repo,
activeWorktree: null,
sessions: [session]
})
expect(context.sessionProjectById.get(session.id)).toEqual({
kind: 'unknown',
key: 'unknown',
label: ''
})
})
})
function makeSession(overrides: Partial<AiVaultSession>): AiVaultSession {
return { ...baseSession, ...overrides }
}
function makeRepo(overrides: Partial<Repo>): Repo {
return {
id: 'repo-1',
path: '/Users/ada/orca',
displayName: 'Orca',
badgeColor: '#737373',
addedAt: 1,
...overrides
}
}
function makeProject(overrides: Partial<Project>): Project {
return {
id: 'project-1',
displayName: 'Project',
badgeColor: '#737373',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeSetup(overrides: Partial<ProjectHostSetup>): ProjectHostSetup {
return {
id: overrides.repoId ?? 'setup-1',
projectId: 'project-1',
hostId: 'local',
repoId: 'repo-1',
path: '/Users/ada/orca',
displayName: 'Orca',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeWorktree(overrides: Partial<Worktree>): Worktree {
return {
id: 'wt-1',
repoId: 'repo-1',
displayName: 'main',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
path: '/Users/ada/orca',
head: 'abc123',
branch: 'main',
isBare: false,
isMainWorktree: true,
...overrides
}
}
function makeProjection(
overrides: Partial<ProjectHostSetupProjection>
): ProjectHostSetupProjection {
return {
projects: [],
setups: [],
...overrides
}
}

View File

@ -0,0 +1,284 @@
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison,
normalizeRuntimePathSeparators
} from '../../../../shared/cross-platform-path'
export type AiVaultSessionProject = {
kind: 'repo' | 'folder' | 'unknown'
key: string
label: string
projectId?: string
repoId?: string
hostKey?: string
}
export type AiVaultProjectContext = {
activeProjectKey: string | null
activeRepoId: string | null
projectLabelByKey: Map<string, string>
sessionProjectById: Map<string, AiVaultSessionProject>
}
type SessionProjectCandidate = {
source: 'worktree' | 'setup'
normalizedPath: string
hostKey: string
projectId: string | null
repoId: string | null
}
type ProjectResolverArgs = {
repos: readonly Repo[]
worktrees: readonly Worktree[]
projectHostSetupProjection: ProjectHostSetupProjection
activeRepo: Repo | null
activeWorktree: Worktree | null
sessions: readonly AiVaultSession[]
}
export function buildAiVaultProjectContext({
repos,
worktrees,
projectHostSetupProjection,
activeRepo,
activeWorktree,
sessions
}: ProjectResolverArgs): AiVaultProjectContext {
const repoById = new Map(repos.map((repo) => [repo.id, repo]))
const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups)
const projectLabelByKey = buildProjectLabelByKey(repos, projectHostSetupProjection)
const candidates = buildProjectCandidates(
worktrees,
projectHostSetupProjection,
repoById,
setupByRepoId
)
const sessionProjectById = new Map<string, AiVaultSessionProject>()
for (const session of sessions) {
sessionProjectById.set(
session.id,
resolveSessionProject(session.cwd, candidates, projectLabelByKey)
)
}
return {
activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, setupByRepoId),
activeRepoId: activeRepo?.id ?? activeWorktree?.repoId ?? null,
projectLabelByKey,
sessionProjectById
}
}
export function toAiVaultProjectKey(
projectId: string | null | undefined,
repoId?: string | null
): string | null {
if (projectId) {
// Why: legacy projections can already use repo-prefixed project ids; wrapping
// them again would split active scope and resolved session keys.
return projectId.startsWith('repo:') ? projectId : `project:${projectId}`
}
return repoId ? `repo:${repoId}` : null
}
function buildSetupByRepoId(
setups: readonly ProjectHostSetup[]
): ReadonlyMap<string, ProjectHostSetup> {
const setupByRepoId = new Map<string, ProjectHostSetup>()
for (const setup of setups) {
if (setup.repoId && !setupByRepoId.has(setup.repoId)) {
setupByRepoId.set(setup.repoId, setup)
}
}
return setupByRepoId
}
function buildProjectLabelByKey(
repos: readonly Repo[],
projection: ProjectHostSetupProjection
): Map<string, string> {
const labels = new Map<string, string>()
const projectLabelById = new Map(
projection.projects.map((project) => [project.id, project.displayName])
)
for (const project of projection.projects) {
const key = toAiVaultProjectKey(project.id, project.sourceRepoIds[0])
if (key && !key.startsWith('repo:')) {
labels.set(key, project.displayName)
}
}
for (const repo of repos) {
labels.set(`repo:${repo.id}`, repo.displayName)
}
for (const setup of projection.setups) {
const key = toAiVaultProjectKey(setup.projectId, setup.repoId)
if (key && !labels.has(key)) {
labels.set(key, projectLabelById.get(setup.projectId) ?? setup.displayName)
}
}
return labels
}
function buildProjectCandidates(
worktrees: readonly Worktree[],
projection: ProjectHostSetupProjection,
repoById: ReadonlyMap<string, Repo>,
setupByRepoId: ReadonlyMap<string, ProjectHostSetup>
): SessionProjectCandidate[] {
const candidates: SessionProjectCandidate[] = []
const setupRepoIds = new Set<string>()
for (const worktree of worktrees) {
if (!hasCandidatePath(worktree.path)) {
continue
}
const repo = repoById.get(worktree.repoId)
const setup = setupByRepoId.get(worktree.repoId)
candidates.push({
source: 'worktree',
normalizedPath: normalizeRuntimePathForComparison(worktree.path),
hostKey:
worktree.hostId ??
setup?.hostId ??
(repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID),
projectId: worktree.projectId ?? setup?.projectId ?? null,
repoId: worktree.repoId
})
}
for (const setup of projection.setups) {
if (setup.repoId) {
if (hasCandidatePath(setup.path)) {
setupRepoIds.add(setup.repoId)
}
}
if (!hasCandidatePath(setup.path)) {
continue
}
candidates.push({
source: 'setup',
normalizedPath: normalizeRuntimePathForComparison(setup.path),
hostKey: setup.hostId || getRepoExecutionHostId(setup),
projectId: setup.projectId,
repoId: setup.repoId || null
})
}
for (const repo of repoById.values()) {
if (setupRepoIds.has(repo.id)) {
continue
}
if (!hasCandidatePath(repo.path)) {
continue
}
candidates.push({
source: 'setup',
normalizedPath: normalizeRuntimePathForComparison(repo.path),
hostKey: getRepoExecutionHostId(repo),
projectId: null,
repoId: repo.id
})
}
return candidates
}
function hasCandidatePath(pathValue: string): boolean {
return pathValue.trim().length > 0
}
function resolveSessionProject(
cwd: string | null,
candidates: readonly SessionProjectCandidate[],
projectLabelByKey: ReadonlyMap<string, string>
): AiVaultSessionProject {
if (!cwd) {
return { kind: 'unknown', key: 'unknown', label: '' }
}
const matches = candidates.filter((candidate) =>
isPathInsideOrEqual(candidate.normalizedPath, cwd)
)
const hostBuckets = new Set(matches.map((candidate) => candidate.hostKey))
if (hostBuckets.size > 1) {
// Why: session rows do not carry host ids yet, so overlapping local/SSH
// paths must stay visible without being attributed to the wrong project.
return folderProject(cwd)
}
const bestCandidate = matches.sort(compareCandidates)[0]
if (!bestCandidate) {
return folderProject(cwd)
}
const key = toAiVaultProjectKey(bestCandidate.projectId, bestCandidate.repoId)
if (!key) {
return folderProject(cwd)
}
return {
kind: 'repo',
key,
label: projectLabelByKey.get(key) ?? key,
...(bestCandidate.projectId ? { projectId: bestCandidate.projectId } : {}),
...(bestCandidate.repoId ? { repoId: bestCandidate.repoId } : {}),
hostKey: bestCandidate.hostKey
}
}
function compareCandidates(left: SessionProjectCandidate, right: SessionProjectCandidate): number {
const lengthDifference = right.normalizedPath.length - left.normalizedPath.length
if (lengthDifference !== 0) {
return lengthDifference
}
if (left.source === right.source) {
return 0
}
return left.source === 'worktree' ? -1 : 1
}
function folderProject(cwd: string): AiVaultSessionProject {
const normalizedPath = normalizeRuntimePathForComparison(cwd)
return {
kind: 'folder',
key: `folder:${normalizedPath}`,
label: compactFolderLabel(cwd)
}
}
function compactFolderLabel(pathValue: string): string {
const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean)
if (parts.length >= 2) {
return parts.slice(-2).join('/')
}
return parts[0] ?? pathValue
}
function resolveActiveProjectKey(
activeRepo: Repo | null,
activeWorktree: Worktree | null,
setupByRepoId: ReadonlyMap<string, ProjectHostSetup>
): string | null {
if (activeWorktree?.projectId) {
return toAiVaultProjectKey(activeWorktree.projectId, activeWorktree.repoId)
}
const setup =
(activeRepo ? setupByRepoId.get(activeRepo.id) : null) ??
(activeWorktree ? setupByRepoId.get(activeWorktree.repoId) : null)
if (setup) {
return toAiVaultProjectKey(setup.projectId, setup.repoId || activeRepo?.id)
}
return toAiVaultProjectKey(null, activeRepo?.id ?? activeWorktree?.repoId ?? null)
}

View File

@ -6263,7 +6263,19 @@
"ab20575a8a": "off",
"ab3a1f9068": "wsl.exe",
"ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals",
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent."
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.",
"scrollSpeed": {
"title": "Scroll Speed",
"description": "Tune normal terminal scrollback, fast modifier scrolling, and full-screen TUI wheel speed.",
"helper": "Adjust how wheel input feels in scrollback and in mouse-aware terminal apps.",
"reset": "Reset",
"normal": "Normal",
"normalDescription": "Scrollback wheel multiplier.",
"fast": "Fast",
"fastDescription": "Extra multiplier while scrolling with a modifier key.",
"tui": "TUI",
"tuiDescription": "Discrete wheel reports for full-screen terminal apps."
}
},
"TerminalSettingsPreview": {
"a63953a48a": "Preview {{value0}} theme",
@ -7899,7 +7911,11 @@
"0fe0073f0c": "Default terminal font size for new panes and live updates.",
"5930244899": "Font Size",
"ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals",
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent."
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.",
"scrollSpeed": {
"title": "Scroll Speed",
"description": "Tune normal terminal scrollback, fast modifier scrolling, and full-screen TUI wheel speed."
}
},
"windows": {
"search": {
@ -9122,7 +9138,10 @@
"hideEmptySessions": "Hide empty sessions",
"workspaceScope": "Workspace",
"worktreeScope": "Worktree",
"globalScope": "Global"
"globalScope": "Global",
"projectScope": "Project",
"currentProjectLower": "current project",
"project": "Project"
},
"AiVaultSessionDetails": {
"originalAsk": "Original ask",

View File

@ -6219,7 +6219,19 @@
"ab20575a8a": "apagado",
"ab3a1f9068": "wsl.exe",
"ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals",
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent."
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.",
"scrollSpeed": {
"title": "Velocidad de desplazamiento",
"description": "Ajusta el desplazamiento normal del terminal, el desplazamiento rápido con modificador y la velocidad de rueda en TUI de pantalla completa.",
"helper": "Ajusta cómo se siente la entrada de la rueda en el historial y en apps de terminal compatibles con mouse.",
"reset": "Restablecer",
"normal": "Normal",
"normalDescription": "Multiplicador de rueda del historial.",
"fast": "Rápido",
"fastDescription": "Multiplicador adicional al desplazarse con una tecla modificadora.",
"tui": "TUI",
"tuiDescription": "Eventos discretos de rueda para apps de terminal de pantalla completa."
}
},
"TerminalSettingsPreview": {
"a63953a48a": "Vista previa del tema {{value0}}",
@ -7855,7 +7867,11 @@
"keyword_custom": "custom"
},
"ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals",
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent."
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.",
"scrollSpeed": {
"title": "Velocidad de desplazamiento",
"description": "Ajusta el desplazamiento normal del terminal, el desplazamiento rápido con modificador y la velocidad de rueda en TUI de pantalla completa."
}
},
"windows": {
"search": {
@ -9115,7 +9131,10 @@
"hideEmptySessions": "Hide empty sessions",
"workspaceScope": "Workspace",
"worktreeScope": "Worktree",
"globalScope": "Global"
"globalScope": "Global",
"projectScope": "Proyecto",
"currentProjectLower": "proyecto actual",
"project": "Proyecto"
},
"AiVaultSessionDetails": {
"updated": "Updated",

View File

@ -2313,7 +2313,7 @@
"TerminalContextMenu": {
"b4cdd9314e": "クリアスクリーン",
"8c17d6786d": "ペインを閉じる",
"copyTerminalId": "Copy Terminal ID",
"copyTerminalId": "ターミナル ID をコピー",
"2cf85a6a55": "ペインIDのコピー",
"39809d152f": "タイトルを設定…",
"06c2b0f043": "ペインのサイズを均等化する",
@ -2405,9 +2405,9 @@
"a29b9faa01": "ペイン ID がコピーされました",
"terminal": {
"id": {
"copied": "Terminal ID をコピーしました",
"copied": "ターミナル ID をコピーしました",
"copy": {
"failed": "Terminal ID をコピーできません"
"failed": "ターミナル ID をコピーできません"
}
}
}
@ -6241,7 +6241,19 @@
"ab20575a8a": "オフ",
"ab3a1f9068": "wsl.exe",
"ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals",
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent."
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.",
"scrollSpeed": {
"title": "スクロール速度",
"description": "通常のターミナルスクロールバック、修飾キーでの高速スクロール、全画面 TUI のホイール速度を調整します。",
"helper": "ホイール入力の感触をスクロールバックとマウス対応のターミナルアプリで調整します。",
"reset": "リセット",
"normal": "通常",
"normalDescription": "スクロールバックのホイール倍率。",
"fast": "高速",
"fastDescription": "修飾キーを押しながらスクロールするときの追加倍率。",
"tui": "TUI",
"tuiDescription": "全画面ターミナルアプリ向けの離散的なホイール通知。"
}
},
"TerminalSettingsPreview": {
"a63953a48a": "{{value0}} テーマのプレビュー",
@ -7877,7 +7889,11 @@
"0fe0073f0c": "新規ペインとライブ アップデートのデフォルトの terminal フォント サイズ。",
"5930244899": "フォントサイズ",
"ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals",
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent."
"ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.",
"scrollSpeed": {
"title": "スクロール速度",
"description": "通常のターミナルスクロールバック、修飾キーでの高速スクロール、全画面 TUI のホイール速度を調整します。"
}
},
"windows": {
"search": {
@ -9115,7 +9131,10 @@
"hideEmptySessions": "Hide empty sessions",
"workspaceScope": "ワークスペース",
"worktreeScope": "Worktree",
"globalScope": "Global"
"globalScope": "Global",
"projectScope": "プロジェクト",
"currentProjectLower": "現在のプロジェクト",
"project": "プロジェクト"
},
"AiVaultSessionDetails": {
"updated": "Updated",

View File

@ -2313,7 +2313,7 @@
"TerminalContextMenu": {
"b4cdd9314e": "화면 지우기",
"8c17d6786d": "창 닫기",
"copyTerminalId": "Copy Terminal ID",
"copyTerminalId": "터미널 ID 복사",
"2cf85a6a55": "창 ID 복사",
"39809d152f": "제목 설정…",
"06c2b0f043": "창 크기 균등화",
@ -2405,7 +2405,7 @@
"a29b9faa01": "창 ID가 복사되었습니다.",
"terminal": {
"id": {
"copied": "터미널 ID가 복사되었습니다",
"copied": "터미널 ID를 복사했습니다",
"copy": {
"failed": "터미널 ID를 복사할 수 없습니다"
}
@ -6204,7 +6204,19 @@
"ab20575a8a": "끄기",
"ab3a1f9068": "wsl.exe",
"ask_before_closing_running_terminals_title": "실행 중인 Terminals을 닫기 전에 확인",
"ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인 창을 표시합니다."
"ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인 창을 표시합니다.",
"scrollSpeed": {
"title": "스크롤 속도",
"description": "일반 터미널 스크롤백, 수정 키 빠른 스크롤, 전체 화면 TUI 휠 속도를 조정합니다.",
"helper": "휠 입력이 스크롤백과 마우스 인식 터미널 앱에서 느껴지는 방식을 조정합니다.",
"reset": "재설정",
"normal": "일반",
"normalDescription": "스크롤백 휠 배율.",
"fast": "빠름",
"fastDescription": "수정 키로 스크롤할 때 적용되는 추가 배율.",
"tui": "TUI",
"tuiDescription": "전체 화면 터미널 앱을 위한 개별 휠 보고."
}
},
"TerminalSettingsPreview": {
"a63953a48a": "{{value0}} 테마 미리보기",
@ -7840,7 +7852,11 @@
"keyword_custom": "custom"
},
"ask_before_closing_running_terminals_title": "실행 중인 Terminals을 닫기 전에 확인",
"ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인을 표시합니다."
"ask_before_closing_running_terminals_description": "실행 중인 명령이나 agent가 있는 terminal을 닫기 전에 확인을 표시합니다.",
"scrollSpeed": {
"title": "스크롤 속도",
"description": "일반 터미널 스크롤백, 수정 키 빠른 스크롤, 전체 화면 TUI 휠 속도를 조정합니다."
}
},
"windows": {
"search": {
@ -9115,7 +9131,10 @@
"hideEmptySessions": "빈 세션 숨기기",
"workspaceScope": "워크스페이스",
"worktreeScope": "워크트리",
"globalScope": "전역"
"globalScope": "전역",
"projectScope": "프로젝트",
"currentProjectLower": "현재 프로젝트",
"project": "프로젝트"
},
"AiVaultSessionDetails": {
"updated": "업데이트됨",

View File

@ -2313,7 +2313,7 @@
"TerminalContextMenu": {
"b4cdd9314e": "清晰的屏幕",
"8c17d6786d": "关闭窗格",
"copyTerminalId": "复制 Terminal ID",
"copyTerminalId": "复制终端 ID",
"2cf85a6a55": "复制窗格 ID",
"39809d152f": "设置标题...",
"06c2b0f043": "均衡窗格大小",
@ -6204,7 +6204,19 @@
"ab20575a8a": "关",
"ab3a1f9068": "执行程序",
"ask_before_closing_running_terminals_title": "关闭运行中的 Terminal 前询问",
"ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。"
"ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。",
"scrollSpeed": {
"title": "滚动速度",
"description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 滚轮速度。",
"helper": "调整滚轮输入在回滚和支持鼠标的终端应用中的手感。",
"reset": "重置",
"normal": "普通",
"normalDescription": "回滚滚轮倍数。",
"fast": "快速",
"fastDescription": "按住修饰键滚动时的额外倍数。",
"tui": "TUI",
"tuiDescription": "面向全屏终端应用的离散滚轮报告。"
}
},
"TerminalSettingsPreview": {
"a63953a48a": "预览 {{value0}} 主题",
@ -7840,7 +7852,11 @@
"keyword_custom": "自定义"
},
"ask_before_closing_running_terminals_title": "关闭运行中的 Terminal 前询问",
"ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。"
"ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。",
"scrollSpeed": {
"title": "滚动速度",
"description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 滚轮速度。"
}
},
"windows": {
"search": {
@ -9115,7 +9131,10 @@
"hideEmptySessions": "隐藏空会话",
"workspaceScope": "工作区",
"worktreeScope": "工作树",
"globalScope": "全局"
"globalScope": "全局",
"projectScope": "项目",
"currentProjectLower": "当前项目",
"project": "项目"
},
"AiVaultSessionDetails": {
"updated": "更新时间",

View File

@ -19,9 +19,9 @@ export const AI_VAULT_AGENTS = [
] as const satisfies readonly TuiAgent[]
export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number]
export type AiVaultScope = 'workspace' | 'all'
export type AiVaultScope = 'workspace' | 'project' | 'all'
export type AiVaultSort = 'updated' | 'created'
export type AiVaultGroup = 'folder' | 'agent'
export type AiVaultGroup = 'project' | 'folder' | 'agent'
export const AI_VAULT_AGENT_LABELS = {
claude: 'Claude',