mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Show path-only Claude sessions in workspace history
This commit is contained in:
@@ -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'"
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user