cc-haha/desktop/src/api/sessions.ts
程序员阿江(Relakkes) b811ffb8f1 feat: make session workspace changes inspectable
Expose a workspace inspector for desktop sessions so users can browse the active project, preview files, inspect session-derived changes, and review diffs even when the work directory is not a git repository.

Constraint: The workspace view must work for temporary and non-git project directories.
Rejected: Rely only on git status | non-git sessions would lose the changed-files surface.
Confidence: medium
Scope-risk: moderate
Directive: Keep undo semantics separate from workspace browsing; checkpoint-backed rewind should remain the source of truth for rollback.
Tested: bun test src/server/__tests__/sessions.test.ts src/server/__tests__/workspace-service.test.ts
Tested: cd desktop && bun run test -- src/components/workspace/WorkspacePanel.test.tsx src/stores/workspacePanelStore.test.ts
Tested: cd desktop && bun run lint
Tested: git diff --check
Not-tested: Full desktop packaged app build after this commit
2026-04-29 17:28:53 +08:00

291 lines
7.9 KiB
TypeScript

import { api } from './client'
import type { SessionListItem, MessageEntry } from '../types/session'
type SessionsResponse = { sessions: SessionListItem[]; total: number }
type MessagesResponse = { messages: MessageEntry[] }
type CreateSessionResponse = { sessionId: string }
export type SessionRewindResponse = {
target: {
targetUserMessageId: string
userMessageIndex: number
userMessageCount: number
}
conversation: {
messagesRemoved: number
removedMessageIds?: string[]
}
code: {
available: boolean
reason?: string
filesChanged: string[]
insertions: number
deletions: number
}
}
export type RecentProject = {
projectPath: string
realPath: string
projectName: string
isGit: boolean
repoName: string | null
branch: string | null
modifiedAt: string
sessionCount: number
}
export type SessionUsageSnapshot = {
source?: 'current_process' | 'transcript'
totalCostUSD: number
costDisplay: string
hasUnknownModelCost: boolean
totalAPIDuration: number
totalDuration: number
totalLinesAdded: number
totalLinesRemoved: number
totalInputTokens: number
totalOutputTokens: number
totalCacheReadInputTokens: number
totalCacheCreationInputTokens: number
totalWebSearchRequests: number
models: Array<{
model: string
displayName: string
inputTokens: number
outputTokens: number
cacheReadInputTokens: number
cacheCreationInputTokens: number
webSearchRequests: number
costUSD: number
costDisplay: string
contextWindow: number
maxOutputTokens: number
}>
}
export type SessionContextSnapshot = {
categories: Array<{
name: string
tokens: number
color: string
isDeferred?: boolean
}>
totalTokens: number
maxTokens: number
rawMaxTokens: number
percentage: number
gridRows: Array<Array<{
color: string
isFilled: boolean
categoryName: string
tokens: number
percentage: number
squareFullness: number
}>>
model: string
memoryFiles: Array<{ path: string; type: string; tokens: number }>
mcpTools: Array<{ name: string; serverName: string; tokens: number; isLoaded?: boolean }>
deferredBuiltinTools?: Array<{ name: string; tokens: number; isLoaded: boolean }>
systemTools?: Array<{ name: string; tokens: number }>
systemPromptSections?: Array<{ name: string; tokens: number }>
agents: Array<{ agentType: string; source: string; tokens: number }>
slashCommands?: {
totalCommands: number
includedCommands: number
tokens: number
}
skills?: {
totalSkills: number
includedSkills: number
tokens: number
skillFrontmatter: Array<{ name: string; source: string; tokens: number }>
}
messageBreakdown?: {
toolCallTokens: number
toolResultTokens: number
attachmentTokens: number
assistantMessageTokens: number
userMessageTokens: number
toolCallsByType: Array<{ name: string; callTokens: number; resultTokens: number }>
attachmentsByType: Array<{ name: string; tokens: number }>
}
apiUsage?: {
input_tokens: number
output_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
} | null
}
export type SessionInspectionResponse = {
active: boolean
status: {
sessionId: string
workDir: string
permissionMode: string
version?: string
cwd?: string
model?: string
apiKeySource?: string
outputStyle?: string
tools?: string[]
mcpServers?: Array<{ name: string; status: string }>
slashCommandCount?: number
skillCount?: number
}
usage?: SessionUsageSnapshot
context?: SessionContextSnapshot
contextEstimate?: SessionContextSnapshot
errors?: Record<string, string>
}
export type WorkspaceFileStatus =
| 'modified'
| 'added'
| 'deleted'
| 'renamed'
| 'untracked'
| 'copied'
| 'type_changed'
| 'unknown'
export type WorkspaceChangedFile = {
path: string
oldPath?: string
status: WorkspaceFileStatus
additions: number
deletions: number
}
export type WorkspaceStatusResult = {
state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error'
workDir: string
repoName: string | null
branch: string | null
isGitRepo: boolean
changedFiles: WorkspaceChangedFile[]
error?: string
}
export type WorkspaceReadFileResult = {
state: 'ok' | 'binary' | 'too_large' | 'missing' | 'error'
path: string
previewType?: 'text' | 'image'
content?: string
dataUrl?: string
mimeType?: string
language: string
size: number
error?: string
}
export type WorkspaceTreeEntry = {
name: string
path: string
isDirectory: boolean
}
export type WorkspaceTreeResult = {
state: 'ok' | 'missing' | 'error'
path: string
entries: WorkspaceTreeEntry[]
error?: string
}
export type WorkspaceDiffResult = {
state: 'ok' | 'missing' | 'not_git_repo' | 'error'
path: string
diff?: string
error?: string
}
function buildWorkspacePath(
sessionId: string,
resource: 'status' | 'tree' | 'file' | 'diff',
workspacePath?: string,
) {
const query = new URLSearchParams()
if (typeof workspacePath === 'string' && workspacePath.length > 0) {
query.set('path', workspacePath)
}
const qs = query.toString()
return `/api/sessions/${sessionId}/workspace/${resource}${qs ? `?${qs}` : ''}`
}
export const sessionsApi = {
list(params?: { project?: string; limit?: number; offset?: number }) {
const query = new URLSearchParams()
if (params?.project) query.set('project', params.project)
if (params?.limit) query.set('limit', String(params.limit))
if (params?.offset) query.set('offset', String(params.offset))
const qs = query.toString()
return api.get<SessionsResponse>(`/api/sessions${qs ? `?${qs}` : ''}`)
},
getMessages(sessionId: string) {
return api.get<MessagesResponse>(`/api/sessions/${sessionId}/messages`)
},
create(workDir?: string) {
return api.post<CreateSessionResponse>('/api/sessions', workDir ? { workDir } : {})
},
delete(sessionId: string) {
return api.delete<{ ok: true }>(`/api/sessions/${sessionId}`)
},
rename(sessionId: string, title: string) {
return api.patch<{ ok: true }>(`/api/sessions/${sessionId}`, { title })
},
getRecentProjects(limit?: number) {
const query = typeof limit === 'number' ? `?limit=${limit}` : ''
return api.get<{ projects: RecentProject[] }>(`/api/sessions/recent-projects${query}`)
},
getGitInfo(sessionId: string) {
return api.get<{ branch: string | null; repoName: string | null; workDir: string; changedFiles: number }>(`/api/sessions/${sessionId}/git-info`)
},
getSlashCommands(sessionId: string) {
return api.get<{ commands: Array<{ name: string; description: string }> }>(`/api/sessions/${sessionId}/slash-commands`)
},
getInspection(sessionId: string, options?: { includeContext?: boolean; timeout?: number }) {
const query = options?.includeContext === undefined
? ''
: `?includeContext=${options.includeContext ? '1' : '0'}`
return api.get<SessionInspectionResponse>(`/api/sessions/${sessionId}/inspection${query}`, {
timeout: options?.timeout ?? (options?.includeContext ? 45_000 : 25_000),
})
},
getWorkspaceStatus(sessionId: string) {
return api.get<WorkspaceStatusResult>(buildWorkspacePath(sessionId, 'status'))
},
getWorkspaceTree(sessionId: string, workspacePath = '') {
return api.get<WorkspaceTreeResult>(buildWorkspacePath(sessionId, 'tree', workspacePath))
},
getWorkspaceFile(sessionId: string, workspacePath: string) {
return api.get<WorkspaceReadFileResult>(buildWorkspacePath(sessionId, 'file', workspacePath))
},
getWorkspaceDiff(sessionId: string, workspacePath: string) {
return api.get<WorkspaceDiffResult>(buildWorkspacePath(sessionId, 'diff', workspacePath))
},
rewind(sessionId: string, body: {
targetUserMessageId?: string
userMessageIndex?: number
expectedContent?: string
dryRun?: boolean
}) {
return api.post<SessionRewindResponse>(`/api/sessions/${sessionId}/rewind`, body, {
timeout: 60_000,
})
},
}