Show path-only Claude sessions in workspace history

This commit is contained in:
brennanb2025
2026-06-23 23:49:26 -07:00
parent 5106e33947
commit 4d0cd4e6b5
9 changed files with 463 additions and 57 deletions
+32
View File
@@ -0,0 +1,32 @@
import { join } from 'path'
// Per-test scan roots that point every agent source at a throwaway temp dir, so
// the scanner never reads the developer's real ~/.claude, ~/.codex, or the
// shared OpenCode SQLite database while tests run.
export function isolatedScanRoots(root: string) {
return {
claudeProjectsDir: join(root, 'claude-projects'),
codexSessionsDir: join(root, 'codex-sessions'),
geminiSessionsDir: join(root, 'gemini-sessions'),
copilotSessionsDir: join(root, 'copilot-sessions'),
cursorProjectsDir: join(root, 'cursor-projects'),
opencodeStorageDir: join(root, 'opencode-storage'),
// Why: prevent the SQLite scanner from picking up the real
// ~/.local/share/opencode/opencode.db during tests.
opencodeDbPaths: [] as readonly string[],
grokSessionsDir: join(root, 'grok-sessions'),
devinTranscriptsDir: join(root, 'devin-transcripts'),
hermesSessionsDir: join(root, 'hermes-sessions'),
rovoSessionsDir: join(root, 'rovo-sessions'),
openclawStateDir: join(root, 'openclaw-state'),
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
piSessionsDir: join(root, 'pi-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions')
}
}
export function jsonLines(records: unknown[]): string {
return records.map((record) => JSON.stringify(record)).join('\n')
}
@@ -0,0 +1,60 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { isolatedScanRoots, jsonLines } from './isolated-scan-roots'
import { scanAiVaultSessions } from './session-scanner'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
describe('scanAiVaultSessions path-only Claude transcripts', () => {
it('keeps cwd null because the scanner does not decode the project folder', async () => {
// The scanner intentionally does NOT decode the Claude project folder name
// back into a cwd (decoding is lossy for names with punctuation). It keeps
// cwd null; the renderer's workspace filter and resume builder match the file
// path against known worktree paths instead (see ai-vault-session-filters and
// ai-vault-resume-command tests, which prove the user-visible bug is fixed).
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-claude-project-path-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const claudeProjectDir = join(
roots.claudeProjectsDir,
'-Users-ada-orca-workspaces-orca-path-only-session'
)
await mkdir(claudeProjectDir, { recursive: true })
const filePath = join(claudeProjectDir, '77777777-1111-4222-8333-444444444444.jsonl')
await writeFile(
filePath,
jsonLines([
{
type: 'user',
sessionId: '77777777-1111-4222-8333-444444444444',
timestamp: '2026-06-12T10:00:00.000Z',
isMeta: false,
message: { role: 'user', content: 'Find the path-only Claude session' }
}
])
)
const result = await scanAiVaultSessions({
...roots,
platform: 'darwin'
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'claude',
sessionId: '77777777-1111-4222-8333-444444444444',
cwd: null,
filePath,
resumeCommand: "claude --resume '77777777-1111-4222-8333-444444444444'"
})
})
})
+1 -28
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types'
import { isolatedScanRoots, jsonLines } from './isolated-scan-roots'
import { scanAiVaultSessions } from './session-scanner'
let tempRoots: string[] = []
@@ -13,34 +14,6 @@ afterEach(async () => {
tempRoots = []
})
function isolatedScanRoots(root: string) {
return {
claudeProjectsDir: join(root, 'claude-projects'),
codexSessionsDir: join(root, 'codex-sessions'),
geminiSessionsDir: join(root, 'gemini-sessions'),
copilotSessionsDir: join(root, 'copilot-sessions'),
cursorProjectsDir: join(root, 'cursor-projects'),
opencodeStorageDir: join(root, 'opencode-storage'),
// Why: prevent the SQLite scanner from picking up the real
// ~/.local/share/opencode/opencode.db during tests.
opencodeDbPaths: [] as readonly string[],
grokSessionsDir: join(root, 'grok-sessions'),
devinTranscriptsDir: join(root, 'devin-transcripts'),
hermesSessionsDir: join(root, 'hermes-sessions'),
rovoSessionsDir: join(root, 'rovo-sessions'),
openclawStateDir: join(root, 'openclaw-state'),
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
piSessionsDir: join(root, 'pi-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions')
}
}
function jsonLines(records: unknown[]): string {
return records.map((record) => JSON.stringify(record)).join('\n')
}
describe('scanAiVaultSessions', () => {
it('indexes Claude and Codex transcripts with resume commands', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-'))
@@ -86,6 +86,70 @@ describe('filterAiVaultSessions', () => {
).toEqual(['claude:old-path'])
})
it('includes a path-only Claude session whose file matches the active worktree path', () => {
const pathOnlySession: AiVaultSession = {
...baseSession,
id: 'claude:path-only',
cwd: null,
filePath:
'/Users/ada/.claude/projects/-Users-ada-orca-workspaces-orca-path-only-session/sess.jsonl'
}
expect(
filterAiVaultSessions([pathOnlySession], {
query: '',
agents: ['claude'],
scope: 'workspace',
sort: 'updated',
activeWorktreePaths: ['/Users/ada/orca/workspaces/orca/path-only-session'],
hideEmptySessions: true
}).map((session) => session.id)
).toEqual(['claude:path-only'])
})
it('excludes a path-only Claude session for a non-matching worktree path', () => {
const pathOnlySession: AiVaultSession = {
...baseSession,
id: 'claude:path-only',
cwd: null,
filePath:
'/Users/ada/.claude/projects/-Users-ada-orca-workspaces-orca-path-only-session/sess.jsonl'
}
expect(
filterAiVaultSessions([pathOnlySession], {
query: '',
agents: ['claude'],
scope: 'workspace',
sort: 'updated',
activeWorktreePaths: ['/Users/ada/orca/workspaces/orca/other-session'],
hideEmptySessions: true
})
).toEqual([])
})
it('does not apply the path-only fallback to non-Claude null-cwd sessions', () => {
const codexPathOnly: AiVaultSession = {
...baseSession,
id: 'codex:path-only',
agent: 'codex',
cwd: null,
filePath:
'/Users/ada/.claude/projects/-Users-ada-orca-workspaces-orca-path-only-session/sess.jsonl'
}
expect(
filterAiVaultSessions([codexPathOnly], {
query: '',
agents: ['codex'],
scope: 'workspace',
sort: 'updated',
activeWorktreePaths: ['/Users/ada/orca/workspaces/orca/path-only-session'],
hideEmptySessions: true
})
).toEqual([])
})
it('returns no workspace matches when active workspace paths are empty', () => {
expect(
filterAiVaultSessions([baseSession], {
@@ -5,6 +5,7 @@ import {
normalizeRuntimePathSeparators
} from '../../../../shared/cross-platform-path'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import { isClaudeSessionFileForWorkspace } from '../../../../shared/claude-project-path'
import { parseWslUncPath } from '../../../../shared/wsl-paths'
import type {
AiVaultAgent,
@@ -71,16 +72,8 @@ export function filterAiVaultSessions(
if (filters.hideEmptySessions && session.messageCount === 0) {
return false
}
if (filters.scope === 'workspace') {
const cwd = session.cwd
if (
!cwd ||
!filters.activeWorktreePaths.some((pathValue) =>
isAiVaultSessionInWorkspacePath(pathValue, cwd)
)
) {
return false
}
if (filters.scope === 'workspace' && !isSessionInWorkspaceScope(session, filters)) {
return false
}
if (filters.scope === 'project') {
if (!filters.activeProjectKey) {
@@ -278,6 +271,26 @@ function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void
paths.push(trimmedPath)
}
function isSessionInWorkspaceScope(
session: AiVaultSession,
filters: Pick<AiVaultSessionFilterState, 'activeWorktreePaths'>
): boolean {
const cwd = session.cwd
if (cwd) {
return filters.activeWorktreePaths.some((pathValue) =>
isAiVaultSessionInWorkspacePath(pathValue, cwd)
)
}
// Path-only Claude transcripts encode their cwd only in the project folder
// name; match the file path against known worktree paths instead.
return (
session.agent === 'claude' &&
filters.activeWorktreePaths.some((pathValue) =>
isClaudeSessionFileForWorkspace(session.filePath, pathValue)
)
)
}
function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean {
if (isPathInsideOrEqual(workspacePath, sessionCwd)) {
return true
@@ -59,12 +59,79 @@ describe('ai vault resume command runtime', () => {
agent: 'claude',
sessionId: 'session one',
cwd: 'C:\\Users\\alice\\repo',
codexHome: null
codexHome: null,
filePath: 'C:\\Users\\alice\\.claude\\projects\\repo\\s.jsonl'
}
})
).toBe('cmd /d /s /c "cd /d ""C:\\Users\\alice\\repo"" && claude --resume ""session one"""')
})
it('resumes a path-only Claude session into the matching worktree cwd', () => {
const state = makeState({
worktreePath: '/Users/ada/orca/workspaces/orca/path-only-session',
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
})
expect(
buildAiVaultResumeCommandForWorktree({
state,
worktreeId: 'repo-1::worktree-1',
session: {
agent: 'claude',
sessionId: 'session one',
cwd: null,
codexHome: null,
filePath:
'/Users/ada/.claude/projects/-Users-ada-orca-workspaces-orca-path-only-session/s.jsonl'
}
})
).toBe(
"cd '/Users/ada/orca/workspaces/orca/path-only-session' && claude --resume 'session one'"
)
})
it('keeps a no-cwd command when a path-only Claude session does not match the worktree', () => {
const state = makeState({
worktreePath: '/Users/ada/orca/workspaces/orca/other-session',
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
})
expect(
buildAiVaultResumeCommandForWorktree({
state,
worktreeId: 'repo-1::worktree-1',
session: {
agent: 'claude',
sessionId: 'session one',
cwd: null,
codexHome: null,
filePath:
'/Users/ada/.claude/projects/-Users-ada-orca-workspaces-orca-path-only-session/s.jsonl'
}
})
).toBe("claude --resume 'session one'")
})
it('resumes a WSL path-only Claude session into the linux cwd', () => {
const state = makeState({
worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo'
})
expect(
buildAiVaultResumeCommandForWorktree({
state,
worktreeId: 'repo-1::worktree-1',
session: {
agent: 'claude',
sessionId: 'session one',
cwd: null,
codexHome: null,
filePath: '/Users/ada/.claude/projects/-home-ada-repo/s.jsonl'
}
})
).toBe("cd '/home/ada/repo' && claude --resume 'session one'")
})
it('uses POSIX command wrapping for Windows-path projects forced to WSL', () => {
const state = makeState({
worktreePath: 'C:\\Users\\alice\\repo',
@@ -80,7 +147,8 @@ describe('ai vault resume command runtime', () => {
agent: 'claude',
sessionId: 'session one',
cwd: '/home/alice/repo',
codexHome: null
codexHome: null,
filePath: '/home/alice/.claude/projects/repo/s.jsonl'
}
})
).toBe("cd '/home/alice/repo' && claude --resume 'session one'")
@@ -107,7 +175,8 @@ describe('ai vault resume command runtime', () => {
agent: 'codex',
sessionId: 'session one',
cwd: '/home/alice/repo',
codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex',
filePath: '/home/alice/.codex/sessions/s.jsonl'
}
})
).toBe("cd '/home/alice/repo' && CODEX_HOME='/home/alice/.codex' codex resume 'session one'")
+52 -16
View File
@@ -1,32 +1,76 @@
import { buildAiVaultResumeCommand, type AiVaultSession } from '../../../shared/ai-vault-types'
import { resolveClaudeSessionWorkspaceCwd } from '../../../shared/claude-project-path'
import { parseWslUncPath } from '../../../shared/wsl-paths'
import type { AppState } from '@/store/types'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
type AiVaultResumeCommandSession = Pick<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'>
type AiVaultResumeWorktree = { id: string; path?: string }
type AiVaultResumeState = Pick<
AppState,
'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo'
>
type AiVaultResumeCommandSession = Pick<
AiVaultSession,
'agent' | 'sessionId' | 'cwd' | 'codexHome' | 'filePath'
>
export function buildAiVaultResumeCommandForWorktree(args: {
state: Pick<
AppState,
'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo'
>
state: AiVaultResumeState
worktreeId?: string | null
session: AiVaultResumeCommandSession
commandOverride?: string | null
}): string {
const platform = getAiVaultResumePlatform(args.state, args.worktreeId)
const codexHome = getAiVaultResumeCodexHome(args.session.codexHome, platform)
const cwd = resolveAiVaultResumeCwd(args.state, args.worktreeId, args.session)
return buildAiVaultResumeCommand({
agent: args.session.agent,
sessionId: args.session.sessionId,
cwd: args.session.cwd,
cwd,
platform,
commandOverride: args.commandOverride,
codexHome
})
}
function resolveAiVaultResumeCwd(
state: AiVaultResumeState,
worktreeId: string | null | undefined,
session: AiVaultResumeCommandSession
): string | null {
if (session.cwd) {
return session.cwd
}
// Path-only Claude transcripts carry no cwd; resume into the target worktree
// only when its path encodes to the transcript's Claude project folder.
if (session.agent !== 'claude') {
return null
}
const worktreePath = findAiVaultResumeWorktree(state, worktreeId)?.path
if (!worktreePath) {
return null
}
return resolveClaudeSessionWorkspaceCwd(session.filePath, worktreePath)
}
function findAiVaultResumeWorktree(
state: AiVaultResumeState,
worktreeId: string | null | undefined
): AiVaultResumeWorktree | null {
const targetWorktreeId = worktreeId ?? state.activeWorktreeId
if (!targetWorktreeId) {
return null
}
return (
Object.values(state.worktreesByRepo ?? {})
.flat()
.find((candidate) => candidate.id === targetWorktreeId) ?? null
)
}
function getAiVaultResumeCodexHome(
codexHome: string | null,
platform: NodeJS.Platform
@@ -40,10 +84,7 @@ function getAiVaultResumeCodexHome(
}
export function getAiVaultResumePlatform(
state: Pick<
AppState,
'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo'
>,
state: AiVaultResumeState,
worktreeId?: string | null
): NodeJS.Platform {
const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId, CLIENT_PLATFORM)
@@ -54,11 +95,6 @@ export function getAiVaultResumePlatform(
return 'linux'
}
const targetWorktreeId = worktreeId ?? state.activeWorktreeId
const worktree = targetWorktreeId
? Object.values(state.worktreesByRepo ?? {})
.flat()
.find((candidate) => candidate.id === targetWorktreeId)
: null
const worktree = findAiVaultResumeWorktree(state, worktreeId)
return worktree?.path && parseWslUncPath(worktree.path) ? 'linux' : CLIENT_PLATFORM
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import {
encodeClaudeProjectPath,
isClaudeSessionFileForWorkspace,
resolveClaudeSessionWorkspaceCwd
} from './claude-project-path'
describe('encodeClaudeProjectPath', () => {
it('replaces every non-alphanumeric char with a dash without collapsing runs', () => {
expect(encodeClaudeProjectPath('/Users/ada/orca/workspaces/orca/path-only-session')).toBe(
'-Users-ada-orca-workspaces-orca-path-only-session'
)
// `/.` produces `--`, not a single dash (no run collapsing).
expect(encodeClaudeProjectPath('/Users/ada/.superset/worktrees/foo')).toBe(
'-Users-ada--superset-worktrees-foo'
)
expect(encodeClaudeProjectPath('/Users/ada/source/orca/.claude/worktrees/foo')).toBe(
'-Users-ada-source-orca--claude-worktrees-foo'
)
})
it('preserves POSIX case but lowercases Windows-flavored paths', () => {
expect(encodeClaudeProjectPath('/Users/Ada/Repo')).toBe('-Users-Ada-Repo')
expect(encodeClaudeProjectPath('C:\\Users\\Ada\\repo')).toBe('c--users-ada-repo')
})
it('trims a trailing separator before encoding', () => {
expect(encodeClaudeProjectPath('/Users/ada/repo/')).toBe('-Users-ada-repo')
})
})
describe('resolveClaudeSessionWorkspaceCwd', () => {
const fileFor = (folder: string): string =>
`/Users/ada/.claude/projects/${folder}/77777777-1111-4222-8333-444444444444.jsonl`
it('matches an all-slash workspace path to its Claude folder', () => {
const workspace = '/Users/ada/orca/workspaces/orca/path-only-session'
expect(
resolveClaudeSessionWorkspaceCwd(
fileFor('-Users-ada-orca-workspaces-orca-path-only-session'),
workspace
)
).toBe(workspace)
})
it('matches a dotted/underscored worktree path a slash-only encoder would miss', () => {
const workspace = '/Users/ada/source/orca/.claude/worktrees/my_project'
expect(
resolveClaudeSessionWorkspaceCwd(
fileFor('-Users-ada-source-orca--claude-worktrees-my-project'),
workspace
)
).toBe(workspace)
})
it('does not match when a single encoded character differs', () => {
expect(
resolveClaudeSessionWorkspaceCwd(
fileFor('-Users-ada-orca-workspaces-orca-path-only-sessions'),
'/Users/ada/orca/workspaces/orca/path-only-session'
)
).toBeNull()
})
it('documents punctuation-variant paths collide under Claude project encoding', () => {
const folder = fileFor('-Users-ada-orca-workspaces-foo-bar')
expect(resolveClaudeSessionWorkspaceCwd(folder, '/Users/ada/orca/workspaces/foo-bar')).toBe(
'/Users/ada/orca/workspaces/foo-bar'
)
expect(resolveClaudeSessionWorkspaceCwd(folder, '/Users/ada/orca/workspaces/foo_bar')).toBe(
'/Users/ada/orca/workspaces/foo_bar'
)
})
it('matches a WSL UNC worktree against its linux-encoded folder and returns the linux cwd', () => {
expect(
resolveClaudeSessionWorkspaceCwd(
fileFor('-home-ada-repo'),
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo'
)
).toBe('/home/ada/repo')
})
it('tolerates Windows case drift between recorded cwd and stored worktree path', () => {
expect(
resolveClaudeSessionWorkspaceCwd(fileFor('c--users-ada-repo'), 'C:\\Users\\Ada\\repo')
).toBe('C:\\Users\\Ada\\repo')
})
it('exposes a boolean predicate', () => {
expect(isClaudeSessionFileForWorkspace(fileFor('-Users-ada-repo'), '/Users/ada/repo')).toBe(
true
)
expect(isClaudeSessionFileForWorkspace(fileFor('-Users-ada-repo'), '/Users/ada/other')).toBe(
false
)
})
})
+60
View File
@@ -0,0 +1,60 @@
import {
isWindowsAbsolutePathLike,
normalizeRuntimePathForComparison,
normalizeRuntimePathSeparators
} from './cross-platform-path'
import { parseWslUncPath } from './wsl-paths'
// Claude Code names each project directory after the session's absolute cwd by
// replacing EVERY non-alphanumeric character with '-' — it does not collapse
// runs of '-' and preserves case (verified against real on-disk names, e.g.
// `/Users/ada/.claude/worktrees/foo` -> `-Users-ada--claude-worktrees-foo`).
// We never decode these names (lossy and non-injective); instead we encode a
// KNOWN workspace path and compare, which keeps the fallback scoped to the
// worktree the user is currently viewing/resuming from.
export function encodeClaudeProjectPath(workspacePath: string): string {
return normalizeRuntimePathForComparison(workspacePath).replace(/[^a-zA-Z0-9]/g, '-')
}
// Returns the effective cwd to use for a path-only Claude transcript when its
// project folder encodes exactly to one of the given workspace candidates, or
// null when none match. For WSL UNC worktrees the returned cwd is the Linux
// path so POSIX resume commands cd into a real directory, not a UNC path.
export function resolveClaudeSessionWorkspaceCwd(
filePath: string,
workspacePath: string
): string | null {
const folder = claudeProjectFolderName(filePath)
if (!folder) {
return null
}
for (const candidate of claudeWorkspaceMatchCandidates(workspacePath)) {
// Claude preserves the original cwd casing in the folder name, so lowercase
// it only on the Windows branch to mirror normalizeRuntimePathForComparison.
const folderKey = isWindowsAbsolutePathLike(candidate) ? folder.toLowerCase() : folder
if (encodeClaudeProjectPath(candidate) === folderKey) {
return candidate
}
}
return null
}
export function isClaudeSessionFileForWorkspace(filePath: string, workspacePath: string): boolean {
return resolveClaudeSessionWorkspaceCwd(filePath, workspacePath) !== null
}
function claudeProjectFolderName(filePath: string): string {
// The transcript's immediate parent directory is Claude's encoded project
// folder name. String-only (no fs): normalize separators then take the
// second-to-last segment.
const segments = normalizeRuntimePathSeparators(filePath).split('/').filter(Boolean)
return segments.at(-2) ?? ''
}
function claudeWorkspaceMatchCandidates(workspacePath: string): string[] {
// Claude under WSL encodes the Linux cwd, but Orca may store the worktree as a
// Windows UNC path; try the Linux path too so the encoded folder can match.
const wsl = parseWslUncPath(workspacePath)
return wsl ? [workspacePath, wsl.linuxPath] : [workspacePath]
}