From 5311ab3867fe787fefc5101da273a2ea9af22d64 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:27:21 -0700 Subject: [PATCH] Group agent session history by project (#5432) Co-authored-by: Orca --- .../components/right-sidebar/AiVaultPanel.tsx | 109 +++- .../right-sidebar/AiVaultPanelControls.tsx | 57 +- .../right-sidebar/AiVaultPanelHeader.tsx | 18 +- .../ai-vault-scope-state.test.ts | 67 +++ .../right-sidebar/ai-vault-scope-state.ts | 23 + .../ai-vault-session-filters.test.ts | 83 +++ .../right-sidebar/ai-vault-session-filters.ts | 62 ++- .../ai-vault-session-projects.test.ts | 506 ++++++++++++++++++ .../ai-vault-session-projects.ts | 284 ++++++++++ src/renderer/src/i18n/locales/en.json | 25 +- src/renderer/src/i18n/locales/es.json | 25 +- src/renderer/src/i18n/locales/ja.json | 31 +- src/renderer/src/i18n/locales/ko.json | 29 +- src/renderer/src/i18n/locales/zh.json | 27 +- src/shared/ai-vault-types.ts | 4 +- 15 files changed, 1280 insertions(+), 70 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 4a6429580..810ecdc89 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -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('workspace') + const [scope, setScope] = useState('project') const [sort, setSort] = useState('updated') - const [group, setGroup] = useState('folder') + const [group, setGroup] = useState('project') const [hideEmptySessions, setHideEmptySessions] = useState(true) const [agents, setAgents] = useState([...AI_VAULT_AGENTS]) const [sessions, setSessions] = useState([]) @@ -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 => { 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} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index a241117a4..f9feda064 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -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' + ) } )} > - - {allLabel} - - {worktreeLabel} + {workspaceLabel} + + + {projectLabel} + + + {allLabel} ) @@ -253,6 +272,10 @@ export function VaultViewMenu({ value={group} onValueChange={(value) => onGroupChange(value as AiVaultGroup)} > + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.project', 'Project')} + {translate('auto.components.right.sidebar.AiVaultPanelControls.folder', 'Folder')} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 9c0af2468..309ecdbea 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -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 (
-
+
{/* Why: below 300px the header competes with fixed controls, so compact copy prevents overlap. */} @@ -97,11 +99,6 @@ export function AiVaultPanelHeader({
-
+
+ +
+
{ + 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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts new file mode 100644 index 000000000..5f564cdcd --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts @@ -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) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 97772cac1..5acfd125b 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts index 376e52a22..f04225477 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts @@ -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 + projectLabelByKey?: ReadonlyMap 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 + projectLabelByKey?: ReadonlyMap + } = {} ): AiVaultSessionGroup[] { const groups = new Map() 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 +): 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 + projectLabelByKey?: ReadonlyMap + } +): Pick { + 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' } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts new file mode 100644 index 000000000..fa53c685d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -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 { + return { ...baseSession, ...overrides } +} + +function makeRepo(overrides: Partial): Repo { + return { + id: 'repo-1', + path: '/Users/ada/orca', + displayName: 'Orca', + badgeColor: '#737373', + addedAt: 1, + ...overrides + } +} + +function makeProject(overrides: Partial): Project { + return { + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeSetup(overrides: Partial): 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 { + 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 { + return { + projects: [], + setups: [], + ...overrides + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts new file mode 100644 index 000000000..fdb783250 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -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 + sessionProjectById: Map +} + +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() + + 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 { + const setupByRepoId = new Map() + 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 { + const labels = new Map() + 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, + setupByRepoId: ReadonlyMap +): SessionProjectCandidate[] { + const candidates: SessionProjectCandidate[] = [] + const setupRepoIds = new Set() + + 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 +): 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 | 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) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f08537a27..164b8eaf2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a60a6d2db..970eaeba0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b25277abe..fd5617c88 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index beda6a43a..86595e86d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "업데이트됨", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0723b0e1e..5618cd611 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "更新时间", diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 2df683734..b977489b4 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -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',