Group agent session history by project (#5432)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
edf98f6578
commit
5311ab3867
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "업데이트됨",
|
||||
|
|
|
|||
|
|
@ -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": "更新时间",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue