{
+ 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 00000000000..5f564cdcdcd
--- /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 97772cac169..5acfd125b09 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 376e52a220f..f0422547761 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 00000000000..fa53c685da8
--- /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 00000000000..fdb78325025
--- /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 f08537a2769..164b8eaf230 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 a60a6d2db2a..970eaeba0b5 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 b25277abe07..fd5617c8836 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 beda6a43a8e..86595e86d71 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 0723b0e1ef0..5618cd61179 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 2df68373451..b977489b4bc 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',
From a7a22d5e183c37e25c80604a90f497f2e386a9e4 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 23 Jun 2026 00:38:17 -0700
Subject: [PATCH 20/57] Display draft PR status under tasks (#6119)
* fix: address review findings
* Improve accessibility and refactor GitHub work item status helpers
- Add aria-label and aria-hidden attributes to the task ID pill and icons to make them screen-reader friendly.
- Refactor getTaskPageGitHubPRIconTone to use an exhaustive switch statement with explicit type-safety fallbacks.
- Add comprehensive test coverage for draft checks, state labels, and icon tone edge cases.
---
src/renderer/src/components/TaskPage.tsx | 85 ++++++++++++-------
...ask-page-github-work-item-status-badge.tsx | 27 ++++++
.../task-page-github-work-item-status.test.ts | 67 +++++++++++++++
.../task-page-github-work-item-status.ts | 72 ++++++++++++++++
4 files changed, 222 insertions(+), 29 deletions(-)
create mode 100644 src/renderer/src/components/task-page-github-work-item-status-badge.tsx
create mode 100644 src/renderer/src/components/task-page-github-work-item-status.test.ts
create mode 100644 src/renderer/src/components/task-page-github-work-item-status.ts
diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx
index 02036030118..92c6ebe5544 100644
--- a/src/renderer/src/components/TaskPage.tsx
+++ b/src/renderer/src/components/TaskPage.tsx
@@ -22,6 +22,7 @@ import {
Files,
GitMerge,
GitPullRequest,
+ GitPullRequestDraft,
List,
LoaderCircle,
Lock,
@@ -210,6 +211,11 @@ import {
resolveTaskPageGitHubStatusStateDraft,
updateTaskPageGitHubStatusLocalState
} from '@/components/task-page-github-status-state'
+import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page-github-work-item-status-badge'
+import {
+ getTaskPageGitHubPRIconTone,
+ isTaskPageGitHubDraftPR
+} from '@/components/task-page-github-work-item-status'
import {
createTaskPageJiraLoadFailureState,
type TaskPageJiraLoadError
@@ -1119,11 +1125,7 @@ function GHStatusCell({
)
if (item.type !== 'issue' || !repo) {
- return (
-
- {translate('auto.components.TaskPage.606a85c774', 'Open')}
-
- )
+ return
}
return (
@@ -8817,6 +8819,29 @@ export default function TaskPage(): React.JSX.Element {
const attachedWorkspaceLabel = attachedWorkspace
? getGithubWorkItemWorkspaceAttachmentLabel(attachedWorkspace)
: null
+ const githubTaskIdPill = (
+
+ {item.type === 'pr' ? (
+ isTaskPageGitHubDraftPR(item) ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+ #{item.number}
+
+ )
return (
// Why: the row is a clickable container rather than a
//