mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Merge commit 'df52bea0d0cb5889f9c5e324c54f2bdce9e30378'
This commit is contained in:
commit
10b974dac3
3
.gitignore
vendored
3
.gitignore
vendored
@ -38,6 +38,7 @@ desktop/brand-assets/
|
||||
|
||||
# Superpowers plans & specs (local only)
|
||||
docs/superpowers/
|
||||
.superpowers/
|
||||
|
||||
# Temp server/web logs
|
||||
.tmp-*.log
|
||||
@ -64,4 +65,4 @@ docs/.vitepress/cache
|
||||
runtime/__pycache__
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.idea
|
||||
|
||||
@ -137,10 +137,10 @@ fi
|
||||
|
||||
${AB} screenshot "${ARTIFACT_DIR}/03-after-edit.png" >/dev/null
|
||||
|
||||
${AB} eval "const button = [...document.querySelectorAll('button')].find((node) => node.getAttribute('aria-label') === 'Rewind to here'); if (!button) throw new Error('Rewind button not found'); button.click();"
|
||||
${AB} find role button click --name "Undo current turn changes"
|
||||
${AB} wait 1500
|
||||
${AB} screenshot "${ARTIFACT_DIR}/04-rewind-modal.png" >/dev/null
|
||||
${AB} find role button click --name "Rewind here"
|
||||
${AB} screenshot "${ARTIFACT_DIR}/04-undo-current-turn-confirm.png" >/dev/null
|
||||
${AB} find role button click --name "Undo current turn"
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if grep -q "before-rewind" "${TARGET_FILE}" && ! grep -q "after-rewind" "${TARGET_FILE}"; then
|
||||
|
||||
@ -171,10 +171,10 @@ wait_for_file_contains "${README_FILE}" "Second turn touched README" 60
|
||||
wait_for_file_contains "${GENERATED_FILE}" "second-turn" 60
|
||||
${AB} screenshot "${ARTIFACT_DIR}/04-after-second-turn.png" >/dev/null
|
||||
|
||||
${AB} eval "const buttons = [...document.querySelectorAll('button')].filter((node) => node.getAttribute('aria-label') === 'Rewind to here'); if (buttons.length < 2) throw new Error('Expected at least two Rewind buttons, found ' + buttons.length); buttons[buttons.length - 1].click();"
|
||||
${AB} find role button click --name "Undo current turn changes"
|
||||
${AB} wait 1500
|
||||
${AB} screenshot "${ARTIFACT_DIR}/05-rewind-second-turn-modal.png" >/dev/null
|
||||
${AB} find role button click --name "Rewind here"
|
||||
${AB} screenshot "${ARTIFACT_DIR}/05-undo-second-turn-confirm.png" >/dev/null
|
||||
${AB} find role button click --name "Undo current turn"
|
||||
|
||||
for _ in $(seq 1 120); do
|
||||
if grep -q "turn-one" "${APP_FILE}" \
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
@ -46,9 +46,14 @@ import { ToolInspection } from '../pages/ToolInspection'
|
||||
import { Sidebar } from '../components/layout/Sidebar'
|
||||
import { UserMessage } from '../components/chat/UserMessage'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
/**
|
||||
* Core rendering tests: content-only pages must render without crashing
|
||||
* and contain key structural elements from the prototype.
|
||||
@ -576,7 +581,8 @@ describe('Design system compliance', () => {
|
||||
html.includes('C47A5A') ||
|
||||
html.includes('8F482F') ||
|
||||
html.includes('var(--color-brand)') ||
|
||||
html.includes('bg-[var(--color-brand)]'),
|
||||
html.includes('bg-[var(--color-brand)]') ||
|
||||
html.includes('var(--gradient-btn-primary)'),
|
||||
).toBe(true)
|
||||
unmount()
|
||||
}
|
||||
|
||||
@ -139,6 +139,82 @@ export type SessionInspectionResponse = {
|
||||
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
|
||||
truncated?: boolean
|
||||
readBytes?: 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()
|
||||
@ -187,6 +263,22 @@ export const sessionsApi = {
|
||||
})
|
||||
},
|
||||
|
||||
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
|
||||
|
||||
@ -24,6 +24,7 @@ vi.mock('../../api/sessions', () => ({
|
||||
|
||||
import { AskUserQuestion } from './AskUserQuestion'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
|
||||
const ACTIVE_TAB = 'active-tab'
|
||||
@ -31,6 +32,7 @@ const ACTIVE_TAB = 'active-tab'
|
||||
describe('AskUserQuestion', () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useTabStore.setState({
|
||||
activeTabId: ACTIVE_TAB,
|
||||
tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session', status: 'idle' }],
|
||||
|
||||
@ -5,8 +5,13 @@ export type AttachmentPreview = {
|
||||
id?: string
|
||||
type: 'image' | 'file'
|
||||
name: string
|
||||
path?: string
|
||||
data?: string
|
||||
previewUrl?: string
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
note?: string
|
||||
quote?: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@ -35,7 +40,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'grid grid-cols-1 gap-2 sm:grid-cols-2'}>
|
||||
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'flex flex-wrap justify-end gap-2'}>
|
||||
{attachments.map((attachment, index) => {
|
||||
if (attachment.type === 'image' && (attachment.previewUrl || attachment.data)) {
|
||||
const src = attachment.previewUrl || attachment.data || ''
|
||||
@ -80,18 +85,29 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
return (
|
||||
<div
|
||||
key={attachment.id || `${attachment.name}-${index}`}
|
||||
className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs text-[var(--color-text-secondary)]"
|
||||
className={[
|
||||
'group/file inline-flex max-w-full min-w-0 items-center gap-2 rounded-full border border-[var(--color-border)]',
|
||||
'bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
|
||||
isComposer ? 'h-9 px-3' : 'h-9 px-3',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">attach_file</span>
|
||||
<span className="max-w-[220px] truncate">{attachment.name}</span>
|
||||
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
|
||||
{attachment.lineStart ? 'chat_bubble' : 'description'}
|
||||
</span>
|
||||
<span className="min-w-0 max-w-[220px] truncate text-[13px] font-medium leading-none text-[var(--color-text-primary)]">
|
||||
{attachment.name}
|
||||
{attachment.lineStart
|
||||
? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}`
|
||||
: ''}
|
||||
</span>
|
||||
{onRemove && attachment.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(attachment.id!)}
|
||||
className="ml-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-error)]"
|
||||
className="ml-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
<span className="material-symbols-outlined text-[17px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -6,6 +6,11 @@ import { useUIStore } from '../../stores/uiStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import {
|
||||
formatWorkspaceReferencePrompt,
|
||||
useWorkspaceChatContextStore,
|
||||
type WorkspaceChatReference,
|
||||
} from '../../stores/workspaceChatContextStore'
|
||||
import { sessionsApi } from '../../api/sessions'
|
||||
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
|
||||
import { ModelSelector } from '../controls/ModelSelector'
|
||||
@ -29,16 +34,37 @@ type Attachment = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'image' | 'file'
|
||||
path?: string
|
||||
mimeType?: string
|
||||
previewUrl?: string
|
||||
data?: string
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
note?: string
|
||||
quote?: string
|
||||
}
|
||||
|
||||
type ChatInputProps = {
|
||||
variant?: 'default' | 'hero'
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = []
|
||||
|
||||
function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Attachment {
|
||||
return {
|
||||
id: reference.id,
|
||||
name: reference.name,
|
||||
type: 'file',
|
||||
path: reference.path,
|
||||
lineStart: reference.lineStart,
|
||||
lineEnd: reference.lineEnd,
|
||||
note: reference.note,
|
||||
quote: reference.quote,
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) {
|
||||
const t = useTranslation()
|
||||
const [input, setInput] = useState('')
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
@ -67,13 +93,27 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
||||
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
|
||||
const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false)
|
||||
const workspaceReferences = useWorkspaceChatContextStore(
|
||||
(s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES,
|
||||
)
|
||||
const addWorkspaceReference = useWorkspaceChatContextStore((s) => s.addReference)
|
||||
const removeWorkspaceReference = useWorkspaceChatContextStore((s) => s.removeReference)
|
||||
const clearWorkspaceReferences = useWorkspaceChatContextStore((s) => s.clearReferences)
|
||||
|
||||
const isMemberSession = !!memberInfo
|
||||
const isActive = chatState !== 'idle'
|
||||
const isWorkspaceMissing = activeSession?.workDirExists === false
|
||||
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0))
|
||||
const isHeroComposer = variant === 'hero' && !isMemberSession
|
||||
const hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0
|
||||
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences)))
|
||||
const isHeroComposer = variant === 'hero' && !isMemberSession && !compact
|
||||
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
|
||||
const composerAttachments = useMemo(
|
||||
() => [
|
||||
...attachments,
|
||||
...workspaceReferences.map(workspaceReferenceToAttachment),
|
||||
],
|
||||
[attachments, workspaceReferences],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
@ -302,7 +342,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
|
||||
const handleSubmit = () => {
|
||||
const text = input.trim()
|
||||
if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return
|
||||
if ((!text && ((!attachments.length && !hasWorkspaceReferences) || isMemberSession)) || isWorkspaceMissing) return
|
||||
|
||||
const slashUiAction = !isMemberSession && text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
|
||||
if (slashUiAction?.type === 'panel') {
|
||||
@ -324,16 +364,55 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
|
||||
const workspaceReferencePrompt = !isMemberSession
|
||||
? formatWorkspaceReferencePrompt(workspaceReferences)
|
||||
: ''
|
||||
const contentForModel = [workspaceReferencePrompt, text].filter(Boolean).join('\n\n')
|
||||
const displayContent = text || (
|
||||
workspaceReferences.length > 0
|
||||
? t('chat.workspaceReferencesOnly', { count: workspaceReferences.length })
|
||||
: ''
|
||||
)
|
||||
const uploadAttachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
|
||||
type: attachment.type,
|
||||
name: attachment.name,
|
||||
path: attachment.path,
|
||||
data: attachment.data,
|
||||
mimeType: attachment.mimeType,
|
||||
lineStart: attachment.lineStart,
|
||||
lineEnd: attachment.lineEnd,
|
||||
note: attachment.note,
|
||||
quote: attachment.quote,
|
||||
}))
|
||||
const workspaceAttachmentPayload: AttachmentRef[] = workspaceReferences.map((reference) => ({
|
||||
type: 'file' as const,
|
||||
name: reference.name,
|
||||
path: reference.absolutePath ?? reference.path,
|
||||
lineStart: reference.lineStart,
|
||||
lineEnd: reference.lineEnd,
|
||||
note: reference.note,
|
||||
quote: reference.quote,
|
||||
}))
|
||||
const visibleAttachmentPayload: AttachmentRef[] = [
|
||||
...uploadAttachmentPayload,
|
||||
...workspaceReferences.map((reference) => ({
|
||||
type: 'file' as const,
|
||||
name: reference.name,
|
||||
path: reference.path,
|
||||
lineStart: reference.lineStart,
|
||||
lineEnd: reference.lineEnd,
|
||||
note: reference.note,
|
||||
quote: reference.quote,
|
||||
})),
|
||||
]
|
||||
|
||||
sendMessage(activeTabId!, text, attachmentPayload)
|
||||
sendMessage(activeTabId!, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], {
|
||||
displayContent,
|
||||
displayAttachments: visibleAttachmentPayload,
|
||||
})
|
||||
setInput('')
|
||||
setAttachments([])
|
||||
if (!isMemberSession) clearWorkspaceReferences(activeTabId!)
|
||||
setPlusMenuOpen(false)
|
||||
setSlashMenuOpen(false)
|
||||
setFileSearchOpen(false)
|
||||
@ -487,6 +566,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
|
||||
if (activeTabId) removeWorkspaceReference(activeTabId, id)
|
||||
}
|
||||
|
||||
const insertSlashCommand = () => {
|
||||
@ -517,12 +597,30 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands')
|
||||
|
||||
return (
|
||||
<div className={isHeroComposer ? 'bg-[var(--color-surface)] px-8 pb-4' : 'bg-[var(--color-surface)] px-4 py-4'}>
|
||||
<div className={isHeroComposer ? 'mx-auto flex w-full max-w-3xl flex-col gap-2' : 'mx-auto max-w-[860px]'}>
|
||||
<div
|
||||
className={
|
||||
isHeroComposer
|
||||
? 'bg-[var(--color-surface)] px-8 pb-4'
|
||||
: compact
|
||||
? 'border-t border-[var(--color-border)]/70 bg-[var(--color-surface)] px-3 py-3'
|
||||
: 'bg-[var(--color-surface)] px-4 py-4'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isHeroComposer
|
||||
? 'mx-auto flex w-full max-w-3xl flex-col gap-2'
|
||||
: compact
|
||||
? 'mx-auto max-w-full'
|
||||
: 'mx-auto max-w-[860px]'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={isHeroComposer
|
||||
? 'glass-panel relative flex flex-col gap-3 rounded-xl p-4 transition-colors'
|
||||
: 'glass-panel relative rounded-xl p-4 transition-colors'}
|
||||
: compact
|
||||
? 'glass-panel relative rounded-xl p-3 transition-colors'
|
||||
: 'glass-panel relative rounded-xl p-4 transition-colors'}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
@ -531,11 +629,19 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
ref={fileSearchRef}
|
||||
cwd={resolvedWorkDir || ''}
|
||||
filter={atFilter}
|
||||
onSelect={(_path, name) => {
|
||||
onSelect={(path, name) => {
|
||||
if (atCursorPos >= 0) {
|
||||
// Insert name at cursor position, replacing filter text
|
||||
const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}`
|
||||
const newCursorPos = atCursorPos + name.length
|
||||
if (activeTabId) {
|
||||
addWorkspaceReference(activeTabId, {
|
||||
kind: 'file',
|
||||
path,
|
||||
absolutePath: path,
|
||||
name,
|
||||
})
|
||||
}
|
||||
setInput(newValue)
|
||||
setFileSearchOpen(false)
|
||||
setAtFilter('')
|
||||
@ -599,12 +705,12 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
{composerAttachments.length > 0 && (
|
||||
isHeroComposer ? (
|
||||
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
|
||||
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />
|
||||
) : (
|
||||
<div className="px-3 pt-3">
|
||||
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
|
||||
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
@ -637,14 +743,18 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
placeholder={composerPlaceholder}
|
||||
disabled={isWorkspaceMissing}
|
||||
rows={1}
|
||||
className="w-full resize-none bg-transparent py-2 pb-12 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
|
||||
className={`w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50 ${
|
||||
compact ? 'py-1.5 pb-11' : 'py-2 pb-12'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={isHeroComposer
|
||||
? 'flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3'
|
||||
: 'absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] px-3 py-3'}>
|
||||
<div className="flex items-center gap-2">
|
||||
: `absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] ${
|
||||
compact ? 'gap-2 px-2.5 py-2' : 'px-3 py-3'
|
||||
}`}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{!isMemberSession && (
|
||||
<>
|
||||
<div ref={plusMenuRef} className="relative">
|
||||
@ -679,20 +789,30 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PermissionModeSelector />
|
||||
<PermissionModeSelector compact={compact} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{!isMemberSession && activeTabId && (
|
||||
<ModelSelector runtimeKey={activeTabId} disabled={isActive} />
|
||||
<ModelSelector runtimeKey={activeTabId} disabled={isActive} compact={compact} />
|
||||
)}
|
||||
<button
|
||||
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
|
||||
disabled={!isMemberSession && isActive ? false : !canSubmit}
|
||||
title={!isMemberSession && isActive ? t('chat.stopTitle') : undefined}
|
||||
className={`flex w-[112px] items-center justify-center gap-1 rounded-lg px-3 py-1.5 text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${
|
||||
title={
|
||||
!isMemberSession && isActive
|
||||
? t('chat.stopTitle')
|
||||
: compact
|
||||
? isMemberSession
|
||||
? t('common.send')
|
||||
: t('common.run')
|
||||
: undefined
|
||||
}
|
||||
className={`flex items-center justify-center gap-1 rounded-lg text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${
|
||||
compact ? 'h-8 w-8 px-0 py-0' : 'w-[112px] px-3 py-1.5'
|
||||
} ${
|
||||
!isMemberSession && isActive
|
||||
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
|
||||
: 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)]'
|
||||
@ -701,7 +821,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{!isMemberSession && isActive ? 'stop' : 'arrow_forward'}
|
||||
</span>
|
||||
{!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run')}
|
||||
{!compact && (!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run'))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -710,12 +830,13 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
|
||||
|
||||
{!isMemberSession && (
|
||||
<div className="mt-3 px-1">
|
||||
<div className={compact ? 'mt-2 flex min-w-0 justify-center px-1' : 'mt-3 px-1'}>
|
||||
{hasMessages ? (
|
||||
<ProjectContextChip
|
||||
workDir={resolvedWorkDir}
|
||||
repoName={gitInfo?.repoName || null}
|
||||
branch={gitInfo?.branch || null}
|
||||
compact={compact}
|
||||
/>
|
||||
) : (
|
||||
<DirectoryPicker
|
||||
|
||||
@ -73,12 +73,14 @@ vi.mock('../../api/computerUse', () => ({
|
||||
}))
|
||||
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { ComputerUsePermissionModal } from './ComputerUsePermissionModal'
|
||||
|
||||
describe('ComputerUsePermissionModal', () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockReset()
|
||||
openSettingsMock.mockReset()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
|
||||
182
desktop/src/components/chat/CurrentTurnChangeCard.tsx
Normal file
182
desktop/src/components/chat/CurrentTurnChangeCard.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { sessionsApi, type SessionRewindResponse } from '../../api/sessions'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { WorkspaceDiffSurface } from '../workspace/WorkspaceCodeSurface'
|
||||
|
||||
type DiffPreviewState = {
|
||||
loading: boolean
|
||||
diff?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
type CurrentTurnChangeCardProps = {
|
||||
sessionId: string
|
||||
preview: SessionRewindResponse
|
||||
workDir: string | null
|
||||
error: string | null
|
||||
isUndoing: boolean
|
||||
onUndo: () => void
|
||||
}
|
||||
|
||||
export function CurrentTurnChangeCard({
|
||||
sessionId,
|
||||
preview,
|
||||
workDir,
|
||||
error,
|
||||
isUndoing,
|
||||
onUndo,
|
||||
}: CurrentTurnChangeCardProps) {
|
||||
const t = useTranslation()
|
||||
const [expandedPath, setExpandedPath] = useState<string | null>(null)
|
||||
const [diffByPath, setDiffByPath] = useState<Record<string, DiffPreviewState>>({})
|
||||
|
||||
const files = useMemo(
|
||||
() => preview.code.filesChanged.map((filePath) => relativizeWorkspacePath(filePath, workDir)),
|
||||
[preview.code.filesChanged, workDir],
|
||||
)
|
||||
|
||||
const toggleDiff = useCallback((filePath: string) => {
|
||||
const nextExpandedPath = expandedPath === filePath ? null : filePath
|
||||
setExpandedPath(nextExpandedPath)
|
||||
if (!nextExpandedPath || diffByPath[filePath]?.diff || diffByPath[filePath]?.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
setDiffByPath((current) => ({
|
||||
...current,
|
||||
[filePath]: { loading: true },
|
||||
}))
|
||||
|
||||
void sessionsApi
|
||||
.getWorkspaceDiff(sessionId, filePath)
|
||||
.then((result) => {
|
||||
setDiffByPath((current) => ({
|
||||
...current,
|
||||
[filePath]: {
|
||||
loading: false,
|
||||
diff: result.state === 'ok' ? result.diff || '' : undefined,
|
||||
error: result.state === 'ok'
|
||||
? undefined
|
||||
: result.error || t('chat.turnChangesDiffUnavailable'),
|
||||
},
|
||||
}))
|
||||
})
|
||||
.catch((diffError) => {
|
||||
setDiffByPath((current) => ({
|
||||
...current,
|
||||
[filePath]: {
|
||||
loading: false,
|
||||
error: diffError instanceof Error
|
||||
? diffError.message
|
||||
: String(diffError),
|
||||
},
|
||||
}))
|
||||
})
|
||||
}, [diffByPath, expandedPath, sessionId, t])
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mx-auto mb-5 w-full max-w-[860px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm"
|
||||
aria-label={t('chat.turnChangesCardLabel')}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{t('chat.turnChangesTitle', { count: files.length })}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-[var(--color-success)]">
|
||||
+{preview.code.insertions}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-[var(--color-error)]">
|
||||
-{preview.code.deletions}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('chat.turnChangesSubtitle')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUndo}
|
||||
disabled={isUndoing}
|
||||
aria-label={t('chat.turnChangesUndoAria')}
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/40 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px]">undo</span>
|
||||
{isUndoing ? t('chat.turnChangesUndoing') : t('chat.turnChangesUndo')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[var(--color-border)]">
|
||||
{files.map((filePath) => {
|
||||
const isExpanded = expandedPath === filePath
|
||||
const diffState = diffByPath[filePath]
|
||||
return (
|
||||
<div key={filePath}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDiff(filePath)}
|
||||
aria-label={t(
|
||||
isExpanded ? 'chat.turnChangesHideDiffAria' : 'chat.turnChangesShowDiffAria',
|
||||
{ path: filePath },
|
||||
)}
|
||||
className="flex min-h-11 w-full items-center gap-3 px-4 text-left text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
|
||||
{isExpanded ? 'keyboard_arrow_down' : 'chevron_right'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[13px]">
|
||||
{filePath}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-3">
|
||||
{diffState?.loading ? (
|
||||
<div className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('chat.turnChangesDiffLoading')}
|
||||
</div>
|
||||
) : diffState?.error ? (
|
||||
<div className="text-xs text-[var(--color-error)]">
|
||||
{diffState.error}
|
||||
</div>
|
||||
) : diffState?.diff ? (
|
||||
<WorkspaceDiffSurface
|
||||
value={diffState.diff}
|
||||
path={filePath}
|
||||
className="max-h-[430px] overflow-auto rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-code-bg)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('chat.turnChangesDiffUnavailable')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-t border-[var(--color-error)]/20 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function relativizeWorkspacePath(filePath: string, workDir: string | null): string {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/')
|
||||
if (!workDir || !normalizedPath.startsWith('/')) return normalizedPath
|
||||
|
||||
const normalizedWorkDir = workDir.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
if (normalizedPath === normalizedWorkDir) return ''
|
||||
if (normalizedPath.startsWith(`${normalizedWorkDir}/`)) {
|
||||
return normalizedPath.slice(normalizedWorkDir.length + 1)
|
||||
}
|
||||
return normalizedPath
|
||||
}
|
||||
@ -4,6 +4,7 @@ import '@testing-library/jest-dom'
|
||||
import { FileSearchMenu } from './FileSearchMenu'
|
||||
import { ApiError } from '../../api/client'
|
||||
import { filesystemApi } from '../../api/filesystem'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
vi.mock('../../api/filesystem', () => ({
|
||||
filesystemApi: {
|
||||
@ -15,6 +16,7 @@ vi.mock('../../api/filesystem', () => ({
|
||||
describe('FileSearchMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
it('shows an explicit error when directory browsing is denied', async () => {
|
||||
|
||||
@ -3,22 +3,17 @@ import { CopyButton } from '../shared/CopyButton'
|
||||
type Props = {
|
||||
copyText?: string
|
||||
copyLabel: string
|
||||
onRewind?: () => void
|
||||
rewindLabel?: string
|
||||
align?: 'start' | 'end'
|
||||
}
|
||||
|
||||
export function MessageActionBar({
|
||||
copyText,
|
||||
copyLabel,
|
||||
onRewind,
|
||||
rewindLabel = 'Rewind to here',
|
||||
align = 'start',
|
||||
}: Props) {
|
||||
const hasCopy = Boolean(copyText?.trim())
|
||||
const hasRewind = Boolean(onRewind)
|
||||
|
||||
if (!hasCopy && !hasRewind) return null
|
||||
if (!hasCopy) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -29,27 +24,13 @@ export function MessageActionBar({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasRewind && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRewind}
|
||||
aria-label={rewindLabel}
|
||||
title={rewindLabel}
|
||||
className="inline-flex min-h-7 items-center gap-1 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">undo</span>
|
||||
<span className="hidden min-[920px]:inline">Rewind</span>
|
||||
</button>
|
||||
)}
|
||||
{hasCopy && (
|
||||
<CopyButton
|
||||
text={copyText!}
|
||||
label={copyLabel}
|
||||
displayLabel="Copy"
|
||||
displayCopiedLabel="Copied"
|
||||
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
/>
|
||||
)}
|
||||
<CopyButton
|
||||
text={copyText!}
|
||||
label={copyLabel}
|
||||
displayLabel="Copy"
|
||||
displayCopiedLabel="Copied"
|
||||
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -563,7 +563,7 @@ describe('MessageList nested tool calls', () => {
|
||||
expect(assistantShell?.className).not.toContain('ml-10')
|
||||
})
|
||||
|
||||
it('opens a rewind preview modal for user messages', async () => {
|
||||
it('does not expose the old message-level rewind action', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
@ -575,11 +575,19 @@ describe('MessageList nested tool calls', () => {
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/example.ts'],
|
||||
insertions: 6,
|
||||
deletions: 2,
|
||||
filesChanged: ['src/App.tsx'],
|
||||
insertions: 4,
|
||||
deletions: 1,
|
||||
},
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
@ -588,9 +596,15 @@ describe('MessageList nested tool calls', () => {
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '回到这一步重做',
|
||||
content: '做一个页面',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'done',
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
@ -598,21 +612,11 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rewind to here' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByText('Rewind Conversation')).toBeTruthy()
|
||||
expect(within(dialog).getByText('回到这一步重做')).toBeTruthy()
|
||||
expect(within(dialog).getByText('src/example.ts')).toBeTruthy()
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
expectedContent: '回到这一步重做',
|
||||
dryRun: true,
|
||||
})
|
||||
expect(await screen.findByRole('button', { name: 'Undo current turn changes' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull()
|
||||
})
|
||||
|
||||
it('confirms rewind with the selected message id and prompt guard', async () => {
|
||||
it('shows a current-turn change card from checkpoint preview', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
@ -623,18 +627,22 @@ describe('MessageList nested tool calls', () => {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: false,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
available: true,
|
||||
filesChanged: ['src/App.tsx', 'src/lib/api.ts'],
|
||||
insertions: 12,
|
||||
deletions: 4,
|
||||
},
|
||||
})
|
||||
const reloadHistory = vi.fn().mockResolvedValue(undefined)
|
||||
const queueComposerPrefill = vi.fn()
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
reloadHistory,
|
||||
queueComposerPrefill,
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
@ -654,8 +662,15 @@ describe('MessageList nested tool calls', () => {
|
||||
id: 'user-2',
|
||||
type: 'user_text',
|
||||
content: '第二段',
|
||||
modelContent: '@"/tmp/example-project/src/App.tsx" 第二段',
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
id: 'assistant-2',
|
||||
type: 'assistant_text',
|
||||
content: 'done',
|
||||
timestamp: 4,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
@ -663,25 +678,348 @@ describe('MessageList nested tool calls', () => {
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
const buttons = screen.getAllByRole('button', { name: 'Rewind to here' })
|
||||
fireEvent.click(buttons[1]!)
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /Rewind here/ }))
|
||||
expect(await screen.findByText('2 files changed')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Current turn changed files').className).toContain('w-full max-w-[860px]')
|
||||
expect(screen.getByText('+12')).toBeTruthy()
|
||||
expect(screen.getByText('-4')).toBeTruthy()
|
||||
expect(screen.getByText('src/App.tsx')).toBeTruthy()
|
||||
expect(screen.getByText('src/lib/api.ts')).toBeTruthy()
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '@"/tmp/example-project/src/App.tsx" 第二段',
|
||||
dryRun: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('expands a current-turn changed file diff', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 1,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/App.tsx'],
|
||||
insertions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceDiff').mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: 'src/App.tsx',
|
||||
diff: 'diff --session a/src/App.tsx b/src/App.tsx\n-old\n+new',
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '改一下',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'done',
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Show diff for src/App.tsx' }))
|
||||
|
||||
const diffSurface = await screen.findByTestId('workspace-code')
|
||||
expect(diffSurface.textContent).toContain('+new')
|
||||
expect(sessionsApi.getWorkspaceDiff).toHaveBeenCalledWith(ACTIVE_TAB, 'src/App.tsx')
|
||||
})
|
||||
|
||||
it('confirms before undoing the current turn from the change card', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind')
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 1,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/App.tsx'],
|
||||
insertions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
userMessageCount: 1,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
removedMessageIds: ['user-1', 'assistant-1'],
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/App.tsx'],
|
||||
insertions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
const reloadHistory = vi.fn().mockResolvedValue(undefined)
|
||||
const queueComposerPrefill = vi.fn()
|
||||
|
||||
useChatStore.setState({
|
||||
reloadHistory,
|
||||
queueComposerPrefill,
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '做一个页面',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'done',
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Undo current turn changes' }))
|
||||
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledTimes(1)
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' })
|
||||
expect(within(dialog).getByText('This will rewind the latest assistant response and restore tracked files for this turn.')).toBeTruthy()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Undo current turn' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-1',
|
||||
userMessageIndex: 0,
|
||||
expectedContent: '做一个页面',
|
||||
})
|
||||
})
|
||||
expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB)
|
||||
expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
text: '做一个页面',
|
||||
attachments: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('undoes only the latest completed turn when earlier turns also changed files', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind')
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/second.ts'],
|
||||
insertions: 7,
|
||||
deletions: 2,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
removedMessageIds: ['user-2', 'assistant-2'],
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/second.ts'],
|
||||
insertions: 7,
|
||||
deletions: 2,
|
||||
},
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
const reloadHistory = vi.fn().mockResolvedValue(undefined)
|
||||
const queueComposerPrefill = vi.fn()
|
||||
|
||||
useChatStore.setState({
|
||||
reloadHistory,
|
||||
queueComposerPrefill,
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '第一轮需求',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'first done',
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
type: 'user_text',
|
||||
content: '第二轮需求',
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
id: 'assistant-2',
|
||||
type: 'assistant_text',
|
||||
content: 'second done',
|
||||
timestamp: 4,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
expect(await screen.findByText('1 files changed')).toBeTruthy()
|
||||
expect(screen.getByText('src/second.ts')).toBeTruthy()
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '第二轮需求',
|
||||
dryRun: true,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo current turn changes' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Undo current turn' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '第二段',
|
||||
expectedContent: '第二轮需求',
|
||||
})
|
||||
})
|
||||
expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB)
|
||||
expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
text: '第二段',
|
||||
text: '第二轮需求',
|
||||
attachments: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show a stale current-turn change card when the latest completed turn has no files', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '第一轮改文件',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'first done',
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
type: 'user_text',
|
||||
content: '第二轮只解释',
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
id: 'assistant-2',
|
||||
type: 'assistant_text',
|
||||
content: 'second done',
|
||||
timestamp: 4,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '第二轮只解释',
|
||||
dryRun: true,
|
||||
})
|
||||
})
|
||||
expect(screen.queryByLabelText('Current turn changed files')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows raw startup details under translated CLI startup errors', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
|
||||
@ -17,9 +17,9 @@ import { PermissionDialog } from './PermissionDialog'
|
||||
import { AskUserQuestion } from './AskUserQuestion'
|
||||
import { StreamingIndicator } from './StreamingIndicator'
|
||||
import { InlineTaskSummary } from './InlineTaskSummary'
|
||||
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
|
||||
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||
import { Modal } from '../shared/Modal'
|
||||
import { Button } from '../shared/Button'
|
||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
@ -34,6 +34,20 @@ type RenderModel = {
|
||||
childToolCallsByParent: Map<string, ToolCall[]>
|
||||
}
|
||||
|
||||
type RewindTurnTarget = {
|
||||
messageId: string
|
||||
userMessageIndex: number
|
||||
content: string
|
||||
expectedContent: string
|
||||
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
|
||||
}
|
||||
|
||||
type CurrentTurnPreview = {
|
||||
target: RewindTurnTarget
|
||||
preview: SessionRewindResponse
|
||||
workDir: string | null
|
||||
}
|
||||
|
||||
function appendChildToolCall(
|
||||
childToolCallsByParent: Map<string, ToolCall[]>,
|
||||
parentToolUseId: string,
|
||||
@ -104,8 +118,45 @@ export function buildRenderModel(messages: UIMessage[]): RenderModel {
|
||||
return { renderItems: items, toolResultMap, childToolCallsByParent }
|
||||
}
|
||||
|
||||
export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnTarget | null {
|
||||
let userMessageIndex = -1
|
||||
let latestTarget: (RewindTurnTarget & { messageOffset: number }) | null = null
|
||||
|
||||
for (let messageOffset = 0; messageOffset < messages.length; messageOffset += 1) {
|
||||
const message = messages[messageOffset]
|
||||
if (!message || message.type !== 'user_text' || message.pending) continue
|
||||
userMessageIndex += 1
|
||||
latestTarget = {
|
||||
messageId: message.id,
|
||||
userMessageIndex,
|
||||
content: message.content,
|
||||
expectedContent: message.modelContent ?? message.content,
|
||||
attachments: message.attachments,
|
||||
messageOffset,
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestTarget) return null
|
||||
|
||||
const hasResponseAfterTarget = messages
|
||||
.slice(latestTarget.messageOffset + 1)
|
||||
.some((message) =>
|
||||
message.type === 'assistant_text' ||
|
||||
message.type === 'tool_use' ||
|
||||
message.type === 'tool_result' ||
|
||||
message.type === 'error' ||
|
||||
message.type === 'task_summary',
|
||||
)
|
||||
|
||||
if (!hasResponseAfterTarget) return null
|
||||
|
||||
const { messageOffset: _messageOffset, ...target } = latestTarget
|
||||
return target
|
||||
}
|
||||
|
||||
type MessageListProps = {
|
||||
sessionId?: string | null
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
|
||||
@ -117,7 +168,7 @@ function isNearScrollBottom(element: HTMLElement) {
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageList({ sessionId }: MessageListProps = {}) {
|
||||
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const resolvedSessionId = sessionId ?? activeTabId
|
||||
const sessionState = useChatStore((s) =>
|
||||
@ -140,16 +191,11 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
||||
const shouldAutoScrollRef = useRef(true)
|
||||
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
|
||||
const t = useTranslation()
|
||||
const [rewindTarget, setRewindTarget] = useState<{
|
||||
messageId: string
|
||||
userMessageIndex: number
|
||||
content: string
|
||||
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
|
||||
} | null>(null)
|
||||
const [rewindPreview, setRewindPreview] = useState<SessionRewindResponse | null>(null)
|
||||
const [rewindError, setRewindError] = useState<string | null>(null)
|
||||
const [isLoadingPreview, setIsLoadingPreview] = useState(false)
|
||||
const [isExecutingRewind, setIsExecutingRewind] = useState(false)
|
||||
const [currentTurnPreview, setCurrentTurnPreview] = useState<CurrentTurnPreview | null>(null)
|
||||
const [currentTurnError, setCurrentTurnError] = useState<string | null>(null)
|
||||
const [isLoadingCurrentTurnPreview, setIsLoadingCurrentTurnPreview] = useState(false)
|
||||
const [isUndoingCurrentTurn, setIsUndoingCurrentTurn] = useState(false)
|
||||
const [currentTurnUndoConfirmOpen, setCurrentTurnUndoConfirmOpen] = useState(false)
|
||||
|
||||
const updateAutoScrollState = useCallback(() => {
|
||||
const container = scrollContainerRef.current
|
||||
@ -168,25 +214,50 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
||||
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
|
||||
}, [messages.length, resolvedSessionId, streamingText])
|
||||
|
||||
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
|
||||
() => buildRenderModel(messages),
|
||||
[messages],
|
||||
)
|
||||
const latestTurnTarget = useMemo(() => getLatestCompletedTurnTarget(messages), [messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedSessionId || !rewindTarget) return
|
||||
if (
|
||||
!resolvedSessionId ||
|
||||
!latestTurnTarget ||
|
||||
chatState !== 'idle' ||
|
||||
isMemberSession
|
||||
) {
|
||||
setCurrentTurnPreview(null)
|
||||
setCurrentTurnError(null)
|
||||
setIsLoadingCurrentTurnPreview(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setIsLoadingPreview(true)
|
||||
setRewindPreview(null)
|
||||
setRewindError(null)
|
||||
setIsLoadingCurrentTurnPreview(true)
|
||||
setCurrentTurnPreview(null)
|
||||
setCurrentTurnError(null)
|
||||
|
||||
void sessionsApi
|
||||
.rewind(resolvedSessionId, {
|
||||
targetUserMessageId: rewindTarget.messageId,
|
||||
userMessageIndex: rewindTarget.userMessageIndex,
|
||||
expectedContent: rewindTarget.content,
|
||||
Promise.all([
|
||||
sessionsApi.rewind(resolvedSessionId, {
|
||||
targetUserMessageId: latestTurnTarget.messageId,
|
||||
userMessageIndex: latestTurnTarget.userMessageIndex,
|
||||
expectedContent: latestTurnTarget.expectedContent,
|
||||
dryRun: true,
|
||||
})
|
||||
.then((preview) => {
|
||||
if (!cancelled) {
|
||||
setRewindPreview(preview)
|
||||
}),
|
||||
sessionsApi.getWorkspaceStatus(resolvedSessionId).catch(() => null),
|
||||
])
|
||||
.then(([preview, workspaceStatus]) => {
|
||||
if (cancelled) return
|
||||
if (!preview.code.available || preview.code.filesChanged.length === 0) {
|
||||
setCurrentTurnPreview(null)
|
||||
return
|
||||
}
|
||||
setCurrentTurnPreview({
|
||||
target: latestTurnTarget,
|
||||
preview,
|
||||
workDir: workspaceStatus?.workDir ?? null,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return
|
||||
@ -198,37 +269,25 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
setRewindError(message)
|
||||
setCurrentTurnError(message)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingPreview(false)
|
||||
setIsLoadingCurrentTurnPreview(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [resolvedSessionId, rewindTarget])
|
||||
}, [chatState, isMemberSession, latestTurnTarget, resolvedSessionId])
|
||||
|
||||
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
|
||||
() => buildRenderModel(messages),
|
||||
[messages],
|
||||
)
|
||||
const handleUndoCurrentTurn = useCallback(async () => {
|
||||
if (!resolvedSessionId || !currentTurnPreview || isUndoingCurrentTurn) return
|
||||
|
||||
const closeRewindModal = useCallback(() => {
|
||||
if (isExecutingRewind) return
|
||||
setRewindTarget(null)
|
||||
setRewindPreview(null)
|
||||
setRewindError(null)
|
||||
setIsLoadingPreview(false)
|
||||
}, [isExecutingRewind])
|
||||
|
||||
const handleConfirmRewind = useCallback(async () => {
|
||||
if (!resolvedSessionId || !rewindTarget || isExecutingRewind) return
|
||||
|
||||
setIsExecutingRewind(true)
|
||||
setRewindError(null)
|
||||
const target = currentTurnPreview.target
|
||||
setIsUndoingCurrentTurn(true)
|
||||
setCurrentTurnError(null)
|
||||
|
||||
try {
|
||||
if (chatState !== 'idle') {
|
||||
@ -236,15 +295,15 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
||||
}
|
||||
|
||||
const result = await sessionsApi.rewind(resolvedSessionId, {
|
||||
targetUserMessageId: rewindTarget.messageId,
|
||||
userMessageIndex: rewindTarget.userMessageIndex,
|
||||
expectedContent: rewindTarget.content,
|
||||
targetUserMessageId: target.messageId,
|
||||
userMessageIndex: target.userMessageIndex,
|
||||
expectedContent: target.expectedContent,
|
||||
})
|
||||
|
||||
await reloadHistory(resolvedSessionId)
|
||||
queueComposerPrefill(resolvedSessionId, {
|
||||
text: rewindTarget.content,
|
||||
attachments: rewindTarget.attachments,
|
||||
text: target.content,
|
||||
attachments: target.attachments,
|
||||
})
|
||||
|
||||
addToast({
|
||||
@ -258,8 +317,8 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
||||
}),
|
||||
})
|
||||
|
||||
setRewindTarget(null)
|
||||
setRewindPreview(null)
|
||||
setCurrentTurnPreview(null)
|
||||
setCurrentTurnUndoConfirmOpen(false)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof ApiError
|
||||
@ -269,31 +328,30 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
setRewindError(message)
|
||||
setCurrentTurnError(message)
|
||||
setCurrentTurnUndoConfirmOpen(false)
|
||||
} finally {
|
||||
setIsExecutingRewind(false)
|
||||
setIsUndoingCurrentTurn(false)
|
||||
}
|
||||
}, [
|
||||
addToast,
|
||||
chatState,
|
||||
isExecutingRewind,
|
||||
currentTurnPreview,
|
||||
isUndoingCurrentTurn,
|
||||
queueComposerPrefill,
|
||||
reloadHistory,
|
||||
resolvedSessionId,
|
||||
rewindTarget,
|
||||
stopGeneration,
|
||||
t,
|
||||
])
|
||||
|
||||
let visibleUserMessageIndex = -1
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={updateAutoScrollState}
|
||||
className="flex-1 overflow-y-auto px-4 py-4"
|
||||
className={`flex-1 overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
|
||||
>
|
||||
<div className="mx-auto max-w-[860px]">
|
||||
<div className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}>
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === 'tool_group') {
|
||||
return (
|
||||
@ -312,10 +370,6 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
||||
}
|
||||
|
||||
const msg = item.message
|
||||
const rewindableUserIndex =
|
||||
msg.type === 'user_text' && !msg.pending
|
||||
? ++visibleUserMessageIndex
|
||||
: null
|
||||
return (
|
||||
<MessageBlock
|
||||
key={msg.id}
|
||||
@ -330,19 +384,6 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
||||
})()
|
||||
: null
|
||||
}
|
||||
rewindableUserIndex={rewindableUserIndex}
|
||||
onRequestRewind={
|
||||
!isMemberSession
|
||||
? (message, userMessageIndex) => {
|
||||
setRewindTarget({
|
||||
messageId: message.id,
|
||||
userMessageIndex,
|
||||
content: message.content,
|
||||
attachments: message.attachments,
|
||||
})
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@ -359,121 +400,43 @@ export function MessageList({ sessionId }: MessageListProps = {}) {
|
||||
<StreamingIndicator />
|
||||
)}
|
||||
|
||||
{!isLoadingCurrentTurnPreview && currentTurnPreview && resolvedSessionId && (
|
||||
<CurrentTurnChangeCard
|
||||
sessionId={resolvedSessionId}
|
||||
preview={currentTurnPreview.preview}
|
||||
workDir={currentTurnPreview.workDir}
|
||||
error={currentTurnError}
|
||||
isUndoing={isUndoingCurrentTurn}
|
||||
onUndo={() => {
|
||||
setCurrentTurnUndoConfirmOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!currentTurnPreview && currentTurnError && (
|
||||
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
|
||||
{currentTurnError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={Boolean(rewindTarget)}
|
||||
onClose={closeRewindModal}
|
||||
title={t('chat.rewindModalTitle')}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={closeRewindModal}
|
||||
disabled={isExecutingRewind}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void handleConfirmRewind()
|
||||
}}
|
||||
loading={isExecutingRewind}
|
||||
disabled={isLoadingPreview || Boolean(rewindError)}
|
||||
icon={
|
||||
!isExecutingRewind ? (
|
||||
<span className="material-symbols-outlined text-[16px]">undo</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t('chat.rewindConfirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
<div className="mb-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
||||
{t('chat.rewindPromptLabel')}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed text-[var(--color-text-primary)]">
|
||||
{rewindTarget?.content || t('chat.rewindAttachmentOnly')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingPreview && (
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 text-sm text-[var(--color-text-secondary)]">
|
||||
{t('chat.rewindLoading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingPreview && rewindPreview && (
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)]">history</span>
|
||||
{t('chat.rewindConversationCardTitle')}
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{t('chat.rewindConversationCardBody', {
|
||||
count: rewindPreview.conversation.messagesRemoved,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)]">code</span>
|
||||
{t('chat.rewindCodeCardTitle')}
|
||||
</div>
|
||||
{rewindPreview.code.available ? (
|
||||
<div className="space-y-1 text-sm text-[var(--color-text-secondary)]">
|
||||
<div>{t('chat.rewindCodeFiles', { count: rewindPreview.code.filesChanged.length })}</div>
|
||||
<div>{t('chat.rewindCodeInsertions', { count: rewindPreview.code.insertions })}</div>
|
||||
<div>{t('chat.rewindCodeDeletions', { count: rewindPreview.code.deletions })}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{rewindPreview.code.reason || t('chat.rewindCodeUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingPreview && rewindPreview?.code.available && rewindPreview.code.filesChanged.length > 0 && (
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
||||
{t('chat.rewindFilesLabel')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rewindPreview.code.filesChanged.slice(0, 8).map((filePath) => (
|
||||
<span
|
||||
key={filePath}
|
||||
className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
{filePath}
|
||||
</span>
|
||||
))}
|
||||
{rewindPreview.code.filesChanged.length > 8 && (
|
||||
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)]">
|
||||
{t('chat.rewindFilesMore', {
|
||||
count: rewindPreview.code.filesChanged.length - 8,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rewindError && (
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)]/22 px-4 py-3 text-sm text-[var(--color-error)]">
|
||||
{rewindError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
<ConfirmDialog
|
||||
open={currentTurnUndoConfirmOpen}
|
||||
onClose={() => {
|
||||
if (!isUndoingCurrentTurn) {
|
||||
setCurrentTurnUndoConfirmOpen(false)
|
||||
}
|
||||
}}
|
||||
onConfirm={handleUndoCurrentTurn}
|
||||
title={t('chat.turnChangesConfirmTitle')}
|
||||
body={t('chat.turnChangesConfirmBody')}
|
||||
confirmLabel={t('chat.turnChangesConfirmUndo')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
confirmVariant="danger"
|
||||
loading={isUndoingCurrentTurn}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -483,18 +446,11 @@ export const MessageBlock = memo(function MessageBlock({
|
||||
activeThinkingId,
|
||||
agentTaskNotifications,
|
||||
toolResult,
|
||||
rewindableUserIndex,
|
||||
onRequestRewind,
|
||||
}: {
|
||||
message: UIMessage
|
||||
activeThinkingId: string | null
|
||||
agentTaskNotifications: Record<string, AgentTaskNotification>
|
||||
toolResult?: { content: unknown; isError: boolean } | null
|
||||
rewindableUserIndex?: number | null
|
||||
onRequestRewind?: (
|
||||
message: Extract<UIMessage, { type: 'user_text' }>,
|
||||
userMessageIndex: number,
|
||||
) => void
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
|
||||
@ -504,12 +460,6 @@ export const MessageBlock = memo(function MessageBlock({
|
||||
<UserMessage
|
||||
content={message.content}
|
||||
attachments={message.attachments}
|
||||
onRewind={
|
||||
typeof rewindableUserIndex === 'number' && onRequestRewind
|
||||
? () => onRequestRewind(message, rewindableUserIndex)
|
||||
: undefined
|
||||
}
|
||||
rewindLabel={t('chat.rewindAction')}
|
||||
/>
|
||||
)
|
||||
case 'assistant_text':
|
||||
|
||||
@ -5,11 +5,9 @@ import { MessageActionBar } from './MessageActionBar'
|
||||
type Props = {
|
||||
content: string
|
||||
attachments?: UIAttachment[]
|
||||
onRewind?: () => void
|
||||
rewindLabel?: string
|
||||
}
|
||||
|
||||
export function UserMessage({ content, attachments, onRewind, rewindLabel }: Props) {
|
||||
export function UserMessage({ content, attachments }: Props) {
|
||||
const hasText = content.trim().length > 0
|
||||
|
||||
return (
|
||||
@ -35,8 +33,6 @@ export function UserMessage({ content, attachments, onRewind, rewindLabel }: Pro
|
||||
<MessageActionBar
|
||||
copyText={content}
|
||||
copyLabel="Copy prompt"
|
||||
onRewind={onRewind}
|
||||
rewindLabel={rewindLabel}
|
||||
align="end"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -4,10 +4,12 @@ import { ThinkingBlock } from './ThinkingBlock'
|
||||
import { ToolCallBlock } from './ToolCallBlock'
|
||||
import { PermissionDialog } from './PermissionDialog'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
|
||||
describe('chat blocks', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
@ -21,6 +21,7 @@ type Props = {
|
||||
onChange?: (modelId: string) => void
|
||||
runtimeKey?: string
|
||||
disabled?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function officialChoices(availableModels: ModelInfo[], isDefault: boolean, officialName: string): ProviderChoice {
|
||||
@ -105,6 +106,7 @@ export function ModelSelector({
|
||||
onChange,
|
||||
runtimeKey,
|
||||
disabled = false,
|
||||
compact = false,
|
||||
}: Props = {}) {
|
||||
const t = useTranslation()
|
||||
const {
|
||||
@ -229,13 +231,15 @@ export function ModelSelector({
|
||||
<button
|
||||
onClick={() => !disabled && setOpen(!open)}
|
||||
disabled={disabled}
|
||||
className="flex max-w-[280px] items-center gap-2 rounded-full bg-[var(--color-surface-container-low)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className={`flex items-center gap-2 rounded-full bg-[var(--color-surface-container-low)] text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
compact ? 'max-w-[152px] px-2.5 py-1.5' : 'max-w-[280px] px-3 py-1.5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
<span className={`${compact ? 'text-xs' : 'text-sm'} min-w-0 flex-1 truncate font-semibold text-[var(--color-text-primary)]`}>
|
||||
{buttonModelLabel}
|
||||
</span>
|
||||
{buttonProviderLabel && (
|
||||
{!compact && buttonProviderLabel && (
|
||||
<span className="max-w-[108px] flex-shrink-0 truncate text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{buttonProviderLabel}
|
||||
</span>
|
||||
|
||||
@ -18,13 +18,14 @@ const MODE_ICONS: Record<PermissionMode, string> = {
|
||||
|
||||
type Props = {
|
||||
workDir?: string
|
||||
compact?: boolean
|
||||
/** Controlled mode: override current value */
|
||||
value?: PermissionMode
|
||||
/** Controlled mode: called on change instead of updating global store */
|
||||
onChange?: (mode: PermissionMode) => void
|
||||
}
|
||||
|
||||
export function PermissionModeSelector({ workDir: workDirProp, value, onChange }: Props = {}) {
|
||||
export function PermissionModeSelector({ workDir: workDirProp, compact = false, value, onChange }: Props = {}) {
|
||||
const t = useTranslation()
|
||||
const { permissionMode: storeMode, setPermissionMode } = useSettingsStore()
|
||||
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
|
||||
@ -104,11 +105,20 @@ export function PermissionModeSelector({ workDir: workDirProp, value, onChange }
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs font-medium text-[var(--color-text-secondary)] transition-colors"
|
||||
title={compact ? MODE_LABELS[currentMode] : undefined}
|
||||
className={`flex items-center bg-[var(--color-surface-container-low)] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] ${
|
||||
compact
|
||||
? 'h-8 w-8 justify-center rounded-full p-0'
|
||||
: 'gap-1.5 rounded-full px-2.5 py-1.5 text-xs'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{MODE_ICONS[currentMode]}</span>
|
||||
<span>{MODE_LABELS[currentMode]}</span>
|
||||
<span className="material-symbols-outlined text-[12px]">expand_more</span>
|
||||
{!compact && (
|
||||
<>
|
||||
<span>{MODE_LABELS[currentMode]}</span>
|
||||
<span className="material-symbols-outlined text-[12px]">expand_more</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
|
||||
@ -11,7 +11,6 @@ vi.mock('../../i18n', () => ({
|
||||
const translations: Record<string, string> = {
|
||||
'sidebar.newSession': 'New Session',
|
||||
'sidebar.scheduled': 'Scheduled',
|
||||
'sidebar.terminal': 'Terminal',
|
||||
'sidebar.settings': 'Settings',
|
||||
'sidebar.searchPlaceholder': 'Search sessions',
|
||||
'sidebar.noSessions': 'No sessions',
|
||||
@ -105,26 +104,6 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByRole('complementary')).not.toHaveAttribute('data-tauri-drag-region')
|
||||
})
|
||||
|
||||
it('opens each terminal click as a first-class app tab', () => {
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Terminal' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Terminal' }))
|
||||
|
||||
const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal')
|
||||
expect(terminalTabs).toHaveLength(2)
|
||||
expect(terminalTabs.map((tab) => tab.title)).toEqual(['Terminal 1', 'Terminal 2'])
|
||||
expect(useTabStore.getState().activeTabId).toBe(terminalTabs[1]!.sessionId)
|
||||
|
||||
useTabStore.getState().closeTab(terminalTabs[0]!.sessionId)
|
||||
useTabStore.getState().openTerminalTab()
|
||||
|
||||
expect(useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal').map((tab) => tab.title)).toEqual([
|
||||
'Terminal 2',
|
||||
'Terminal 3',
|
||||
])
|
||||
})
|
||||
|
||||
it('shows a toast when session creation fails', async () => {
|
||||
createSession.mockRejectedValue(new Error('boom'))
|
||||
|
||||
|
||||
@ -26,7 +26,6 @@ export function Sidebar() {
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type)
|
||||
const closeTab = useTabStore((s) => s.closeTab)
|
||||
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@ -203,15 +202,6 @@ export function Sidebar() {
|
||||
>
|
||||
{t('sidebar.scheduled')}
|
||||
</NavItem>
|
||||
<NavItem
|
||||
active={activeTabType === 'terminal'}
|
||||
collapsed={!sidebarOpen}
|
||||
label={t('sidebar.terminal')}
|
||||
onClick={() => useTabStore.getState().openTerminalTab()}
|
||||
icon={<span className="material-symbols-outlined text-[18px]">terminal</span>}
|
||||
>
|
||||
{t('sidebar.terminal')}
|
||||
</NavItem>
|
||||
</div>
|
||||
|
||||
{sidebarOpen ? (
|
||||
|
||||
@ -23,6 +23,9 @@ vi.mock('../../i18n', () => ({
|
||||
'tabs.closeConfirmMessage': 'Still running',
|
||||
'tabs.closeConfirmKeep': 'Keep Running',
|
||||
'tabs.closeConfirmStop': 'Stop & Close',
|
||||
'tabs.openTerminal': 'Open Terminal',
|
||||
'tabs.showWorkspace': 'Show Workspace',
|
||||
'tabs.hideWorkspace': 'Hide Workspace',
|
||||
'common.cancel': 'Cancel',
|
||||
}
|
||||
|
||||
@ -64,11 +67,13 @@ describe('TabBar', () => {
|
||||
afterEach(async () => {
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
|
||||
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useChatStore.setState({
|
||||
sessions: {},
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||
|
||||
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
|
||||
})
|
||||
@ -356,11 +361,122 @@ describe('TabBar', () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
expect(screen.getByText('terminal')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Open Terminal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Close Terminal 1'))
|
||||
|
||||
expect(disconnectSession).not.toHaveBeenCalled()
|
||||
expect(useTabStore.getState().tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('opens a terminal tab from the toolbar', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
{ sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' },
|
||||
],
|
||||
activeTabId: 'tab-1',
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {},
|
||||
disconnectSession: vi.fn(),
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Terminal' }))
|
||||
|
||||
const terminalTabs = useTabStore.getState().tabs.filter((tab) => tab.type === 'terminal')
|
||||
expect(terminalTabs).toHaveLength(1)
|
||||
expect(useTabStore.getState().activeTabId).toBe(terminalTabs[0]?.sessionId)
|
||||
})
|
||||
|
||||
it('toggles the workspace panel for the active session from the toolbar', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
{ sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' },
|
||||
],
|
||||
activeTabId: 'tab-1',
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {},
|
||||
disconnectSession: vi.fn(),
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show Workspace' }))
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(true)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide Workspace' }))
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the workspace toolbar button for non-session tabs', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
{ sessionId: '__terminal__1', title: 'Terminal 1', type: 'terminal', status: 'idle' },
|
||||
{ sessionId: '__settings__', title: 'Settings', type: 'settings', status: 'idle' },
|
||||
],
|
||||
activeTabId: '__terminal__1',
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {},
|
||||
disconnectSession: vi.fn(),
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
|
||||
const { rerender } = render(<TabBar />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
useTabStore.getState().setActiveTab('__settings__')
|
||||
})
|
||||
rerender(<TabBar />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears workspace panel state when closing a session tab', async () => {
|
||||
const { TabBar } = await import('./TabBar')
|
||||
const { useTabStore } = await import('../../stores/tabStore')
|
||||
const { useChatStore } = await import('../../stores/chatStore')
|
||||
const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore')
|
||||
|
||||
useTabStore.setState({
|
||||
tabs: [
|
||||
{ sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' },
|
||||
],
|
||||
activeTabId: 'tab-1',
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {},
|
||||
disconnectSession: vi.fn(),
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
useWorkspacePanelStore.getState().openPanel('tab-1')
|
||||
|
||||
await act(async () => {
|
||||
render(<TabBar />)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Close First Session'))
|
||||
|
||||
expect(useWorkspacePanelStore.getState().panelBySession['tab-1']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { forwardRef, useRef, useState, useEffect, useCallback } from 'react'
|
||||
import { useTabStore, type Tab } from '../../stores/tabStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { WindowControls, showWindowControls } from './WindowControls'
|
||||
import { Folder, FolderOpen, SquareTerminal } from 'lucide-react'
|
||||
|
||||
const TAB_WIDTH = 180
|
||||
const DRAG_START_THRESHOLD = 4
|
||||
@ -14,6 +16,11 @@ export function TabBar() {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab)
|
||||
const closeTab = useTabStore((s) => s.closeTab)
|
||||
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
||||
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId) ?? null
|
||||
const isActiveSessionTab = activeTab?.type === 'session'
|
||||
const isWorkspacePanelOpen = useWorkspacePanelStore((state) =>
|
||||
activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false,
|
||||
)
|
||||
|
||||
const moveTab = useTabStore((s) => s.moveTab)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@ -74,12 +81,19 @@ export function TabBar() {
|
||||
el.scrollBy({ left: direction === 'left' ? -TAB_WIDTH : TAB_WIDTH, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const closeTabWithCleanup = useCallback((tab: Tab) => {
|
||||
if (tab.type === 'session') {
|
||||
useWorkspacePanelStore.getState().clearSession(tab.sessionId)
|
||||
}
|
||||
closeTab(tab.sessionId)
|
||||
}, [closeTab])
|
||||
|
||||
const handleClose = (sessionId: string) => {
|
||||
// Special tabs can always be closed directly
|
||||
const tab = tabs.find((t) => t.sessionId === sessionId)
|
||||
if (!tab) return
|
||||
if (tab.type !== 'session') {
|
||||
closeTab(sessionId)
|
||||
closeTabWithCleanup(tab)
|
||||
return
|
||||
}
|
||||
|
||||
@ -92,7 +106,7 @@ export function TabBar() {
|
||||
}
|
||||
|
||||
disconnectSession(sessionId)
|
||||
closeTab(sessionId)
|
||||
closeTabWithCleanup(tab)
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, sessionId: string) => {
|
||||
@ -105,7 +119,7 @@ export function TabBar() {
|
||||
const otherTabs = tabs.filter((t) => t.sessionId !== sessionId)
|
||||
for (const tab of otherTabs) {
|
||||
if (tab.type === 'session') disconnectSession(tab.sessionId)
|
||||
closeTab(tab.sessionId)
|
||||
closeTabWithCleanup(tab)
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,7 +129,7 @@ export function TabBar() {
|
||||
const leftTabs = tabs.slice(0, idx)
|
||||
for (const tab of leftTabs) {
|
||||
if (tab.type === 'session') disconnectSession(tab.sessionId)
|
||||
closeTab(tab.sessionId)
|
||||
closeTabWithCleanup(tab)
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +139,7 @@ export function TabBar() {
|
||||
const rightTabs = tabs.slice(idx + 1)
|
||||
for (const tab of rightTabs) {
|
||||
if (tab.type === 'session') disconnectSession(tab.sessionId)
|
||||
closeTab(tab.sessionId)
|
||||
closeTabWithCleanup(tab)
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,7 +147,7 @@ export function TabBar() {
|
||||
setContextMenu(null)
|
||||
for (const tab of tabs) {
|
||||
if (tab.type === 'session') disconnectSession(tab.sessionId)
|
||||
closeTab(tab.sessionId)
|
||||
closeTabWithCleanup(tab)
|
||||
}
|
||||
}
|
||||
|
||||
@ -265,6 +279,22 @@ export function TabBar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)]/70 px-2">
|
||||
<ToolbarIconButton
|
||||
icon={<SquareTerminal size={17} strokeWidth={1.9} />}
|
||||
label={t('tabs.openTerminal')}
|
||||
onClick={() => useTabStore.getState().openTerminalTab()}
|
||||
/>
|
||||
{isActiveSessionTab && activeTabId && (
|
||||
<ToolbarIconButton
|
||||
icon={isWorkspacePanelOpen ? <FolderOpen size={18} strokeWidth={1.9} /> : <Folder size={18} strokeWidth={1.9} />}
|
||||
label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')}
|
||||
onClick={() => useWorkspacePanelStore.getState().togglePanel(activeTabId)}
|
||||
active={isWorkspacePanelOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isTauri && (
|
||||
<div
|
||||
data-testid="tab-bar-drag-gutter"
|
||||
@ -331,7 +361,11 @@ export function TabBar() {
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { closeTab(closingTabId); setClosingTabId(null) }}
|
||||
onClick={() => {
|
||||
const tab = tabs.find((item) => item.sessionId === closingTabId)
|
||||
if (tab) closeTabWithCleanup(tab)
|
||||
setClosingTabId(null)
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{t('tabs.closeConfirmKeep')}
|
||||
@ -340,7 +374,8 @@ export function TabBar() {
|
||||
onClick={() => {
|
||||
useChatStore.getState().stopGeneration(closingTabId)
|
||||
disconnectSession(closingTabId)
|
||||
closeTab(closingTabId)
|
||||
const tab = tabs.find((item) => item.sessionId === closingTabId)
|
||||
if (tab) closeTabWithCleanup(tab)
|
||||
setClosingTabId(null)
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-[var(--color-brand)] text-white hover:opacity-90"
|
||||
@ -423,3 +458,32 @@ const TabItem = forwardRef<HTMLDivElement, {
|
||||
)
|
||||
})
|
||||
TabItem.displayName = 'TabItem'
|
||||
|
||||
function ToolbarIconButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
active = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-[10px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
|
||||
active
|
||||
? 'bg-[var(--color-surface-hover)] text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,21 +2,26 @@ type Props = {
|
||||
workDir?: string | null
|
||||
repoName?: string | null
|
||||
branch?: string | null
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function ProjectContextChip({ workDir, repoName, branch }: Props) {
|
||||
export function ProjectContextChip({ workDir, repoName, branch, compact = false }: Props) {
|
||||
const label = branch ? (repoName || workDir?.split('/').pop() || '') : (workDir?.split('/').pop() || repoName || '')
|
||||
|
||||
if (!label) return null
|
||||
|
||||
return (
|
||||
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2 text-sm text-[var(--color-text-secondary)]">
|
||||
<div
|
||||
className={`inline-flex max-w-full items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] ${
|
||||
compact ? 'gap-1.5 px-3 py-1.5 text-xs' : 'gap-2 px-4 py-2 text-sm'
|
||||
}`}
|
||||
>
|
||||
{branch ? (
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" className="shrink-0 text-[var(--color-text-secondary)]">
|
||||
<svg width={compact ? 15 : 18} height={compact ? 15 : 18} viewBox="0 0 16 16" fill="currentColor" className="shrink-0 text-[var(--color-text-secondary)]">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">folder</span>
|
||||
<span className={`material-symbols-outlined text-[var(--color-text-secondary)] ${compact ? 'text-[15px]' : 'text-[18px]'}`}>folder</span>
|
||||
)}
|
||||
<span className="truncate font-medium text-[var(--color-text-primary)]">{label}</span>
|
||||
{branch ? (
|
||||
|
||||
@ -3,10 +3,12 @@ import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { UpdateChecker } from './UpdateChecker'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useUpdateStore } from '../../stores/updateStore'
|
||||
|
||||
describe('UpdateChecker', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
Object.defineProperty(window, '__TAURI__', {
|
||||
value: {},
|
||||
configurable: true,
|
||||
|
||||
174
desktop/src/components/workspace/WorkspaceCodeSurface.tsx
Normal file
174
desktop/src/components/workspace/WorkspaceCodeSurface.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { Highlight, type PrismTheme } from 'prism-react-renderer'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export const WORKSPACE_PREVIEW_LINE_LIMIT = 420
|
||||
|
||||
export const workspacePrismTheme: PrismTheme = {
|
||||
plain: {
|
||||
color: 'var(--color-code-fg)',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
styles: [
|
||||
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' } },
|
||||
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
|
||||
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
|
||||
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
|
||||
{ types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
|
||||
{ types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
|
||||
{ types: ['operator'], style: { color: 'var(--color-code-fg)' } },
|
||||
{ types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
|
||||
{ types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
|
||||
{ types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
|
||||
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
|
||||
{ types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
|
||||
{ types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
|
||||
],
|
||||
}
|
||||
|
||||
export function getFileExtension(name: string) {
|
||||
const cleanName = name.split('/').pop() ?? name
|
||||
const lastDot = cleanName.lastIndexOf('.')
|
||||
if (lastDot <= 0 || lastDot === cleanName.length - 1) return ''
|
||||
return cleanName.slice(lastDot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function normalizePrismLanguage(language: string) {
|
||||
const lower = language.toLowerCase()
|
||||
const map: Record<string, string> = {
|
||||
text: 'text',
|
||||
typescript: 'typescript',
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
javascript: 'javascript',
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
markdown: 'markdown',
|
||||
md: 'markdown',
|
||||
html: 'markup',
|
||||
xml: 'markup',
|
||||
shell: 'bash',
|
||||
sh: 'bash',
|
||||
zsh: 'bash',
|
||||
diff: 'diff',
|
||||
}
|
||||
return map[lower] ?? lower
|
||||
}
|
||||
|
||||
export function getLanguageFromPath(path: string) {
|
||||
return normalizePrismLanguage(getFileExtension(path) || 'text')
|
||||
}
|
||||
|
||||
export function InlineHighlightedCode({
|
||||
value,
|
||||
language,
|
||||
}: {
|
||||
value: string
|
||||
language: string
|
||||
}) {
|
||||
return (
|
||||
<Highlight
|
||||
theme={workspacePrismTheme}
|
||||
code={value}
|
||||
language={normalizePrismLanguage(language)}
|
||||
>
|
||||
{({ tokens, getTokenProps }) => (
|
||||
<>
|
||||
{(tokens[0] ?? []).map((token, tokenIndex) => {
|
||||
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
|
||||
return <span key={String(tokenKey)} {...tokenProps} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Highlight>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceDiffSurface({
|
||||
value,
|
||||
path,
|
||||
className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
|
||||
lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
|
||||
}: {
|
||||
value: string
|
||||
path: string
|
||||
className?: string
|
||||
lineLimit?: number
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const lines = value.split('\n')
|
||||
const visibleLines = lines.slice(0, lineLimit)
|
||||
const hiddenLineCount = Math.max(0, lines.length - visibleLines.length)
|
||||
const language = getLanguageFromPath(path)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="relative min-w-max py-2">
|
||||
<pre
|
||||
data-workspace-code=""
|
||||
data-testid="workspace-code"
|
||||
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55] text-[var(--color-code-fg)]"
|
||||
>
|
||||
{visibleLines.map((line, index) => {
|
||||
const isFileHeader = line.startsWith('diff --') || line.startsWith('--- ') || line.startsWith('+++ ')
|
||||
const isHunk = line.startsWith('@@')
|
||||
const isAdded = line.startsWith('+') && !line.startsWith('+++')
|
||||
const isRemoved = line.startsWith('-') && !line.startsWith('---')
|
||||
const isCodeLine = isAdded || isRemoved || line.startsWith(' ')
|
||||
const code = isCodeLine ? line.slice(1) : line
|
||||
const prefix = isCodeLine ? line[0] : ' '
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${index}:${line}`}
|
||||
className={`grid grid-cols-[48px_18px_minmax(0,1fr)] gap-2 px-3 ${
|
||||
isAdded
|
||||
? 'bg-[var(--color-diff-added-bg)]'
|
||||
: isRemoved
|
||||
? 'bg-[var(--color-diff-removed-bg)]'
|
||||
: isHunk
|
||||
? 'bg-[var(--color-diff-highlight-bg)]'
|
||||
: 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="select-none text-right text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span
|
||||
className={`select-none text-center ${
|
||||
isAdded
|
||||
? 'text-[var(--color-diff-added-text)]'
|
||||
: isRemoved
|
||||
? 'text-[var(--color-diff-removed-text)]'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
<span
|
||||
className={`whitespace-pre pr-6 ${
|
||||
isFileHeader
|
||||
? 'font-semibold text-[var(--color-text-secondary)]'
|
||||
: isHunk
|
||||
? 'font-semibold text-[var(--color-warning)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isCodeLine ? (
|
||||
code ? <InlineHighlightedCode value={code} language={language} /> : ' '
|
||||
) : (
|
||||
code || ' '
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
{hiddenLineCount > 0 && (
|
||||
<div className="sticky bottom-0 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
|
||||
{t('workspace.previewLineLimit', { count: lineLimit })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1033
desktop/src/components/workspace/WorkspacePanel.test.tsx
Normal file
1033
desktop/src/components/workspace/WorkspacePanel.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1173
desktop/src/components/workspace/WorkspacePanel.tsx
Normal file
1173
desktop/src/components/workspace/WorkspacePanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -41,6 +41,41 @@ export const en = {
|
||||
'titlebar.terminal': 'Terminal',
|
||||
'titlebar.history': 'History',
|
||||
|
||||
// ─── Workspace Panel ───────────────────────────────
|
||||
'workspace.changedFiles': 'Changed files',
|
||||
'workspace.allFiles': 'All files',
|
||||
'workspace.viewTabs': 'Workspace views',
|
||||
'workspace.previewTabs': 'Preview tabs',
|
||||
'workspace.filterPlaceholder': 'Filter files...',
|
||||
'workspace.clearFilter': 'Clear file filter',
|
||||
'workspace.refresh': 'Refresh workspace',
|
||||
'workspace.closePanel': 'Close workspace panel',
|
||||
'workspace.resizePanel': 'Resize workspace panel',
|
||||
'workspace.closeTab': 'Close tab',
|
||||
'workspace.preview': 'Preview',
|
||||
'workspace.previewEmpty': 'Select a file to preview.',
|
||||
'workspace.notGitRepo': 'Not a git repository.',
|
||||
'workspace.missingWorkdir': 'Working directory is missing.',
|
||||
'workspace.loadError': 'Failed to load workspace data.',
|
||||
'workspace.noChanges': 'No changes',
|
||||
'workspace.noFiles': 'No files',
|
||||
'workspace.noMatchingFiles': 'No matching files',
|
||||
'workspace.previewKind.diff': 'Diff',
|
||||
'workspace.previewKind.file': 'File',
|
||||
'workspace.previewState.loading': 'Loading preview...',
|
||||
'workspace.previewState.binary': 'Binary file preview is unavailable.',
|
||||
'workspace.previewState.tooLarge': 'File is too large to preview.',
|
||||
'workspace.previewState.missing': 'File not found.',
|
||||
'workspace.imagePreviewUnavailable': 'Image preview is unavailable.',
|
||||
'workspace.previewLineLimit': 'Showing first {count} lines. Open in your editor for the full file.',
|
||||
'workspace.addToChat': 'Add to chat',
|
||||
'workspace.copyPath': 'Copy path',
|
||||
'workspace.localComment': 'Local comment',
|
||||
'workspace.commentLine': 'Comment line {line}',
|
||||
'workspace.commentLineTarget': 'Line {line}',
|
||||
'workspace.commentPlaceholder': 'Describe what should change here...',
|
||||
'workspace.addCommentToChat': 'Add comment',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': 'Connected',
|
||||
'status.connecting': 'Connecting...',
|
||||
@ -520,6 +555,7 @@ export const en = {
|
||||
'chat.placeholder': 'Ask Claude to edit, debug or explain...',
|
||||
'chat.placeholderMissing': 'This session points to a missing workspace. Create a new session or pick another project.',
|
||||
'chat.addFiles': 'Add files or photos',
|
||||
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
|
||||
'chat.slashCommands': 'Slash commands',
|
||||
'slash.mcp.title': 'Available MCP tools',
|
||||
'slash.mcp.subtitle': 'Global and current-project MCP servers available in this chat context.',
|
||||
@ -610,23 +646,21 @@ export const en = {
|
||||
'chat.select': 'select',
|
||||
'chat.dismiss': 'dismiss',
|
||||
'chat.stopTitle': 'Stop generation (Cmd+.)',
|
||||
'chat.rewindAction': 'Rewind to here',
|
||||
'chat.rewindModalTitle': 'Rewind Conversation',
|
||||
'chat.rewindConfirm': 'Rewind here',
|
||||
'chat.rewindPromptLabel': 'Selected prompt',
|
||||
'chat.rewindAttachmentOnly': 'This message only contains attachments.',
|
||||
'chat.rewindLoading': 'Inspecting file checkpoints for this turn...',
|
||||
'chat.rewindConversationCardTitle': 'Conversation rollback',
|
||||
'chat.rewindConversationCardBody': 'Remove this prompt and the {count} active messages that follow it, then reopen the composer at that point.',
|
||||
'chat.rewindCodeCardTitle': 'Code rollback',
|
||||
'chat.rewindCodeFiles': '{count} files will be restored',
|
||||
'chat.rewindCodeInsertions': '{count} insertions will be removed',
|
||||
'chat.rewindCodeDeletions': '{count} deletions will be restored',
|
||||
'chat.rewindCodeUnavailable': 'No file checkpoint is available for this prompt. The conversation can still be rewound.',
|
||||
'chat.rewindFilesLabel': 'Affected files',
|
||||
'chat.rewindFilesMore': '+{count} more',
|
||||
'chat.rewindSuccessWithCode': 'Rewound {count} messages and restored tracked files.',
|
||||
'chat.rewindSuccessConversationOnly': 'Rewound {count} messages. No file checkpoint was available for this turn.',
|
||||
'chat.turnChangesCardLabel': 'Current turn changed files',
|
||||
'chat.turnChangesTitle': '{count} files changed',
|
||||
'chat.turnChangesSubtitle': 'Current turn checkpoint',
|
||||
'chat.turnChangesUndo': 'Undo',
|
||||
'chat.turnChangesUndoing': 'Undoing...',
|
||||
'chat.turnChangesUndoAria': 'Undo current turn changes',
|
||||
'chat.turnChangesConfirmTitle': 'Undo current turn?',
|
||||
'chat.turnChangesConfirmBody': 'This will rewind the latest assistant response and restore tracked files for this turn.',
|
||||
'chat.turnChangesConfirmUndo': 'Undo current turn',
|
||||
'chat.turnChangesShowDiffAria': 'Show diff for {path}',
|
||||
'chat.turnChangesHideDiffAria': 'Hide diff for {path}',
|
||||
'chat.turnChangesDiffLoading': 'Loading diff...',
|
||||
'chat.turnChangesDiffUnavailable': 'Diff unavailable.',
|
||||
|
||||
// ─── Streaming Indicator ──────────────────────────────────────
|
||||
'streaming.thinking': 'Thinking',
|
||||
@ -983,6 +1017,9 @@ export const en = {
|
||||
'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?',
|
||||
'tabs.closeConfirmKeep': 'Keep Running',
|
||||
'tabs.closeConfirmStop': 'Stop & Close',
|
||||
'tabs.openTerminal': 'Open Terminal',
|
||||
'tabs.showWorkspace': 'Show Workspace',
|
||||
'tabs.hideWorkspace': 'Hide Workspace',
|
||||
} as const
|
||||
|
||||
export type TranslationKey = keyof typeof en
|
||||
|
||||
@ -43,6 +43,41 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'titlebar.terminal': '终端',
|
||||
'titlebar.history': '历史',
|
||||
|
||||
// ─── Workspace Panel ───────────────────────────────
|
||||
'workspace.changedFiles': '已更改文件',
|
||||
'workspace.allFiles': '所有文件',
|
||||
'workspace.viewTabs': '工作区视图',
|
||||
'workspace.previewTabs': '预览标签',
|
||||
'workspace.filterPlaceholder': '筛选文件...',
|
||||
'workspace.clearFilter': '清除文件筛选',
|
||||
'workspace.refresh': '刷新工作区',
|
||||
'workspace.closePanel': '关闭工作区面板',
|
||||
'workspace.resizePanel': '调整工作区宽度',
|
||||
'workspace.closeTab': '关闭标签',
|
||||
'workspace.preview': '预览',
|
||||
'workspace.previewEmpty': '选择一个文件进行预览。',
|
||||
'workspace.notGitRepo': '当前目录不是 Git 仓库。',
|
||||
'workspace.missingWorkdir': '工作目录不存在。',
|
||||
'workspace.loadError': '工作区数据加载失败。',
|
||||
'workspace.noChanges': '没有变更',
|
||||
'workspace.noFiles': '没有文件',
|
||||
'workspace.noMatchingFiles': '没有匹配的文件',
|
||||
'workspace.previewKind.diff': 'Diff',
|
||||
'workspace.previewKind.file': '文件',
|
||||
'workspace.previewState.loading': '正在加载预览...',
|
||||
'workspace.previewState.binary': '二进制文件暂不支持预览。',
|
||||
'workspace.previewState.tooLarge': '文件过大,无法预览。',
|
||||
'workspace.previewState.missing': '文件不存在。',
|
||||
'workspace.imagePreviewUnavailable': '图片无法预览。',
|
||||
'workspace.previewLineLimit': '仅显示前 {count} 行。完整内容请在编辑器中打开。',
|
||||
'workspace.addToChat': '添加到聊天',
|
||||
'workspace.copyPath': '复制路径',
|
||||
'workspace.localComment': '本地评论',
|
||||
'workspace.commentLine': '评论第 {line} 行',
|
||||
'workspace.commentLineTarget': '第 {line} 行',
|
||||
'workspace.commentPlaceholder': '描述你希望这里怎么改...',
|
||||
'workspace.addCommentToChat': '添加评论',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': '已连接',
|
||||
'status.connecting': '连接中...',
|
||||
@ -522,6 +557,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.placeholder': '让 Claude 编辑、调试或解释代码...',
|
||||
'chat.placeholderMissing': '此会话指向的工作目录缺失。请新建会话或选择其他项目。',
|
||||
'chat.addFiles': '添加文件或图片',
|
||||
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
|
||||
'chat.slashCommands': '斜杠命令',
|
||||
'slash.mcp.title': '可用 MCP 工具',
|
||||
'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。',
|
||||
@ -612,23 +648,21 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.select': '选择',
|
||||
'chat.dismiss': '关闭',
|
||||
'chat.stopTitle': '停止生成 (Cmd+.)',
|
||||
'chat.rewindAction': '回滚到这里',
|
||||
'chat.rewindModalTitle': '回滚对话',
|
||||
'chat.rewindConfirm': '执行回滚',
|
||||
'chat.rewindPromptLabel': '目标提示词',
|
||||
'chat.rewindAttachmentOnly': '这条消息只包含附件。',
|
||||
'chat.rewindLoading': '正在检查这一轮的文件检查点...',
|
||||
'chat.rewindConversationCardTitle': '对话回滚',
|
||||
'chat.rewindConversationCardBody': '移除这条提示词以及其后的 {count} 条活跃消息,并把输入框恢复到这个位置。',
|
||||
'chat.rewindCodeCardTitle': '代码回滚',
|
||||
'chat.rewindCodeFiles': '将恢复 {count} 个文件',
|
||||
'chat.rewindCodeInsertions': '将移除 {count} 行新增',
|
||||
'chat.rewindCodeDeletions': '将恢复 {count} 行删除',
|
||||
'chat.rewindCodeUnavailable': '这条提示词没有可用的文件检查点,仍然可以只回滚对话。',
|
||||
'chat.rewindFilesLabel': '受影响文件',
|
||||
'chat.rewindFilesMore': '另有 {count} 个',
|
||||
'chat.rewindSuccessWithCode': '已回滚 {count} 条消息,并恢复相关文件。',
|
||||
'chat.rewindSuccessConversationOnly': '已回滚 {count} 条消息。这一轮没有可用的文件检查点。',
|
||||
'chat.turnChangesCardLabel': '当前轮次已更改文件',
|
||||
'chat.turnChangesTitle': '{count} 个文件已更改',
|
||||
'chat.turnChangesSubtitle': '当前轮次检查点',
|
||||
'chat.turnChangesUndo': '撤销',
|
||||
'chat.turnChangesUndoing': '正在撤销...',
|
||||
'chat.turnChangesUndoAria': '撤销当前轮次变更',
|
||||
'chat.turnChangesConfirmTitle': '撤销当前轮次?',
|
||||
'chat.turnChangesConfirmBody': '这会回滚最近一次助手回复,并恢复这一轮中被跟踪的文件变更。',
|
||||
'chat.turnChangesConfirmUndo': '撤销当前轮次',
|
||||
'chat.turnChangesShowDiffAria': '查看 {path} 的 diff',
|
||||
'chat.turnChangesHideDiffAria': '收起 {path} 的 diff',
|
||||
'chat.turnChangesDiffLoading': '正在加载 diff...',
|
||||
'chat.turnChangesDiffUnavailable': 'Diff 不可用。',
|
||||
|
||||
// ─── Streaming Indicator ──────────────────────────────────────
|
||||
'streaming.thinking': '思考中',
|
||||
@ -985,4 +1019,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?',
|
||||
'tabs.closeConfirmKeep': '保持运行',
|
||||
'tabs.closeConfirmStop': '停止并关闭',
|
||||
'tabs.openTerminal': '打开终端',
|
||||
'tabs.showWorkspace': '显示工作区',
|
||||
'tabs.hideWorkspace': '隐藏工作区',
|
||||
}
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { act } from 'react'
|
||||
|
||||
vi.mock('../components/chat/MessageList', () => ({
|
||||
MessageList: () => <div data-testid="message-list" />,
|
||||
MessageList: ({ compact }: { compact?: boolean }) => (
|
||||
<div data-testid="message-list" data-compact={compact ? 'true' : 'false'} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/chat/ChatInput', () => ({
|
||||
ChatInput: () => <div data-testid="chat-input" />,
|
||||
ChatInput: ({ compact, variant }: { compact?: boolean; variant?: string }) => (
|
||||
<div data-testid="chat-input" data-compact={compact ? 'true' : 'false'} data-variant={variant} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/teams/TeamStatusBar', () => ({
|
||||
@ -19,12 +23,20 @@ vi.mock('../components/chat/SessionTaskBar', () => ({
|
||||
SessionTaskBar: () => <div data-testid="session-task-bar" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/workspace/WorkspacePanel', () => ({
|
||||
WorkspacePanel: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="workspace-panel">workspace:{sessionId}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { ActiveSession } from './ActiveSession'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useCLITaskStore } from '../stores/cliTaskStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useTeamStore } from '../stores/teamStore'
|
||||
import { useWorkspacePanelStore } from '../stores/workspacePanelStore'
|
||||
import { WORKSPACE_PANEL_DEFAULT_WIDTH } from '../stores/workspacePanelStore'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
@ -32,6 +44,7 @@ afterEach(() => {
|
||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null })
|
||||
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
describe('ActiveSession task polling', () => {
|
||||
@ -178,4 +191,180 @@ describe('ActiveSession task polling', () => {
|
||||
unmount()
|
||||
useCLITaskStore.setState(originalCliTaskState)
|
||||
})
|
||||
|
||||
it('renders the workspace panel to the right of chat and supports resizing', () => {
|
||||
const sessionId = 'workspace-session'
|
||||
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: sessionId,
|
||||
title: 'Workspace Session',
|
||||
createdAt: '2026-04-10T00:00:00.000Z',
|
||||
modifiedAt: '2026-04-10T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
projectPath: '',
|
||||
workDir: '/tmp/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: sessionId,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId, title: 'Workspace Session', type: 'session', status: 'idle' }],
|
||||
activeTabId: sessionId,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[sessionId]: {
|
||||
messages: [{ id: 'msg-1', type: 'assistant_text', content: 'hello', timestamp: 1 }],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
useWorkspacePanelStore.getState().openPanel(sessionId)
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
const contentRow = screen.getByTestId('active-session-content-row')
|
||||
const chatColumn = screen.getByTestId('active-session-chat-column')
|
||||
const resizeHandle = screen.getByTestId('workspace-resize-handle')
|
||||
|
||||
expect(within(contentRow).getByTestId('message-list')).toBeInTheDocument()
|
||||
expect(within(contentRow).getByTestId('message-list')).toHaveAttribute('data-compact', 'true')
|
||||
expect(within(contentRow).getByTestId('workspace-panel')).toHaveTextContent(`workspace:${sessionId}`)
|
||||
expect(within(chatColumn).getByTestId('chat-input')).toBeInTheDocument()
|
||||
expect(within(chatColumn).getByTestId('chat-input')).toHaveAttribute('data-compact', 'true')
|
||||
expect(chatColumn).toHaveClass('flex-1')
|
||||
expect(chatColumn).not.toHaveClass('shrink-0')
|
||||
expect(contentRow.children[0]).toBe(chatColumn)
|
||||
expect(contentRow.children[1]).toBe(resizeHandle)
|
||||
expect(contentRow.children[2]).toBe(screen.getByTestId('workspace-panel'))
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(resizeHandle, { key: 'ArrowLeft' })
|
||||
})
|
||||
|
||||
expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_DEFAULT_WIDTH + 32)
|
||||
})
|
||||
|
||||
it('does not render the workspace panel when closed or for member sessions', () => {
|
||||
const regularSessionId = 'regular-session'
|
||||
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: regularSessionId,
|
||||
title: 'Regular Session',
|
||||
createdAt: '2026-04-10T00:00:00.000Z',
|
||||
modifiedAt: '2026-04-10T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '',
|
||||
workDir: '/tmp/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: regularSessionId,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId: regularSessionId, title: 'Regular Session', type: 'session', status: 'idle' }],
|
||||
activeTabId: regularSessionId,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[regularSessionId]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { rerender } = render(<ActiveSession />)
|
||||
expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument()
|
||||
|
||||
const memberSessionId = 'team-member:security-reviewer@test-team'
|
||||
useTeamStore.setState({
|
||||
teams: [],
|
||||
activeTeam: {
|
||||
name: 'test-team',
|
||||
leadAgentId: 'team-lead@test-team',
|
||||
leadSessionId: 'leader-session',
|
||||
members: [
|
||||
{
|
||||
agentId: 'team-lead@test-team',
|
||||
role: 'team-lead',
|
||||
status: 'running',
|
||||
sessionId: 'leader-session',
|
||||
},
|
||||
{
|
||||
agentId: 'security-reviewer@test-team',
|
||||
role: 'security-reviewer',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
},
|
||||
memberColors: new Map(),
|
||||
error: null,
|
||||
})
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId: memberSessionId, title: 'security-reviewer', type: 'session', status: 'idle' }],
|
||||
activeTabId: memberSessionId,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[memberSessionId]: {
|
||||
messages: [{ id: 'msg-2', type: 'assistant_text', content: 'hello', timestamp: 1 }],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
useWorkspacePanelStore.getState().openPanel(memberSessionId)
|
||||
|
||||
rerender(<ActiveSession />)
|
||||
|
||||
expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,20 +1,95 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useCLITaskStore } from '../stores/cliTaskStore'
|
||||
import { useTeamStore } from '../stores/teamStore'
|
||||
import { useWorkspacePanelStore } from '../stores/workspacePanelStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { MessageList } from '../components/chat/MessageList'
|
||||
import { ChatInput } from '../components/chat/ChatInput'
|
||||
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
|
||||
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
|
||||
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
|
||||
import { WorkspacePanel } from '../components/workspace/WorkspacePanel'
|
||||
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
|
||||
|
||||
const TASK_POLL_INTERVAL_MS = 1000
|
||||
const WORKSPACE_RESIZE_STEP = 32
|
||||
const CHAT_COLUMN_WITH_WORKSPACE_CLASS =
|
||||
'min-w-[320px] flex-1 border-r border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
|
||||
function WorkspaceResizeHandle() {
|
||||
const t = useTranslation()
|
||||
const width = useWorkspacePanelStore((state) => state.width)
|
||||
const setWidth = useWorkspacePanelStore((state) => state.setWidth)
|
||||
const [dragState, setDragState] = useState<{ startX: number; startWidth: number } | null>(null)
|
||||
const dragStateRef = useRef(dragState)
|
||||
|
||||
useEffect(() => {
|
||||
dragStateRef.current = dragState
|
||||
}, [dragState])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState) return
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const current = dragStateRef.current
|
||||
if (!current) return
|
||||
setWidth(current.startWidth + current.startX - event.clientX)
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setDragState(null)
|
||||
}
|
||||
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
window.addEventListener('pointercancel', handlePointerUp)
|
||||
|
||||
return () => {
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
window.removeEventListener('pointercancel', handlePointerUp)
|
||||
}
|
||||
}, [dragState, setWidth])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-label={t('workspace.resizePanel')}
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={width}
|
||||
tabIndex={0}
|
||||
data-testid="workspace-resize-handle"
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return
|
||||
event.preventDefault()
|
||||
setDragState({ startX: event.clientX, startWidth: width })
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
setWidth(width + WORKSPACE_RESIZE_STEP)
|
||||
}
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
setWidth(width - WORKSPACE_RESIZE_STEP)
|
||||
}
|
||||
}}
|
||||
className="group relative z-10 flex w-2 shrink-0 cursor-col-resize items-stretch justify-center bg-[var(--color-surface)] outline-none focus-visible:bg-[var(--color-surface-container)]"
|
||||
>
|
||||
<div className="my-3 w-px rounded-full bg-[var(--color-border)] transition-colors group-hover:bg-[var(--color-border-focus)] group-focus-visible:bg-[var(--color-border-focus)]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActiveSession() {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const activeTabType = useTabStore((s) => s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type ?? null)
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const connectToSession = useChatStore((s) => s.connectToSession)
|
||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||
@ -29,6 +104,11 @@ export function ActiveSession() {
|
||||
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
||||
const activeTeam = useTeamStore((s) => s.activeTeam)
|
||||
const isMemberSession = !!memberInfo
|
||||
const showWorkspacePanel = useWorkspacePanelStore((state) =>
|
||||
activeTabId && activeTabType === 'session' && !isMemberSession
|
||||
? state.isPanelOpen(activeTabId)
|
||||
: false,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTabId && !isMemberSession) {
|
||||
@ -81,132 +161,167 @@ export function ActiveSession() {
|
||||
if (!activeTabId) return null
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface">
|
||||
{isMemberSession && (
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
|
||||
<div className="mx-auto max-w-[860px] flex items-center justify-between gap-4 px-8 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{memberInfo?.status === 'running' && (
|
||||
<span className="flex h-2 w-2 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
|
||||
)}
|
||||
{memberInfo?.status === 'completed' && (
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
)}
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">smart_toy</span>
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{memberInfo?.role}
|
||||
</span>
|
||||
{activeTeam && (
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
@ {activeTeam.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('teams.memberSessionHint')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTeam?.leadSessionId) {
|
||||
useTabStore.getState().openTab(
|
||||
activeTeam.leadSessionId,
|
||||
t('teams.leader'),
|
||||
'session',
|
||||
)
|
||||
}
|
||||
}}
|
||||
disabled={!activeTeam?.leadSessionId}
|
||||
className="flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
|
||||
{t('teams.backToLeader')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
|
||||
<div className="flex max-w-md flex-col items-center text-center">
|
||||
{isMemberSession ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-[48px] mb-4 text-[var(--color-text-tertiary)]">smart_toy</span>
|
||||
<p className="text-[var(--color-text-secondary)]">
|
||||
{memberInfo?.status === 'running'
|
||||
? `${memberInfo.role} ${t('teams.working')}`
|
||||
: t('teams.noMessages')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24" />
|
||||
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
|
||||
{t('empty.title')}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: 'var(--font-body)' }}>
|
||||
{t('empty.subtitle')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!isMemberSession && (
|
||||
<div className="mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-bold font-headline text-on-surface leading-tight">
|
||||
{session?.title || t('session.untitled')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-[10px] text-outline font-medium mt-1">
|
||||
{isActive && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
|
||||
{t('session.active')}
|
||||
</span>
|
||||
)}
|
||||
{totalTokens > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{totalTokens.toLocaleString()} t</span>
|
||||
</>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{t('session.lastUpdated', { time: lastUpdated })}</span>
|
||||
</>
|
||||
)}
|
||||
{session?.messageCount !== undefined && session.messageCount > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{t('session.messages', { count: session.messageCount })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{session?.workDirExists === false && (
|
||||
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
|
||||
<span className="material-symbols-outlined text-[14px]">warning</span>
|
||||
<span className="truncate">
|
||||
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
|
||||
<div className="flex-1 flex relative overflow-hidden bg-background text-on-surface">
|
||||
<div data-testid="active-session-content-row" className="flex min-h-0 min-w-0 flex-1">
|
||||
<div
|
||||
data-testid="active-session-chat-column"
|
||||
className={`flex flex-col ${showWorkspacePanel ? CHAT_COLUMN_WITH_WORKSPACE_CLASS : 'min-w-[360px] flex-1'}`}
|
||||
>
|
||||
{isMemberSession && (
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
|
||||
<div className="mx-auto max-w-[860px] flex items-center justify-between gap-4 px-8 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{memberInfo?.status === 'running' && (
|
||||
<span className="flex h-2 w-2 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
|
||||
)}
|
||||
{memberInfo?.status === 'completed' && (
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
)}
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">smart_toy</span>
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{memberInfo?.role}
|
||||
</span>
|
||||
{activeTeam && (
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
@ {activeTeam.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('teams.memberSessionHint')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTeam?.leadSessionId) {
|
||||
useTabStore.getState().openTab(
|
||||
activeTeam.leadSessionId,
|
||||
t('teams.leader'),
|
||||
'session',
|
||||
)
|
||||
}
|
||||
}}
|
||||
disabled={!activeTeam?.leadSessionId}
|
||||
className="flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
|
||||
{t('teams.backToLeader')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageList />
|
||||
</>
|
||||
)}
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
|
||||
<div className="flex max-w-md flex-col items-center text-center">
|
||||
{isMemberSession ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-[48px] mb-4 text-[var(--color-text-tertiary)]">smart_toy</span>
|
||||
<p className="text-[var(--color-text-secondary)]">
|
||||
{memberInfo?.status === 'running'
|
||||
? `${memberInfo.role} ${t('teams.working')}`
|
||||
: t('teams.noMessages')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24" />
|
||||
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
|
||||
{t('empty.title')}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: 'var(--font-body)' }}>
|
||||
{t('empty.subtitle')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!isMemberSession && (
|
||||
<div
|
||||
className={
|
||||
showWorkspacePanel
|
||||
? 'flex w-full items-center border-b border-[var(--color-border)]/70 px-4 py-3'
|
||||
: 'mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3'
|
||||
}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1
|
||||
className={
|
||||
showWorkspacePanel
|
||||
? 'truncate text-[15px] font-bold font-headline leading-tight text-on-surface'
|
||||
: 'text-lg font-bold font-headline text-on-surface leading-tight'
|
||||
}
|
||||
>
|
||||
{session?.title || t('session.untitled')}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
showWorkspacePanel
|
||||
? 'mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap text-[10px] font-medium text-outline'
|
||||
: 'flex items-center gap-2 text-[10px] text-outline font-medium mt-1'
|
||||
}
|
||||
>
|
||||
{isActive && (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
|
||||
{t('session.active')}
|
||||
</span>
|
||||
)}
|
||||
{totalTokens > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{totalTokens.toLocaleString()} t</span>
|
||||
</>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<span className="shrink-0 text-[var(--color-outline)]">·</span>
|
||||
<span className="truncate">{t('session.lastUpdated', { time: lastUpdated })}</span>
|
||||
</>
|
||||
)}
|
||||
{!showWorkspacePanel && session?.messageCount !== undefined && session.messageCount > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{t('session.messages', { count: session.messageCount })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{session?.workDirExists === false && (
|
||||
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
|
||||
<span className="material-symbols-outlined text-[14px]">warning</span>
|
||||
<span className="truncate">
|
||||
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMemberSession && <SessionTaskBar />}
|
||||
<MessageList compact={showWorkspacePanel} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TeamStatusBar />
|
||||
{!isMemberSession && <SessionTaskBar />}
|
||||
|
||||
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
|
||||
<TeamStatusBar />
|
||||
|
||||
<ChatInput
|
||||
variant={isEmpty && !isMemberSession && !showWorkspacePanel ? 'hero' : 'default'}
|
||||
compact={showWorkspacePanel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showWorkspacePanel ? (
|
||||
<>
|
||||
<WorkspaceResizeHandle />
|
||||
<WorkspacePanel sessionId={activeTabId} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isMemberSession && activeTabId ? (
|
||||
<ComputerUsePermissionModal
|
||||
|
||||
@ -231,6 +231,117 @@ describe('chatStore history mapping', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('restores CLI file mentions as visible attachment chips from transcript history', () => {
|
||||
const messages: MessageEntry[] = [
|
||||
{
|
||||
id: 'user-with-file-mention',
|
||||
type: 'user',
|
||||
timestamp: '2026-04-06T00:00:00.000Z',
|
||||
content: '@"/private/tmp/example/src/sentinel.ts" 这个常量是什么?',
|
||||
},
|
||||
]
|
||||
|
||||
const mapped = mapHistoryMessagesToUiMessages(messages)
|
||||
|
||||
expect(mapped).toMatchObject([
|
||||
{
|
||||
id: 'user-with-file-mention',
|
||||
type: 'user_text',
|
||||
content: '这个常量是什么?',
|
||||
modelContent: '@"/private/tmp/example/src/sentinel.ts" 这个常量是什么?',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'sentinel.ts',
|
||||
path: '/private/tmp/example/src/sentinel.ts',
|
||||
}],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps workspace reference chips visible while sending CLI attachment paths', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: {
|
||||
messages: [],
|
||||
chatState: 'idle',
|
||||
connectionState: 'connected',
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().sendMessage(
|
||||
TEST_SESSION_ID,
|
||||
'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
|
||||
[{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
path: '/repo/src/App.tsx',
|
||||
lineStart: 4,
|
||||
lineEnd: 4,
|
||||
note: 'tighten this',
|
||||
quote: 'const value = 1',
|
||||
}],
|
||||
{
|
||||
displayContent: '改这里',
|
||||
displayAttachments: [{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
path: 'src/App.tsx',
|
||||
lineStart: 4,
|
||||
lineEnd: 4,
|
||||
note: 'tighten this',
|
||||
quote: 'const value = 1',
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||
{
|
||||
type: 'user_text',
|
||||
content: '改这里',
|
||||
modelContent: 'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
path: 'src/App.tsx',
|
||||
lineStart: 4,
|
||||
lineEnd: 4,
|
||||
note: 'tighten this',
|
||||
quote: 'const value = 1',
|
||||
}],
|
||||
},
|
||||
])
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
TEST_SESSION_ID,
|
||||
{
|
||||
type: 'user_message',
|
||||
content: 'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
path: '/repo/src/App.tsx',
|
||||
lineStart: 4,
|
||||
lineEnd: 4,
|
||||
note: 'tighten this',
|
||||
quote: 'const value = 1',
|
||||
}],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps parent tool linkage for live tool events', () => {
|
||||
// Initialize the session first
|
||||
useChatStore.setState({
|
||||
|
||||
@ -92,7 +92,7 @@ type ChatStore = {
|
||||
sessionId: string,
|
||||
content: string,
|
||||
attachments?: AttachmentRef[],
|
||||
options?: { displayContent?: string },
|
||||
options?: { displayContent?: string; displayAttachments?: AttachmentRef[] },
|
||||
) => void
|
||||
respondToPermission: (
|
||||
sessionId: string,
|
||||
@ -265,13 +265,19 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
const userFacingContent =
|
||||
options?.displayContent?.trim() || content.trim()
|
||||
const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId)
|
||||
const visibleAttachments = options?.displayAttachments ?? attachments
|
||||
const uiAttachments: UIAttachment[] | undefined =
|
||||
attachments && attachments.length > 0
|
||||
? attachments.map((a) => ({
|
||||
visibleAttachments && visibleAttachments.length > 0
|
||||
? visibleAttachments.map((a) => ({
|
||||
type: a.type,
|
||||
name: a.name || a.path || a.mimeType || a.type,
|
||||
path: a.path,
|
||||
data: a.data,
|
||||
mimeType: a.mimeType,
|
||||
lineStart: a.lineStart,
|
||||
lineEnd: a.lineEnd,
|
||||
note: a.note,
|
||||
quote: a.quote,
|
||||
}))
|
||||
: undefined
|
||||
|
||||
@ -309,6 +315,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
id: nextId(),
|
||||
type: 'user_text',
|
||||
content: userFacingContent,
|
||||
...(userFacingContent !== content ? { modelContent: content } : {}),
|
||||
attachments: isMemberSession ? undefined : uiAttachments,
|
||||
timestamp: Date.now(),
|
||||
...(isMemberSession ? { pending: true } : {}),
|
||||
@ -918,6 +925,43 @@ type HistoryMappingOptions = {
|
||||
includeTeammateMessages?: boolean
|
||||
}
|
||||
|
||||
function getReferenceName(referencePath: string): string {
|
||||
const normalized = referencePath.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const name = normalized.split('/').filter(Boolean).pop()
|
||||
return name || referencePath
|
||||
}
|
||||
|
||||
function extractLeadingFileReferences(text: string): {
|
||||
content: string
|
||||
attachments?: UIAttachment[]
|
||||
modelContent?: string
|
||||
} {
|
||||
const attachments: UIAttachment[] = []
|
||||
let remaining = text
|
||||
|
||||
while (true) {
|
||||
const match = remaining.match(/^@"([^"]+)"\s*/)
|
||||
if (!match?.[1]) break
|
||||
|
||||
attachments.push({
|
||||
type: 'file',
|
||||
name: getReferenceName(match[1]),
|
||||
path: match[1],
|
||||
})
|
||||
remaining = remaining.slice(match[0].length)
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return { content: text }
|
||||
}
|
||||
|
||||
return {
|
||||
content: remaining.trimStart(),
|
||||
attachments,
|
||||
modelContent: text,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct agentTaskNotifications from history.
|
||||
*
|
||||
@ -1013,7 +1057,15 @@ export function mapHistoryMessagesToUiMessages(
|
||||
})
|
||||
continue
|
||||
}
|
||||
uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp })
|
||||
const parsed = extractLeadingFileReferences(msg.content)
|
||||
uiMessages.push({
|
||||
id: msg.id || nextId(),
|
||||
type: 'user_text',
|
||||
content: parsed.content,
|
||||
...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}),
|
||||
...(parsed.attachments ? { attachments: parsed.attachments } : {}),
|
||||
timestamp,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (msg.type === 'assistant' && typeof msg.content === 'string') {
|
||||
@ -1043,7 +1095,16 @@ export function mapHistoryMessagesToUiMessages(
|
||||
else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId })
|
||||
}
|
||||
if (textParts.length > 0 || attachments.length > 0) {
|
||||
uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: textParts.join('\n'), attachments: attachments.length > 0 ? attachments : undefined, timestamp })
|
||||
const parsed = extractLeadingFileReferences(textParts.join('\n'))
|
||||
const allAttachments = [...(parsed.attachments ?? []), ...attachments]
|
||||
uiMessages.push({
|
||||
id: msg.id || nextId(),
|
||||
type: 'user_text',
|
||||
content: parsed.content,
|
||||
...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}),
|
||||
attachments: allAttachments.length > 0 ? allAttachments : undefined,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
desktop/src/stores/workspaceChatContextStore.test.ts
Normal file
69
desktop/src/stores/workspaceChatContextStore.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatWorkspaceReferencePrompt,
|
||||
useWorkspaceChatContextStore,
|
||||
} from './workspaceChatContextStore'
|
||||
|
||||
const initialState = useWorkspaceChatContextStore.getInitialState()
|
||||
|
||||
describe('workspaceChatContextStore', () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceChatContextStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
it('deduplicates file references per session', () => {
|
||||
const store = useWorkspaceChatContextStore.getState()
|
||||
|
||||
store.addReference('session-1', {
|
||||
kind: 'file',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
})
|
||||
store.addReference('session-1', {
|
||||
kind: 'file',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
})
|
||||
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-1']).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('formats line comments into the request prompt', () => {
|
||||
const prompt = formatWorkspaceReferencePrompt([
|
||||
{
|
||||
id: 'ref-1',
|
||||
kind: 'code-comment',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
lineStart: 12,
|
||||
lineEnd: 12,
|
||||
note: 'Use a clearer name',
|
||||
quote: 'const value = 1',
|
||||
},
|
||||
])
|
||||
|
||||
expect(prompt).toContain('Notes for attached workspace files:')
|
||||
expect(prompt).toContain('- src/App.tsx:L12')
|
||||
expect(prompt).toContain('Comment: Use a clearer name')
|
||||
expect(prompt).toContain('Selected code: const value = 1')
|
||||
expect(prompt).not.toContain('Use the Read tool')
|
||||
expect(prompt).not.toContain('Path: /repo/src/App.tsx')
|
||||
})
|
||||
|
||||
it('does not add prompt text for plain file attachments', () => {
|
||||
const prompt = formatWorkspaceReferencePrompt([
|
||||
{
|
||||
id: 'ref-1',
|
||||
kind: 'file',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
},
|
||||
])
|
||||
|
||||
expect(prompt).toBe('')
|
||||
})
|
||||
})
|
||||
118
desktop/src/stores/workspaceChatContextStore.ts
Normal file
118
desktop/src/stores/workspaceChatContextStore.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type WorkspaceChatReferenceKind = 'file' | 'code-comment'
|
||||
|
||||
export type WorkspaceChatReference = {
|
||||
id: string
|
||||
kind: WorkspaceChatReferenceKind
|
||||
path: string
|
||||
absolutePath?: string
|
||||
name: string
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
note?: string
|
||||
quote?: string
|
||||
}
|
||||
|
||||
type WorkspaceChatContextStore = {
|
||||
referencesBySession: Record<string, WorkspaceChatReference[] | undefined>
|
||||
addReference: (
|
||||
sessionId: string,
|
||||
reference: Omit<WorkspaceChatReference, 'id'> & { id?: string },
|
||||
) => void
|
||||
removeReference: (sessionId: string, referenceId: string) => void
|
||||
clearReferences: (sessionId: string) => void
|
||||
clearSession: (sessionId: string) => void
|
||||
}
|
||||
|
||||
function makeReferenceId(reference: Omit<WorkspaceChatReference, 'id'>) {
|
||||
const linePart = reference.lineStart
|
||||
? `${reference.lineStart}-${reference.lineEnd ?? reference.lineStart}`
|
||||
: 'file'
|
||||
const notePart = reference.note ? reference.note.slice(0, 48) : ''
|
||||
return `${reference.kind}:${reference.path}:${linePart}:${notePart}`
|
||||
}
|
||||
|
||||
function getReferenceDedupKey(reference: WorkspaceChatReference) {
|
||||
if (reference.kind === 'file') return `${reference.kind}:${reference.path}`
|
||||
return `${reference.kind}:${reference.path}:${reference.lineStart ?? ''}:${reference.lineEnd ?? ''}:${reference.note ?? ''}`
|
||||
}
|
||||
|
||||
export function formatWorkspaceReferenceLocation(reference: WorkspaceChatReference) {
|
||||
if (!reference.lineStart) return reference.path
|
||||
const lineEnd = reference.lineEnd && reference.lineEnd !== reference.lineStart
|
||||
? `-L${reference.lineEnd}`
|
||||
: ''
|
||||
return `${reference.path}:L${reference.lineStart}${lineEnd}`
|
||||
}
|
||||
|
||||
export function formatWorkspaceReferencePrompt(references: WorkspaceChatReference[]) {
|
||||
const referencesWithContext = references.filter((reference) =>
|
||||
reference.kind === 'code-comment' ||
|
||||
!!reference.lineStart ||
|
||||
!!reference.note?.trim() ||
|
||||
!!reference.quote?.trim(),
|
||||
)
|
||||
if (referencesWithContext.length === 0) return ''
|
||||
|
||||
const lines = [
|
||||
'Notes for attached workspace files:',
|
||||
...referencesWithContext.map((reference) => {
|
||||
const location = formatWorkspaceReferenceLocation(reference)
|
||||
const parts = [`- ${location}`]
|
||||
if (reference.note?.trim()) parts.push(`Comment: ${reference.note.trim()}`)
|
||||
if (reference.quote?.trim()) parts.push(`Selected code: ${reference.quote.trim()}`)
|
||||
return parts.join('\n ')
|
||||
}),
|
||||
]
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export const useWorkspaceChatContextStore = create<WorkspaceChatContextStore>((set) => ({
|
||||
referencesBySession: {},
|
||||
|
||||
addReference: (sessionId, input) =>
|
||||
set((state) => {
|
||||
const reference: WorkspaceChatReference = {
|
||||
...input,
|
||||
id: input.id ?? makeReferenceId(input),
|
||||
}
|
||||
const existing = state.referencesBySession[sessionId] ?? []
|
||||
const nextKey = getReferenceDedupKey(reference)
|
||||
const withoutDuplicate = existing.filter((item) => getReferenceDedupKey(item) !== nextKey)
|
||||
|
||||
return {
|
||||
referencesBySession: {
|
||||
...state.referencesBySession,
|
||||
[sessionId]: [...withoutDuplicate, reference],
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
removeReference: (sessionId, referenceId) =>
|
||||
set((state) => {
|
||||
const existing = state.referencesBySession[sessionId] ?? []
|
||||
return {
|
||||
referencesBySession: {
|
||||
...state.referencesBySession,
|
||||
[sessionId]: existing.filter((reference) => reference.id !== referenceId),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
clearReferences: (sessionId) =>
|
||||
set((state) => ({
|
||||
referencesBySession: {
|
||||
...state.referencesBySession,
|
||||
[sessionId]: [],
|
||||
},
|
||||
})),
|
||||
|
||||
clearSession: (sessionId) =>
|
||||
set((state) => {
|
||||
if (!(sessionId in state.referencesBySession)) return state
|
||||
const { [sessionId]: _removed, ...rest } = state.referencesBySession
|
||||
return { referencesBySession: rest }
|
||||
}),
|
||||
}))
|
||||
636
desktop/src/stores/workspacePanelStore.test.ts
Normal file
636
desktop/src/stores/workspacePanelStore.test.ts
Normal file
@ -0,0 +1,636 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type HoistedVi = typeof vi & {
|
||||
hoisted?: <T>(factory: () => T) => T
|
||||
}
|
||||
|
||||
if (typeof (vi as HoistedVi).hoisted !== 'function') {
|
||||
;(vi as HoistedVi).hoisted = <T>(factory: () => T) => factory()
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getWorkspaceStatusMock: vi.fn(),
|
||||
getWorkspaceTreeMock: vi.fn(),
|
||||
getWorkspaceFileMock: vi.fn(),
|
||||
getWorkspaceDiffMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/sessions', () => ({
|
||||
sessionsApi: {
|
||||
getWorkspaceStatus: mocks.getWorkspaceStatusMock,
|
||||
getWorkspaceTree: mocks.getWorkspaceTreeMock,
|
||||
getWorkspaceFile: mocks.getWorkspaceFileMock,
|
||||
getWorkspaceDiff: mocks.getWorkspaceDiffMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import {
|
||||
WORKSPACE_PANEL_DEFAULT_WIDTH,
|
||||
WORKSPACE_PANEL_MAX_WIDTH,
|
||||
WORKSPACE_PANEL_MIN_WIDTH,
|
||||
getWorkspacePreviewTabId,
|
||||
useWorkspacePanelStore,
|
||||
} from './workspacePanelStore'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('workspacePanelStore', () => {
|
||||
const initialState = useWorkspacePanelStore.getInitialState()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useWorkspacePanelStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useWorkspacePanelStore.setState(initialState, true)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses hoisted session api mocks', () => {
|
||||
expect(sessionsApi.getWorkspaceStatus).toBe(mocks.getWorkspaceStatusMock)
|
||||
expect(sessionsApi.getWorkspaceTree).toBe(mocks.getWorkspaceTreeMock)
|
||||
expect(sessionsApi.getWorkspaceFile).toBe(mocks.getWorkspaceFileMock)
|
||||
expect(sessionsApi.getWorkspaceDiff).toBe(mocks.getWorkspaceDiffMock)
|
||||
})
|
||||
|
||||
it('keeps panel open state and active view isolated per session', () => {
|
||||
const store = useWorkspacePanelStore.getState()
|
||||
|
||||
expect(store.isPanelOpen('session-a')).toBe(false)
|
||||
expect(store.getActiveView('session-a')).toBe('changed')
|
||||
expect(store.width).toBe(WORKSPACE_PANEL_DEFAULT_WIDTH)
|
||||
|
||||
store.openPanel('session-a')
|
||||
store.setActiveView('session-a', 'all')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(true)
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-a')).toBe('all')
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('session-b')).toBe(false)
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-b')).toBe('changed')
|
||||
|
||||
store.togglePanel('session-b')
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('session-b')).toBe(true)
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(true)
|
||||
|
||||
store.closePanel('session-a')
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('session-a')).toBe(false)
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-a')).toBe('all')
|
||||
|
||||
store.setWidth(120)
|
||||
expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_MIN_WIDTH)
|
||||
store.setWidth(1200)
|
||||
expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_MAX_WIDTH)
|
||||
})
|
||||
|
||||
it('loads workspace status successfully', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [
|
||||
{
|
||||
path: 'src/a.ts',
|
||||
status: 'modified',
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await useWorkspacePanelStore.getState().loadStatus('session-1')
|
||||
|
||||
expect(mocks.getWorkspaceStatusMock).toHaveBeenCalledWith('session-1')
|
||||
expect(useWorkspacePanelStore.getState().statusBySession['session-1']).toMatchObject({
|
||||
branch: 'main',
|
||||
changedFiles: [{ path: 'src/a.ts' }],
|
||||
})
|
||||
expect(useWorkspacePanelStore.getState().loading.statusBySession['session-1']).toBe(false)
|
||||
expect(useWorkspacePanelStore.getState().errors.statusBySession['session-1']).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults an empty changed-files status to the all-files view', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
})
|
||||
|
||||
useWorkspacePanelStore.getState().openPanel('session-empty-changes')
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-changes')).toBe('changed')
|
||||
|
||||
await useWorkspacePanelStore.getState().loadStatus('session-empty-changes')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().statusBySession['session-empty-changes']?.changedFiles).toEqual([])
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-changes')).toBe('all')
|
||||
})
|
||||
|
||||
it('keeps the changed-files view when status contains changes', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [
|
||||
{
|
||||
path: 'src/app.ts',
|
||||
status: 'modified',
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
useWorkspacePanelStore.getState().openPanel('session-has-changes')
|
||||
await useWorkspacePanelStore.getState().loadStatus('session-has-changes')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-has-changes')).toBe('changed')
|
||||
})
|
||||
|
||||
it('does not override an explicit changed-files selection when status is empty', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
})
|
||||
|
||||
useWorkspacePanelStore.getState().openPanel('session-explicit-changed')
|
||||
useWorkspacePanelStore.getState().setActiveView('session-explicit-changed', 'changed')
|
||||
await useWorkspacePanelStore.getState().loadStatus('session-explicit-changed')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().getActiveView('session-explicit-changed')).toBe('changed')
|
||||
})
|
||||
|
||||
it('captures workspace status request errors', async () => {
|
||||
mocks.getWorkspaceStatusMock.mockRejectedValue(new Error('status failed'))
|
||||
|
||||
await useWorkspacePanelStore.getState().loadStatus('session-err')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().statusBySession['session-err']).toBeUndefined()
|
||||
expect(useWorkspacePanelStore.getState().loading.statusBySession['session-err']).toBe(false)
|
||||
expect(useWorkspacePanelStore.getState().errors.statusBySession['session-err']).toBe('status failed')
|
||||
})
|
||||
|
||||
it('lazily loads tree nodes when expanding and reuses cached results', async () => {
|
||||
mocks.getWorkspaceTreeMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: '',
|
||||
entries: [
|
||||
{ name: 'src', path: 'src', isDirectory: true },
|
||||
{ name: 'README.md', path: 'README.md', isDirectory: false },
|
||||
],
|
||||
})
|
||||
|
||||
await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '')
|
||||
|
||||
expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '')
|
||||
expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual([''])
|
||||
expect(useWorkspacePanelStore.getState().treeBySessionPath['session-tree']?.['']).toMatchObject({
|
||||
entries: [{ path: 'src' }, { path: 'README.md' }],
|
||||
})
|
||||
|
||||
await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '')
|
||||
expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual([])
|
||||
|
||||
await useWorkspacePanelStore.getState().toggleTreeNode('session-tree', '')
|
||||
expect(mocks.getWorkspaceTreeMock).toHaveBeenCalledTimes(1)
|
||||
expect(useWorkspacePanelStore.getState().expandedPathsBySession['session-tree']).toEqual([''])
|
||||
})
|
||||
|
||||
it('ignores stale tree responses for the same session path', async () => {
|
||||
const first = deferred<{ state: 'ok'; path: string; entries: Array<{ name: string; path: string; isDirectory: boolean }> }>()
|
||||
const second = deferred<{ state: 'ok'; path: string; entries: Array<{ name: string; path: string; isDirectory: boolean }> }>()
|
||||
|
||||
mocks.getWorkspaceTreeMock
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
|
||||
const firstLoad = useWorkspacePanelStore.getState().loadTree('session-tree-race', 'src')
|
||||
const secondLoad = useWorkspacePanelStore.getState().loadTree('session-tree-race', 'src')
|
||||
|
||||
second.resolve({
|
||||
state: 'ok',
|
||||
path: 'src',
|
||||
entries: [{ name: 'new.ts', path: 'src/new.ts', isDirectory: false }],
|
||||
})
|
||||
await secondLoad
|
||||
|
||||
first.resolve({
|
||||
state: 'ok',
|
||||
path: 'src',
|
||||
entries: [{ name: 'old.ts', path: 'src/old.ts', isDirectory: false }],
|
||||
})
|
||||
await firstLoad
|
||||
|
||||
expect(useWorkspacePanelStore.getState().treeBySessionPath['session-tree-race']?.src?.entries).toEqual([
|
||||
{ name: 'new.ts', path: 'src/new.ts', isDirectory: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('opens preview tabs, supports multiple kinds, and reuses duplicates without persistence', async () => {
|
||||
const storage = typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage
|
||||
const setItemSpy = storage ? vi.spyOn(storage, 'setItem') : null
|
||||
|
||||
mocks.getWorkspaceFileMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
content: 'export const a = 1',
|
||||
language: 'typescript',
|
||||
size: 18,
|
||||
})
|
||||
mocks.getWorkspaceDiffMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
diff: '@@ -1 +1 @@',
|
||||
})
|
||||
|
||||
useWorkspacePanelStore.getState().openPanel('session-preview')
|
||||
await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'file')
|
||||
await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'diff')
|
||||
await useWorkspacePanelStore.getState().openPreview('session-preview', 'src/a.ts', 'file')
|
||||
|
||||
expect(mocks.getWorkspaceFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.getWorkspaceDiffMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const tabs = useWorkspacePanelStore.getState().previewTabsBySession['session-preview']
|
||||
expect(tabs).toBeDefined()
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs![0]).toMatchObject({
|
||||
id: 'file:src/a.ts',
|
||||
kind: 'file',
|
||||
path: 'src/a.ts',
|
||||
content: 'export const a = 1',
|
||||
language: 'typescript',
|
||||
})
|
||||
expect(tabs![1]).toMatchObject({
|
||||
id: 'diff:src/a.ts',
|
||||
kind: 'diff',
|
||||
path: 'src/a.ts',
|
||||
diff: '@@ -1 +1 @@',
|
||||
})
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview']).toBe('file:src/a.ts')
|
||||
if (setItemSpy) {
|
||||
expect(setItemSpy).not.toHaveBeenCalled()
|
||||
} else {
|
||||
expect(storage).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('closes exact tab id and preserves sibling preview for the same path', async () => {
|
||||
mocks.getWorkspaceFileMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
content: 'export const a = 1',
|
||||
language: 'typescript',
|
||||
size: 18,
|
||||
})
|
||||
mocks.getWorkspaceDiffMock.mockResolvedValue({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
diff: '@@ -1 +1 @@',
|
||||
})
|
||||
|
||||
await useWorkspacePanelStore.getState().openPreview('session-close-path', 'src/a.ts', 'file')
|
||||
await useWorkspacePanelStore.getState().openPreview('session-close-path', 'src/a.ts', 'diff')
|
||||
|
||||
useWorkspacePanelStore.getState().closePreview('session-close-path', 'diff:src/a.ts')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-close-path']).toEqual([
|
||||
expect.objectContaining({ id: 'file:src/a.ts' }),
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close-path']).toBe('file:src/a.ts')
|
||||
})
|
||||
|
||||
it('chooses the next sensible active preview when closing the current tab', async () => {
|
||||
mocks.getWorkspaceFileMock
|
||||
.mockResolvedValueOnce({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
content: 'a',
|
||||
language: 'typescript',
|
||||
size: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
state: 'ok',
|
||||
path: 'src/b.ts',
|
||||
content: 'b',
|
||||
language: 'typescript',
|
||||
size: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
state: 'ok',
|
||||
path: 'src/c.ts',
|
||||
content: 'c',
|
||||
language: 'typescript',
|
||||
size: 1,
|
||||
})
|
||||
|
||||
await useWorkspacePanelStore.getState().openPreview('session-close', 'src/a.ts', 'file')
|
||||
await useWorkspacePanelStore.getState().openPreview('session-close', 'src/b.ts', 'file')
|
||||
await useWorkspacePanelStore.getState().openPreview('session-close', 'src/c.ts', 'file')
|
||||
|
||||
useWorkspacePanelStore.getState().closePreview('session-close', 'file:src/b.ts')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-close']).toMatchObject([
|
||||
{ id: 'file:src/a.ts' },
|
||||
{ id: 'file:src/c.ts' },
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close']).toBe('file:src/c.ts')
|
||||
|
||||
useWorkspacePanelStore.getState().closePreview('session-close', 'file:src/c.ts')
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-close']).toBe('file:src/a.ts')
|
||||
})
|
||||
|
||||
it('closes preview tabs by context-menu scope', () => {
|
||||
useWorkspacePanelStore.setState((state) => ({
|
||||
...state,
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-preview-scope': [
|
||||
{ id: 'file:a.ts', path: 'a.ts', kind: 'file', title: 'a.ts', state: 'ok', language: 'typescript', size: 1 },
|
||||
{ id: 'file:b.ts', path: 'b.ts', kind: 'file', title: 'b.ts', state: 'ok', language: 'typescript', size: 1 },
|
||||
{ id: 'file:c.ts', path: 'c.ts', kind: 'file', title: 'c.ts', state: 'ok', language: 'typescript', size: 1 },
|
||||
{ id: 'file:d.ts', path: 'd.ts', kind: 'file', title: 'd.ts', state: 'ok', language: 'typescript', size: 1 },
|
||||
],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-preview-scope': 'file:d.ts',
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
previewByTabId: {
|
||||
...state.loading.previewByTabId,
|
||||
'session-preview-scope::file:c.ts': true,
|
||||
'session-preview-scope::file:d.ts': true,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
previewByTabId: {
|
||||
...state.errors.previewByTabId,
|
||||
'session-preview-scope::file:c.ts': 'loading',
|
||||
'session-preview-scope::file:d.ts': 'loading',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
useWorkspacePanelStore.getState().closePreviewTabs('session-preview-scope', 'file:b.ts', 'right')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-scope']).toMatchObject([
|
||||
{ id: 'file:a.ts' },
|
||||
{ id: 'file:b.ts' },
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-scope']).toBe('file:b.ts')
|
||||
expect(useWorkspacePanelStore.getState().loading.previewByTabId['session-preview-scope::file:c.ts']).toBeUndefined()
|
||||
expect(useWorkspacePanelStore.getState().errors.previewByTabId['session-preview-scope::file:d.ts']).toBeUndefined()
|
||||
|
||||
useWorkspacePanelStore.getState().closePreviewTabs('session-preview-scope', 'file:b.ts', 'others')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-scope']).toMatchObject([
|
||||
{ id: 'file:b.ts' },
|
||||
])
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-scope']).toBe('file:b.ts')
|
||||
|
||||
useWorkspacePanelStore.getState().closePreviewTabs('session-preview-scope', 'file:b.ts', 'all')
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-scope']).toBeUndefined()
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-scope']).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores stale status responses for the same session', async () => {
|
||||
const first = deferred<{
|
||||
state: 'ok'
|
||||
workDir: string
|
||||
repoName: string | null
|
||||
branch: string | null
|
||||
isGitRepo: boolean
|
||||
changedFiles: []
|
||||
error?: string
|
||||
}>()
|
||||
const second = deferred<{
|
||||
state: 'ok'
|
||||
workDir: string
|
||||
repoName: string | null
|
||||
branch: string | null
|
||||
isGitRepo: boolean
|
||||
changedFiles: []
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
mocks.getWorkspaceStatusMock
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
|
||||
const firstLoad = useWorkspacePanelStore.getState().loadStatus('session-race')
|
||||
const secondLoad = useWorkspacePanelStore.getState().loadStatus('session-race')
|
||||
|
||||
second.resolve({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'new',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
})
|
||||
await secondLoad
|
||||
|
||||
first.resolve({
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'old',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
})
|
||||
await firstLoad
|
||||
|
||||
expect(useWorkspacePanelStore.getState().statusBySession['session-race']?.branch).toBe('new')
|
||||
})
|
||||
|
||||
it('ignores stale preview responses after close and reopen', async () => {
|
||||
const first = deferred<{ state: 'ok'; path: string; content: string; language: string; size: number }>()
|
||||
const second = deferred<{ state: 'ok'; path: string; content: string; language: string; size: number }>()
|
||||
|
||||
mocks.getWorkspaceFileMock
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
|
||||
const firstOpen = useWorkspacePanelStore.getState().openPreview('session-preview-race', 'src/a.ts', 'file')
|
||||
useWorkspacePanelStore.getState().closePreview('session-preview-race', 'file:src/a.ts')
|
||||
const secondOpen = useWorkspacePanelStore.getState().openPreview('session-preview-race', 'src/a.ts', 'file')
|
||||
|
||||
second.resolve({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
content: 'new',
|
||||
language: 'typescript',
|
||||
size: 3,
|
||||
})
|
||||
await secondOpen
|
||||
|
||||
first.resolve({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
content: 'old',
|
||||
language: 'typescript',
|
||||
size: 3,
|
||||
})
|
||||
await firstOpen
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-race']).toEqual([
|
||||
expect.objectContaining({ id: 'file:src/a.ts', content: 'new' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('does not recreate loading or error state when a loading preview is closed before completion', async () => {
|
||||
const pending = deferred<{ state: 'ok'; path: string; diff: string }>()
|
||||
const tabId = getWorkspacePreviewTabId('src/a.ts', 'diff')
|
||||
const previewKey = `session-preview-close::${tabId}`
|
||||
|
||||
mocks.getWorkspaceDiffMock.mockReturnValueOnce(pending.promise)
|
||||
|
||||
const openPromise = useWorkspacePanelStore.getState().openPreview('session-preview-close', 'src/a.ts', 'diff')
|
||||
useWorkspacePanelStore.getState().closePreview('session-preview-close', tabId)
|
||||
|
||||
pending.resolve({
|
||||
state: 'ok',
|
||||
path: 'src/a.ts',
|
||||
diff: '@@ -1 +1 @@',
|
||||
})
|
||||
await openPromise
|
||||
|
||||
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-close']).toBeUndefined()
|
||||
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-close']).toBeNull()
|
||||
expect(useWorkspacePanelStore.getState().loading.previewByTabId[previewKey]).toBeUndefined()
|
||||
expect(useWorkspacePanelStore.getState().errors.previewByTabId[previewKey]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears session UI and cached data for clearSession and resetSessionUi', () => {
|
||||
useWorkspacePanelStore.setState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
'session-clear': { isOpen: true, activeView: 'all' },
|
||||
'session-reset': { isOpen: true, activeView: 'all' },
|
||||
},
|
||||
statusBySession: {
|
||||
'session-clear': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
'session-reset': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
expandedPathsBySession: {
|
||||
'session-clear': ['src'],
|
||||
'session-reset': ['src'],
|
||||
},
|
||||
treeBySessionPath: {
|
||||
'session-clear': {
|
||||
src: { state: 'ok', path: 'src', entries: [] },
|
||||
},
|
||||
'session-reset': {
|
||||
src: { state: 'ok', path: 'src', entries: [] },
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
'session-clear': [
|
||||
{ id: 'file:src/a.ts', path: 'src/a.ts', kind: 'file', title: 'a.ts' },
|
||||
],
|
||||
'session-reset': [
|
||||
{ id: 'file:src/b.ts', path: 'src/b.ts', kind: 'file', title: 'b.ts' },
|
||||
],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
'session-clear': 'file:src/a.ts',
|
||||
'session-reset': 'file:src/b.ts',
|
||||
},
|
||||
loading: {
|
||||
statusBySession: {
|
||||
'session-clear': true,
|
||||
'session-reset': true,
|
||||
},
|
||||
treeBySessionPath: {
|
||||
'session-clear::src': true,
|
||||
'session-reset::src': true,
|
||||
},
|
||||
previewByTabId: {
|
||||
'session-clear::file:src/a.ts': true,
|
||||
'session-reset::file:src/b.ts': true,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
statusBySession: {
|
||||
'session-clear': 'bad',
|
||||
'session-reset': 'bad',
|
||||
},
|
||||
treeBySessionPath: {
|
||||
'session-clear::src': 'bad',
|
||||
'session-reset::src': 'bad',
|
||||
},
|
||||
previewByTabId: {
|
||||
'session-clear::file:src/a.ts': 'bad',
|
||||
'session-reset::file:src/b.ts': 'bad',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
useWorkspacePanelStore.getState().clearSession('session-clear')
|
||||
useWorkspacePanelStore.getState().resetSessionUi('session-reset')
|
||||
|
||||
const state = useWorkspacePanelStore.getState()
|
||||
expect(state.panelBySession['session-clear']).toBeUndefined()
|
||||
expect(state.panelBySession['session-reset']).toBeUndefined()
|
||||
expect(state.statusBySession['session-clear']).toBeUndefined()
|
||||
expect(state.statusBySession['session-reset']).toBeUndefined()
|
||||
expect(state.expandedPathsBySession['session-clear']).toBeUndefined()
|
||||
expect(state.expandedPathsBySession['session-reset']).toBeUndefined()
|
||||
expect(state.treeBySessionPath['session-clear']).toBeUndefined()
|
||||
expect(state.treeBySessionPath['session-reset']).toBeUndefined()
|
||||
expect(state.previewTabsBySession['session-clear']).toBeUndefined()
|
||||
expect(state.previewTabsBySession['session-reset']).toBeUndefined()
|
||||
expect(state.activePreviewTabIdBySession['session-clear']).toBeUndefined()
|
||||
expect(state.activePreviewTabIdBySession['session-reset']).toBeUndefined()
|
||||
expect(state.loading.statusBySession['session-clear']).toBeUndefined()
|
||||
expect(state.loading.statusBySession['session-reset']).toBeUndefined()
|
||||
expect(state.loading.treeBySessionPath['session-clear::src']).toBeUndefined()
|
||||
expect(state.loading.treeBySessionPath['session-reset::src']).toBeUndefined()
|
||||
expect(state.loading.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined()
|
||||
expect(state.loading.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined()
|
||||
expect(state.errors.statusBySession['session-clear']).toBeUndefined()
|
||||
expect(state.errors.statusBySession['session-reset']).toBeUndefined()
|
||||
expect(state.errors.treeBySessionPath['session-clear::src']).toBeUndefined()
|
||||
expect(state.errors.treeBySessionPath['session-reset::src']).toBeUndefined()
|
||||
expect(state.errors.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined()
|
||||
expect(state.errors.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
716
desktop/src/stores/workspacePanelStore.ts
Normal file
716
desktop/src/stores/workspacePanelStore.ts
Normal file
@ -0,0 +1,716 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
sessionsApi,
|
||||
type WorkspaceDiffResult,
|
||||
type WorkspaceReadFileResult,
|
||||
type WorkspaceStatusResult,
|
||||
type WorkspaceTreeResult,
|
||||
} from '../api/sessions'
|
||||
|
||||
export const WORKSPACE_PANEL_DEFAULT_WIDTH = 860
|
||||
export const WORKSPACE_PANEL_MIN_WIDTH = 640
|
||||
export const WORKSPACE_PANEL_MAX_WIDTH = 1120
|
||||
|
||||
export type WorkspacePanelView = 'changed' | 'all'
|
||||
export type WorkspacePreviewKind = 'file' | 'diff'
|
||||
export type WorkspacePreviewCloseScope = 'current' | 'others' | 'left' | 'right' | 'all'
|
||||
export type WorkspacePreviewState =
|
||||
| 'loading'
|
||||
| WorkspaceReadFileResult['state']
|
||||
| WorkspaceDiffResult['state']
|
||||
|
||||
export type WorkspacePreviewTab = {
|
||||
id: string
|
||||
path: string
|
||||
kind: WorkspacePreviewKind
|
||||
title: string
|
||||
language?: string
|
||||
content?: string
|
||||
dataUrl?: string
|
||||
mimeType?: string
|
||||
previewType?: 'text' | 'image'
|
||||
diff?: string
|
||||
state?: WorkspacePreviewState
|
||||
error?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export type WorkspacePanelSessionState = {
|
||||
isOpen: boolean
|
||||
activeView: WorkspacePanelView
|
||||
hasUserSelectedView?: boolean
|
||||
}
|
||||
|
||||
type WorkspacePanelLoadingState = {
|
||||
statusBySession: Record<string, boolean | undefined>
|
||||
treeBySessionPath: Record<string, boolean | undefined>
|
||||
previewByTabId: Record<string, boolean | undefined>
|
||||
}
|
||||
|
||||
type WorkspacePanelErrorState = {
|
||||
statusBySession: Record<string, string | null | undefined>
|
||||
treeBySessionPath: Record<string, string | null | undefined>
|
||||
previewByTabId: Record<string, string | null | undefined>
|
||||
}
|
||||
|
||||
type WorkspacePanelStore = {
|
||||
panelBySession: Record<string, WorkspacePanelSessionState | undefined>
|
||||
width: number
|
||||
statusBySession: Record<string, WorkspaceStatusResult | undefined>
|
||||
expandedPathsBySession: Record<string, string[] | undefined>
|
||||
treeBySessionPath: Record<string, Record<string, WorkspaceTreeResult | undefined> | undefined>
|
||||
previewTabsBySession: Record<string, WorkspacePreviewTab[] | undefined>
|
||||
activePreviewTabIdBySession: Record<string, string | null | undefined>
|
||||
loading: WorkspacePanelLoadingState
|
||||
errors: WorkspacePanelErrorState
|
||||
|
||||
isPanelOpen: (sessionId: string) => boolean
|
||||
getActiveView: (sessionId: string) => WorkspacePanelView
|
||||
openPanel: (sessionId: string) => void
|
||||
closePanel: (sessionId: string) => void
|
||||
togglePanel: (sessionId: string) => void
|
||||
setWidth: (width: number) => void
|
||||
setActiveView: (sessionId: string, view: WorkspacePanelView) => void
|
||||
loadStatus: (sessionId: string) => Promise<void>
|
||||
loadTree: (sessionId: string, path?: string) => Promise<void>
|
||||
toggleTreeNode: (sessionId: string, path: string) => Promise<void>
|
||||
openPreview: (sessionId: string, path: string, kind: WorkspacePreviewKind) => Promise<void>
|
||||
closePreview: (sessionId: string, tabId: string) => void
|
||||
closePreviewTabs: (sessionId: string, tabId: string, scope: WorkspacePreviewCloseScope) => void
|
||||
clearSession: (sessionId: string) => void
|
||||
resetSessionUi: (sessionId: string) => void
|
||||
}
|
||||
|
||||
const DEFAULT_PANEL_STATE: WorkspacePanelSessionState = {
|
||||
isOpen: false,
|
||||
activeView: 'changed',
|
||||
}
|
||||
|
||||
const statusRequestIds = new Map<string, number>()
|
||||
const treeRequestIds = new Map<string, number>()
|
||||
const previewRequestIds = new Map<string, number>()
|
||||
|
||||
function nextRequestId(store: Map<string, number>, key: string) {
|
||||
const requestId = (store.get(key) ?? 0) + 1
|
||||
store.set(key, requestId)
|
||||
return requestId
|
||||
}
|
||||
|
||||
function invalidateRequest(store: Map<string, number>, key: string) {
|
||||
store.set(key, (store.get(key) ?? 0) + 1)
|
||||
}
|
||||
|
||||
function isLatestRequest(store: Map<string, number>, key: string, requestId: number) {
|
||||
return store.get(key) === requestId
|
||||
}
|
||||
|
||||
export function clampWorkspacePanelWidth(width: number) {
|
||||
if (!Number.isFinite(width)) return WORKSPACE_PANEL_DEFAULT_WIDTH
|
||||
const rounded = Math.round(width)
|
||||
return Math.min(WORKSPACE_PANEL_MAX_WIDTH, Math.max(WORKSPACE_PANEL_MIN_WIDTH, rounded))
|
||||
}
|
||||
|
||||
function getSessionPanelState(
|
||||
panelBySession: Record<string, WorkspacePanelSessionState | undefined>,
|
||||
sessionId: string,
|
||||
) {
|
||||
return panelBySession[sessionId] ?? DEFAULT_PANEL_STATE
|
||||
}
|
||||
|
||||
function makeTreeKey(sessionId: string, path: string) {
|
||||
return `${sessionId}::${path}`
|
||||
}
|
||||
|
||||
export function getWorkspacePreviewTabId(path: string, kind: WorkspacePreviewKind) {
|
||||
return `${kind}:${path}`
|
||||
}
|
||||
|
||||
function makePreviewKey(sessionId: string, tabId: string) {
|
||||
return `${sessionId}::${tabId}`
|
||||
}
|
||||
|
||||
function getPathTitle(path: string) {
|
||||
if (!path) return 'Workspace'
|
||||
const segments = path.split('/').filter(Boolean)
|
||||
return segments[segments.length - 1] ?? path
|
||||
}
|
||||
|
||||
function stripSessionKeys<T>(record: Record<string, T>, sessionId: string) {
|
||||
const prefix = `${sessionId}::`
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).filter(([key]) => !key.startsWith(prefix)),
|
||||
) as Record<string, T>
|
||||
}
|
||||
|
||||
function removeRecordKey<T>(record: Record<string, T>, key: string) {
|
||||
if (!(key in record)) return record
|
||||
const { [key]: _removed, ...rest } = record
|
||||
return rest
|
||||
}
|
||||
|
||||
function removeRecordKeys<T>(record: Record<string, T>, keys: string[]) {
|
||||
let next = record
|
||||
for (const key of keys) {
|
||||
next = removeRecordKey(next, key)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function invalidateSessionScopedRequests(store: Map<string, number>, sessionId: string) {
|
||||
const prefix = `${sessionId}::`
|
||||
for (const key of store.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
invalidateRequest(store, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function upsertPreviewTab(
|
||||
tabs: WorkspacePreviewTab[],
|
||||
tabId: string,
|
||||
update: WorkspacePreviewTab | ((current: WorkspacePreviewTab) => WorkspacePreviewTab),
|
||||
) {
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId)
|
||||
if (index < 0) return tabs
|
||||
|
||||
const current = tabs[index]!
|
||||
const next = typeof update === 'function' ? update(current) : update
|
||||
const nextTabs = [...tabs]
|
||||
nextTabs[index] = next
|
||||
return nextTabs
|
||||
}
|
||||
|
||||
export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) => ({
|
||||
panelBySession: {},
|
||||
width: WORKSPACE_PANEL_DEFAULT_WIDTH,
|
||||
statusBySession: {},
|
||||
expandedPathsBySession: {},
|
||||
treeBySessionPath: {},
|
||||
previewTabsBySession: {},
|
||||
activePreviewTabIdBySession: {},
|
||||
loading: {
|
||||
statusBySession: {},
|
||||
treeBySessionPath: {},
|
||||
previewByTabId: {},
|
||||
},
|
||||
errors: {
|
||||
statusBySession: {},
|
||||
treeBySessionPath: {},
|
||||
previewByTabId: {},
|
||||
},
|
||||
|
||||
isPanelOpen: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).isOpen,
|
||||
getActiveView: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).activeView,
|
||||
|
||||
openPanel: (sessionId) =>
|
||||
set((state) => ({
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
[sessionId]: {
|
||||
...getSessionPanelState(state.panelBySession, sessionId),
|
||||
isOpen: true,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
closePanel: (sessionId) =>
|
||||
set((state) => ({
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
[sessionId]: {
|
||||
...getSessionPanelState(state.panelBySession, sessionId),
|
||||
isOpen: false,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
togglePanel: (sessionId) =>
|
||||
set((state) => {
|
||||
const panel = getSessionPanelState(state.panelBySession, sessionId)
|
||||
return {
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
[sessionId]: {
|
||||
...panel,
|
||||
isOpen: !panel.isOpen,
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
setWidth: (width) => set({ width: clampWorkspacePanelWidth(width) }),
|
||||
|
||||
setActiveView: (sessionId, view) =>
|
||||
set((state) => ({
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
[sessionId]: {
|
||||
...getSessionPanelState(state.panelBySession, sessionId),
|
||||
activeView: view,
|
||||
hasUserSelectedView: true,
|
||||
},
|
||||
},
|
||||
})),
|
||||
|
||||
loadStatus: async (sessionId) => {
|
||||
const requestId = nextRequestId(statusRequestIds, sessionId)
|
||||
|
||||
set((state) => ({
|
||||
loading: {
|
||||
...state.loading,
|
||||
statusBySession: {
|
||||
...state.loading.statusBySession,
|
||||
[sessionId]: true,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
statusBySession: {
|
||||
...state.errors.statusBySession,
|
||||
[sessionId]: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await sessionsApi.getWorkspaceStatus(sessionId)
|
||||
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
|
||||
|
||||
set((state) => {
|
||||
const panel = getSessionPanelState(state.panelBySession, sessionId)
|
||||
const shouldDefaultToAllFiles =
|
||||
!panel.hasUserSelectedView
|
||||
&& panel.activeView === 'changed'
|
||||
&& result.state === 'ok'
|
||||
&& result.changedFiles.length === 0
|
||||
|
||||
return {
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
[sessionId]: {
|
||||
...panel,
|
||||
activeView: shouldDefaultToAllFiles ? 'all' : panel.activeView,
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
[sessionId]: result,
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
statusBySession: {
|
||||
...state.loading.statusBySession,
|
||||
[sessionId]: false,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
statusBySession: {
|
||||
...state.errors.statusBySession,
|
||||
[sessionId]: result.error ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
|
||||
|
||||
set((state) => ({
|
||||
loading: {
|
||||
...state.loading,
|
||||
statusBySession: {
|
||||
...state.loading.statusBySession,
|
||||
[sessionId]: false,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
statusBySession: {
|
||||
...state.errors.statusBySession,
|
||||
[sessionId]: error instanceof Error ? error.message : 'Failed to load workspace status',
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
loadTree: async (sessionId, path = '') => {
|
||||
const treeKey = makeTreeKey(sessionId, path)
|
||||
const requestId = nextRequestId(treeRequestIds, treeKey)
|
||||
|
||||
set((state) => ({
|
||||
loading: {
|
||||
...state.loading,
|
||||
treeBySessionPath: {
|
||||
...state.loading.treeBySessionPath,
|
||||
[treeKey]: true,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
treeBySessionPath: {
|
||||
...state.errors.treeBySessionPath,
|
||||
[treeKey]: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await sessionsApi.getWorkspaceTree(sessionId, path)
|
||||
if (!isLatestRequest(treeRequestIds, treeKey, requestId)) return
|
||||
|
||||
set((state) => ({
|
||||
treeBySessionPath: {
|
||||
...state.treeBySessionPath,
|
||||
[sessionId]: {
|
||||
...state.treeBySessionPath[sessionId],
|
||||
[path]: result,
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
treeBySessionPath: {
|
||||
...state.loading.treeBySessionPath,
|
||||
[treeKey]: false,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
treeBySessionPath: {
|
||||
...state.errors.treeBySessionPath,
|
||||
[treeKey]: result.error ?? null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
} catch (error) {
|
||||
if (!isLatestRequest(treeRequestIds, treeKey, requestId)) return
|
||||
|
||||
set((state) => ({
|
||||
loading: {
|
||||
...state.loading,
|
||||
treeBySessionPath: {
|
||||
...state.loading.treeBySessionPath,
|
||||
[treeKey]: false,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
treeBySessionPath: {
|
||||
...state.errors.treeBySessionPath,
|
||||
[treeKey]: error instanceof Error ? error.message : 'Failed to load workspace tree',
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
toggleTreeNode: async (sessionId, path) => {
|
||||
let shouldLoad = false
|
||||
|
||||
set((state) => {
|
||||
const expanded = new Set(state.expandedPathsBySession[sessionId] ?? [])
|
||||
if (expanded.has(path)) {
|
||||
expanded.delete(path)
|
||||
} else {
|
||||
expanded.add(path)
|
||||
if (!state.treeBySessionPath[sessionId]?.[path]) {
|
||||
shouldLoad = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
expandedPathsBySession: {
|
||||
...state.expandedPathsBySession,
|
||||
[sessionId]: [...expanded],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
if (shouldLoad) {
|
||||
await get().loadTree(sessionId, path)
|
||||
}
|
||||
},
|
||||
|
||||
openPreview: async (sessionId, path, kind) => {
|
||||
const tabId = getWorkspacePreviewTabId(path, kind)
|
||||
const requestKey = makePreviewKey(sessionId, tabId)
|
||||
const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId)
|
||||
|
||||
if (existing) {
|
||||
set((state) => ({
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
[sessionId]: tabId,
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = nextRequestId(previewRequestIds, requestKey)
|
||||
const baseTab: WorkspacePreviewTab = {
|
||||
id: tabId,
|
||||
path,
|
||||
kind,
|
||||
title: getPathTitle(path),
|
||||
state: 'loading',
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
[sessionId]: [...(state.previewTabsBySession[sessionId] ?? []), baseTab],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
[sessionId]: tabId,
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
previewByTabId: {
|
||||
...state.loading.previewByTabId,
|
||||
[requestKey]: true,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
previewByTabId: {
|
||||
...state.errors.previewByTabId,
|
||||
[requestKey]: null,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
try {
|
||||
if (kind === 'diff') {
|
||||
const result = await sessionsApi.getWorkspaceDiff(sessionId, path)
|
||||
if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return
|
||||
if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return
|
||||
|
||||
set((state) => {
|
||||
const tabs = state.previewTabsBySession[sessionId] ?? []
|
||||
return {
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
|
||||
...current,
|
||||
diff: result.diff ?? '',
|
||||
content: undefined,
|
||||
language: undefined,
|
||||
size: undefined,
|
||||
state: result.state,
|
||||
error: result.error,
|
||||
})),
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
previewByTabId: {
|
||||
...state.loading.previewByTabId,
|
||||
[requestKey]: false,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
previewByTabId: {
|
||||
...state.errors.previewByTabId,
|
||||
[requestKey]: result.error ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await sessionsApi.getWorkspaceFile(sessionId, path)
|
||||
if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return
|
||||
if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return
|
||||
|
||||
set((state) => {
|
||||
const tabs = state.previewTabsBySession[sessionId] ?? []
|
||||
return {
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
|
||||
...current,
|
||||
content: result.content,
|
||||
dataUrl: result.dataUrl,
|
||||
mimeType: result.mimeType,
|
||||
previewType: result.previewType ?? 'text',
|
||||
diff: undefined,
|
||||
language: result.language,
|
||||
size: result.size,
|
||||
state: result.state,
|
||||
error: result.error,
|
||||
})),
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
previewByTabId: {
|
||||
...state.loading.previewByTabId,
|
||||
[requestKey]: false,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
previewByTabId: {
|
||||
...state.errors.previewByTabId,
|
||||
[requestKey]: result.error ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isLatestRequest(previewRequestIds, requestKey, requestId)) return
|
||||
if (!get().previewTabsBySession[sessionId]?.some((tab) => tab.id === tabId)) return
|
||||
|
||||
set((state) => {
|
||||
const tabs = state.previewTabsBySession[sessionId] ?? []
|
||||
const message = error instanceof Error ? error.message : 'Failed to load workspace preview'
|
||||
|
||||
return {
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
|
||||
...current,
|
||||
state: 'error',
|
||||
error: message,
|
||||
})),
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
previewByTabId: {
|
||||
...state.loading.previewByTabId,
|
||||
[requestKey]: false,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
previewByTabId: {
|
||||
...state.errors.previewByTabId,
|
||||
[requestKey]: message,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
closePreview: (sessionId, tabId) => {
|
||||
get().closePreviewTabs(sessionId, tabId, 'current')
|
||||
},
|
||||
|
||||
closePreviewTabs: (sessionId, tabId, scope) => {
|
||||
set((state) => {
|
||||
const tabs = state.previewTabsBySession[sessionId] ?? []
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId)
|
||||
if (index < 0) {
|
||||
const requestKey = makePreviewKey(sessionId, tabId)
|
||||
invalidateRequest(previewRequestIds, requestKey)
|
||||
return {
|
||||
loading: {
|
||||
...state.loading,
|
||||
previewByTabId: removeRecordKey(state.loading.previewByTabId, requestKey),
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
previewByTabId: removeRecordKey(state.errors.previewByTabId, requestKey),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let nextTabs: WorkspacePreviewTab[]
|
||||
switch (scope) {
|
||||
case 'others':
|
||||
nextTabs = [tabs[index]!]
|
||||
break
|
||||
case 'left':
|
||||
nextTabs = tabs.slice(index)
|
||||
break
|
||||
case 'right':
|
||||
nextTabs = tabs.slice(0, index + 1)
|
||||
break
|
||||
case 'all':
|
||||
nextTabs = []
|
||||
break
|
||||
case 'current':
|
||||
default:
|
||||
nextTabs = tabs.filter((tab) => tab.id !== tabId)
|
||||
break
|
||||
}
|
||||
|
||||
const nextTabIds = new Set(nextTabs.map((tab) => tab.id))
|
||||
const closingTabIds = tabs.map((tab) => tab.id).filter((id) => !nextTabIds.has(id))
|
||||
const requestKeys = closingTabIds.map((id) => makePreviewKey(sessionId, id))
|
||||
for (const key of requestKeys) {
|
||||
invalidateRequest(previewRequestIds, key)
|
||||
}
|
||||
|
||||
const activeTabId = state.activePreviewTabIdBySession[sessionId] ?? null
|
||||
|
||||
let nextActiveTabId = activeTabId
|
||||
if (scope === 'all' || nextTabs.length === 0) {
|
||||
nextActiveTabId = null
|
||||
} else if (!activeTabId || !nextTabIds.has(activeTabId)) {
|
||||
const targetTab = nextTabs.find((tab) => tab.id === tabId)
|
||||
nextActiveTabId = targetTab?.id ?? nextTabs[Math.min(index, nextTabs.length - 1)]?.id ?? null
|
||||
} else if (scope === 'others') {
|
||||
nextActiveTabId = tabId
|
||||
} else if (activeTabId === tabId && scope === 'current') {
|
||||
if (nextTabs.length === 0) {
|
||||
nextActiveTabId = null
|
||||
} else if (index >= nextTabs.length) {
|
||||
nextActiveTabId = nextTabs[nextTabs.length - 1]!.id
|
||||
} else {
|
||||
nextActiveTabId = nextTabs[index]!.id
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
[sessionId]: nextTabs.length > 0 ? nextTabs : undefined,
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
[sessionId]: nextActiveTabId,
|
||||
},
|
||||
loading: {
|
||||
...state.loading,
|
||||
previewByTabId: removeRecordKeys(state.loading.previewByTabId, requestKeys),
|
||||
},
|
||||
errors: {
|
||||
...state.errors,
|
||||
previewByTabId: removeRecordKeys(state.errors.previewByTabId, requestKeys),
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clearSession: (sessionId) => {
|
||||
invalidateRequest(statusRequestIds, sessionId)
|
||||
invalidateSessionScopedRequests(treeRequestIds, sessionId)
|
||||
invalidateSessionScopedRequests(previewRequestIds, sessionId)
|
||||
|
||||
set((state) => ({
|
||||
panelBySession: removeRecordKey(state.panelBySession, sessionId),
|
||||
statusBySession: removeRecordKey(state.statusBySession, sessionId),
|
||||
expandedPathsBySession: removeRecordKey(state.expandedPathsBySession, sessionId),
|
||||
treeBySessionPath: removeRecordKey(state.treeBySessionPath, sessionId),
|
||||
previewTabsBySession: removeRecordKey(state.previewTabsBySession, sessionId),
|
||||
activePreviewTabIdBySession: removeRecordKey(state.activePreviewTabIdBySession, sessionId),
|
||||
loading: {
|
||||
statusBySession: removeRecordKey(state.loading.statusBySession, sessionId),
|
||||
treeBySessionPath: stripSessionKeys(state.loading.treeBySessionPath, sessionId),
|
||||
previewByTabId: stripSessionKeys(state.loading.previewByTabId, sessionId),
|
||||
},
|
||||
errors: {
|
||||
statusBySession: removeRecordKey(state.errors.statusBySession, sessionId),
|
||||
treeBySessionPath: stripSessionKeys(state.errors.treeBySessionPath, sessionId),
|
||||
previewByTabId: stripSessionKeys(state.errors.previewByTabId, sessionId),
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
resetSessionUi: (sessionId) => {
|
||||
get().clearSession(sessionId)
|
||||
},
|
||||
}))
|
||||
@ -31,13 +31,22 @@ export type AttachmentRef = {
|
||||
path?: string
|
||||
data?: string
|
||||
mimeType?: string
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
note?: string
|
||||
quote?: string
|
||||
}
|
||||
|
||||
export type UIAttachment = {
|
||||
type: 'file' | 'image'
|
||||
name: string
|
||||
path?: string
|
||||
data?: string
|
||||
mimeType?: string
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
note?: string
|
||||
quote?: string
|
||||
}
|
||||
|
||||
// ─── Server → Client ──────────────────────────────────────────────
|
||||
@ -157,7 +166,7 @@ export type TaskSummaryItem = {
|
||||
}
|
||||
|
||||
export type UIMessage =
|
||||
| { id: string; type: 'user_text'; content: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
|
||||
| { id: string; type: 'user_text'; content: string; modelContent?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
|
||||
| { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string }
|
||||
| { id: string; type: 'thinking'; content: string; timestamp: number }
|
||||
| { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string }
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { SessionService } from '../services/sessionService.js'
|
||||
@ -75,6 +76,34 @@ async function writeSkill(
|
||||
)
|
||||
}
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
async function createWorkspaceApiGitRepo(baseDir: string): Promise<string> {
|
||||
const workDir = path.join(
|
||||
baseDir,
|
||||
`workspace-api-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
)
|
||||
|
||||
await fs.mkdir(path.join(workDir, 'src'), { recursive: true })
|
||||
git(workDir, 'init')
|
||||
git(workDir, 'config', 'user.email', 'sessions-api@example.com')
|
||||
git(workDir, 'config', 'user.name', 'Sessions API')
|
||||
|
||||
await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\n')
|
||||
await fs.writeFile(path.join(workDir, 'src', 'app.ts'), 'export const answer = 42\n')
|
||||
git(workDir, 'add', 'tracked.txt', 'src/app.ts')
|
||||
git(workDir, 'commit', '-m', 'initial')
|
||||
|
||||
await fs.writeFile(path.join(workDir, 'tracked.txt'), 'before\nafter\n')
|
||||
|
||||
return workDir
|
||||
}
|
||||
|
||||
// Sample entries matching real CLI format
|
||||
function makeSnapshotEntry(): Record<string, unknown> {
|
||||
return {
|
||||
@ -136,6 +165,31 @@ function makeAssistantEntry(content: string, parentUuid?: string): Record<string
|
||||
}
|
||||
}
|
||||
|
||||
function makeAssistantToolUseEntry(
|
||||
toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }>,
|
||||
parentUuid?: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
parentUuid: parentUuid || null,
|
||||
isSidechain: false,
|
||||
type: 'assistant',
|
||||
message: {
|
||||
model: 'claude-opus-4-7',
|
||||
id: `msg_${crypto.randomUUID().slice(0, 20)}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: toolUses.map((toolUse) => ({
|
||||
type: 'tool_use',
|
||||
id: toolUse.id,
|
||||
name: toolUse.name,
|
||||
input: toolUse.input,
|
||||
})),
|
||||
},
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-01-01T00:02:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function makeMetaUserEntry(): Record<string, unknown> {
|
||||
return {
|
||||
parentUuid: null,
|
||||
@ -930,6 +984,301 @@ describe('Sessions API', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/workspace/status|tree|file|diff should return workspace data', async () => {
|
||||
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
||||
const { sessionId } = await service.createSession(workDir)
|
||||
|
||||
const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`)
|
||||
expect(statusRes.status).toBe(200)
|
||||
const statusBody = await statusRes.json() as {
|
||||
state: string
|
||||
workDir: string
|
||||
changedFiles: Array<{ path: string; status: string }>
|
||||
isGitRepo: boolean
|
||||
}
|
||||
expect(statusBody.state).toBe('ok')
|
||||
expect(statusBody.workDir).toBe(workDir)
|
||||
expect(statusBody.isGitRepo).toBe(true)
|
||||
expect(statusBody.changedFiles).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'tracked.txt', status: 'modified' }),
|
||||
]),
|
||||
)
|
||||
|
||||
const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`)
|
||||
expect(treeRes.status).toBe(200)
|
||||
const treeBody = await treeRes.json() as {
|
||||
state: string
|
||||
path: string
|
||||
entries: Array<{ name: string; path: string; isDirectory: boolean }>
|
||||
}
|
||||
expect(treeBody).toMatchObject({
|
||||
state: 'ok',
|
||||
path: '',
|
||||
})
|
||||
expect(treeBody.entries).toEqual([
|
||||
{ name: 'src', path: 'src', isDirectory: true },
|
||||
{ name: 'tracked.txt', path: 'tracked.txt', isDirectory: false },
|
||||
])
|
||||
|
||||
const fileRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`,
|
||||
)
|
||||
expect(fileRes.status).toBe(200)
|
||||
const fileBody = await fileRes.json() as {
|
||||
state: string
|
||||
path: string
|
||||
content?: string
|
||||
language: string
|
||||
size: number
|
||||
}
|
||||
expect(fileBody).toMatchObject({
|
||||
state: 'ok',
|
||||
path: 'src/app.ts',
|
||||
language: 'typescript',
|
||||
size: 25,
|
||||
content: 'export const answer = 42\n',
|
||||
})
|
||||
|
||||
const diffRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('tracked.txt')}`,
|
||||
)
|
||||
expect(diffRes.status).toBe(200)
|
||||
const diffBody = await diffRes.json() as {
|
||||
state: string
|
||||
path: string
|
||||
diff?: string
|
||||
}
|
||||
expect(diffBody.state).toBe('ok')
|
||||
expect(diffBody.path).toBe('tracked.txt')
|
||||
expect(diffBody.diff).toContain('tracked.txt')
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/workspace/* should surface transcript changes for a non-git tmp session', async () => {
|
||||
const sessionId = 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff'
|
||||
const workDir = await fs.mkdtemp(path.join(tmpDir, 'workspace-api-non-git-'))
|
||||
const srcDir = path.join(workDir, 'src')
|
||||
const notesDir = path.join(workDir, 'notes')
|
||||
const assetsDir = path.join(workDir, 'assets')
|
||||
|
||||
await fs.mkdir(srcDir, { recursive: true })
|
||||
await fs.mkdir(notesDir, { recursive: true })
|
||||
await fs.mkdir(assetsDir, { recursive: true })
|
||||
await fs.writeFile(path.join(workDir, 'README.md'), '# Temporary project\n')
|
||||
await fs.writeFile(path.join(srcDir, 'app.ts'), 'export const answer = 2\n')
|
||||
await fs.writeFile(path.join(notesDir, 'todo.md'), '- ship workspace panel\n')
|
||||
await fs.writeFile(
|
||||
path.join(assetsDir, 'pixel.png'),
|
||||
Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
),
|
||||
)
|
||||
|
||||
await writeSessionFile(sanitizePath(workDir), sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeSessionMetaEntry(workDir),
|
||||
makeUserEntry('Update this temporary project'),
|
||||
makeAssistantToolUseEntry([
|
||||
{
|
||||
id: 'toolu-edit-app',
|
||||
name: 'Edit',
|
||||
input: {
|
||||
file_path: path.join(workDir, 'src', 'app.ts'),
|
||||
old_string: 'export const answer = 1\n',
|
||||
new_string: 'export const answer = 2\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'toolu-write-todo',
|
||||
name: 'Write',
|
||||
input: {
|
||||
file_path: path.join(workDir, 'notes', 'todo.md'),
|
||||
content: '- ship workspace panel\n',
|
||||
},
|
||||
},
|
||||
]),
|
||||
])
|
||||
|
||||
const statusRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`)
|
||||
expect(statusRes.status).toBe(200)
|
||||
const statusBody = await statusRes.json() as {
|
||||
state: string
|
||||
workDir: string
|
||||
repoName: string | null
|
||||
branch: string | null
|
||||
isGitRepo: boolean
|
||||
changedFiles: Array<{
|
||||
path: string
|
||||
status: string
|
||||
additions: number
|
||||
deletions: number
|
||||
}>
|
||||
}
|
||||
expect(statusBody).toMatchObject({
|
||||
state: 'ok',
|
||||
workDir,
|
||||
repoName: path.basename(workDir),
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
})
|
||||
expect(statusBody.changedFiles).toEqual([
|
||||
expect.objectContaining({
|
||||
path: 'notes/todo.md',
|
||||
status: 'added',
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: 'src/app.ts',
|
||||
status: 'modified',
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
}),
|
||||
])
|
||||
|
||||
const treeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/tree`)
|
||||
expect(treeRes.status).toBe(200)
|
||||
const treeBody = await treeRes.json() as {
|
||||
state: string
|
||||
path: string
|
||||
entries: Array<{ name: string; path: string; isDirectory: boolean }>
|
||||
}
|
||||
expect(treeBody).toMatchObject({ state: 'ok', path: '' })
|
||||
expect(treeBody.entries).toEqual([
|
||||
{ name: 'assets', path: 'assets', isDirectory: true },
|
||||
{ name: 'notes', path: 'notes', isDirectory: true },
|
||||
{ name: 'src', path: 'src', isDirectory: true },
|
||||
{ name: 'README.md', path: 'README.md', isDirectory: false },
|
||||
])
|
||||
|
||||
const srcTreeRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/tree?path=${encodeURIComponent('src')}`,
|
||||
)
|
||||
expect(srcTreeRes.status).toBe(200)
|
||||
expect(await srcTreeRes.json()).toMatchObject({
|
||||
state: 'ok',
|
||||
path: 'src',
|
||||
entries: [{ name: 'app.ts', path: 'src/app.ts', isDirectory: false }],
|
||||
})
|
||||
|
||||
const fileRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('src/app.ts')}`,
|
||||
)
|
||||
expect(fileRes.status).toBe(200)
|
||||
expect(await fileRes.json()).toMatchObject({
|
||||
state: 'ok',
|
||||
path: 'src/app.ts',
|
||||
previewType: 'text',
|
||||
language: 'typescript',
|
||||
content: 'export const answer = 2\n',
|
||||
})
|
||||
|
||||
const imageRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/file?path=${encodeURIComponent('assets/pixel.png')}`,
|
||||
)
|
||||
expect(imageRes.status).toBe(200)
|
||||
const imageBody = await imageRes.json() as {
|
||||
state: string
|
||||
path: string
|
||||
previewType: string
|
||||
mimeType: string
|
||||
dataUrl: string
|
||||
}
|
||||
expect(imageBody).toMatchObject({
|
||||
state: 'ok',
|
||||
path: 'assets/pixel.png',
|
||||
previewType: 'image',
|
||||
mimeType: 'image/png',
|
||||
})
|
||||
expect(imageBody.dataUrl).toStartWith('data:image/png;base64,')
|
||||
|
||||
const appDiffRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('src/app.ts')}`,
|
||||
)
|
||||
expect(appDiffRes.status).toBe(200)
|
||||
const appDiffBody = await appDiffRes.json() as { state: string; path: string; diff?: string }
|
||||
expect(appDiffBody).toMatchObject({ state: 'ok', path: 'src/app.ts' })
|
||||
expect(appDiffBody.diff).toContain('diff --session a/src/app.ts b/src/app.ts')
|
||||
expect(appDiffBody.diff).toContain('-export const answer = 1')
|
||||
expect(appDiffBody.diff).toContain('+export const answer = 2')
|
||||
|
||||
const todoDiffRes = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('notes/todo.md')}`,
|
||||
)
|
||||
expect(todoDiffRes.status).toBe(200)
|
||||
const todoDiffBody = await todoDiffRes.json() as { state: string; path: string; diff?: string }
|
||||
expect(todoDiffBody).toMatchObject({ state: 'ok', path: 'notes/todo.md' })
|
||||
expect(todoDiffBody.diff).toContain('--- /dev/null')
|
||||
expect(todoDiffBody.diff).toContain('+++ b/notes/todo.md')
|
||||
expect(todoDiffBody.diff).toContain('+- ship workspace panel')
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/workspace/file and diff should require a path query', async () => {
|
||||
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
||||
const { sessionId } = await service.createSession(workDir)
|
||||
|
||||
for (const route of ['file', 'diff']) {
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/${route}`)
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/workspace/file and tree should reject traversal with 403', async () => {
|
||||
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
||||
const { sessionId } = await service.createSession(workDir)
|
||||
|
||||
for (const route of ['file', 'tree']) {
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/${route}?path=${encodeURIComponent('../outside.txt')}`,
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: 'FORBIDDEN',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/workspace/diff should reject traversal with 403', async () => {
|
||||
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
||||
const { sessionId } = await service.createSession(workDir)
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/sessions/${sessionId}/workspace/diff?path=${encodeURIComponent('../outside.txt')}`,
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: 'FORBIDDEN',
|
||||
})
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/workspace/status should 404 for unknown sessions', async () => {
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/sessions/00000000-0000-0000-0000-000000000000/workspace/status`,
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: 'NOT_FOUND',
|
||||
})
|
||||
})
|
||||
|
||||
it('non-GET workspace routes should return 405', async () => {
|
||||
const workDir = await createWorkspaceApiGitRepo(tmpDir)
|
||||
const { sessionId } = await service.createSession(workDir)
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/workspace/status`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(405)
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: 'METHOD_NOT_ALLOWED',
|
||||
})
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should preview and trim the active conversation chain', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const firstUserId = crypto.randomUUID()
|
||||
@ -1188,6 +1537,65 @@ describe('Sessions API', () => {
|
||||
expect(remainingMessages).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should resolve checkpoint paths from the target prompt cwd', async () => {
|
||||
const sessionId = 'bbbbbbbb-bbbb-cccc-dddd-ffffffffffff'
|
||||
const parentDir = path.join(tmpDir, 'nested-cwd-parent')
|
||||
const workDir = path.join(parentDir, 'testbb')
|
||||
const targetFile = path.join(workDir, 'vite.config.js')
|
||||
const userId = crypto.randomUUID()
|
||||
const assistantId = crypto.randomUUID()
|
||||
const laterUserId = crypto.randomUUID()
|
||||
const backupName = 'nested-cwd@v1'
|
||||
|
||||
await fs.mkdir(workDir, { recursive: true })
|
||||
await fs.writeFile(targetFile, "export default 'after'\n", 'utf-8')
|
||||
await writeFileHistoryBackup(sessionId, backupName, "export default 'before'\n")
|
||||
|
||||
await writeSessionFile(sanitizePath(parentDir), sessionId, [
|
||||
makeFileHistorySnapshotEntry(userId, {
|
||||
'testbb/vite.config.js': {
|
||||
backupFileName: backupName,
|
||||
version: 1,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('create a nested project', userId),
|
||||
cwd: parentDir,
|
||||
sessionId,
|
||||
},
|
||||
{
|
||||
...makeAssistantEntry('DONE', userId),
|
||||
uuid: assistantId,
|
||||
},
|
||||
{
|
||||
...makeUserEntry('latest tool result after cd', laterUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
])
|
||||
|
||||
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 0, dryRun: true }),
|
||||
})
|
||||
expect(previewRes.status).toBe(200)
|
||||
const preview = await previewRes.json() as {
|
||||
code: { available: boolean; filesChanged: string[] }
|
||||
}
|
||||
expect(preview.code.available).toBe(true)
|
||||
expect(preview.code.filesChanged).toEqual([targetFile])
|
||||
|
||||
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 0 }),
|
||||
})
|
||||
expect(executeRes.status).toBe(200)
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe("export default 'before'\n")
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should restore multiple files and remove created files', async () => {
|
||||
const sessionId = 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const workDir = path.join(tmpDir, 'multi-file-fixture')
|
||||
@ -1318,6 +1726,163 @@ describe('Sessions API', () => {
|
||||
expect(remainingMessages[0]?.id).toBe(firstUserId)
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should keep first-turn file state when undoing only the latest turn', async () => {
|
||||
const sessionId = 'dddddddd-bbbb-cccc-dddd-ffffffffffff'
|
||||
const workDir = path.join(tmpDir, 'two-turns-separate-files')
|
||||
const firstTurnFile = path.join(workDir, 'src', 'first.js')
|
||||
const secondTurnFile = path.join(workDir, 'src', 'second.js')
|
||||
const firstUserId = crypto.randomUUID()
|
||||
const secondUserId = crypto.randomUUID()
|
||||
const firstBaseBackup = 'separate-first@v1'
|
||||
const firstAfterTurnBackup = 'separate-first@v2'
|
||||
const secondBaseBackup = 'separate-second@v1'
|
||||
|
||||
await fs.mkdir(path.dirname(firstTurnFile), { recursive: true })
|
||||
await fs.writeFile(firstTurnFile, "export const FIRST = 'v1'\n", 'utf-8')
|
||||
await fs.writeFile(secondTurnFile, "export const SECOND = 'v2'\n", 'utf-8')
|
||||
await writeFileHistoryBackup(sessionId, firstBaseBackup, "export const FIRST = 'base'\n")
|
||||
await writeFileHistoryBackup(sessionId, firstAfterTurnBackup, "export const FIRST = 'v1'\n")
|
||||
await writeFileHistoryBackup(sessionId, secondBaseBackup, "export const SECOND = 'base'\n")
|
||||
|
||||
await writeSessionFile('-tmp-api-two-turns-separate-files', sessionId, [
|
||||
makeSessionMetaEntry(workDir),
|
||||
makeFileHistorySnapshotEntry(firstUserId, {
|
||||
'src/first.js': {
|
||||
backupFileName: firstBaseBackup,
|
||||
version: 1,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('make first file v1', firstUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantEntry('DONE first', firstUserId),
|
||||
makeFileHistorySnapshotEntry(secondUserId, {
|
||||
'src/first.js': {
|
||||
backupFileName: firstAfterTurnBackup,
|
||||
version: 2,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
'src/second.js': {
|
||||
backupFileName: secondBaseBackup,
|
||||
version: 1,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('make second file v2', secondUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantEntry('DONE second', secondUserId),
|
||||
])
|
||||
|
||||
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 1, dryRun: true }),
|
||||
})
|
||||
expect(previewRes.status).toBe(200)
|
||||
const preview = await previewRes.json() as {
|
||||
code: { available: boolean; filesChanged: string[] }
|
||||
}
|
||||
expect(preview.code.available).toBe(true)
|
||||
expect(preview.code.filesChanged).toEqual([secondTurnFile])
|
||||
|
||||
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 1 }),
|
||||
})
|
||||
expect(executeRes.status).toBe(200)
|
||||
|
||||
expect(await fs.readFile(firstTurnFile, 'utf-8')).toBe("export const FIRST = 'v1'\n")
|
||||
expect(await fs.readFile(secondTurnFile, 'utf-8')).toBe("export const SECOND = 'base'\n")
|
||||
|
||||
const remainingMessages = await service.getSessionMessages(sessionId)
|
||||
expect(remainingMessages).toHaveLength(2)
|
||||
expect(remainingMessages[0]?.id).toBe(firstUserId)
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should include files created after the first turn', async () => {
|
||||
const sessionId = 'eeeeeeee-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const workDir = path.join(tmpDir, 'created-on-second-turn')
|
||||
const firstFile = path.join(workDir, 'src', 'step.js')
|
||||
const createdFile = path.join(workDir, 'notes', 'generated.txt')
|
||||
const firstUserId = crypto.randomUUID()
|
||||
const secondUserId = crypto.randomUUID()
|
||||
const backupV1 = 'second-created-step@v1'
|
||||
const backupV2 = 'second-created-step@v2'
|
||||
|
||||
await fs.mkdir(path.dirname(firstFile), { recursive: true })
|
||||
await fs.mkdir(path.dirname(createdFile), { recursive: true })
|
||||
await fs.writeFile(firstFile, "export const STEP = 'v2'\n", 'utf-8')
|
||||
await fs.writeFile(createdFile, 'generated\n', 'utf-8')
|
||||
await writeFileHistoryBackup(sessionId, backupV1, "export const STEP = 'base'\n")
|
||||
await writeFileHistoryBackup(sessionId, backupV2, "export const STEP = 'v1'\n")
|
||||
|
||||
await writeSessionFile('-tmp-api-second-turn-created', sessionId, [
|
||||
makeSessionMetaEntry(workDir),
|
||||
makeFileHistorySnapshotEntry(firstUserId, {
|
||||
'src/step.js': {
|
||||
backupFileName: backupV1,
|
||||
version: 1,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('make v1', firstUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantEntry('DONE', firstUserId),
|
||||
makeFileHistorySnapshotEntry(secondUserId, {
|
||||
'src/step.js': {
|
||||
backupFileName: backupV2,
|
||||
version: 2,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
'notes/generated.txt': {
|
||||
backupFileName: null,
|
||||
version: 2,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('make v2 and create file', secondUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantEntry('DONE', secondUserId),
|
||||
])
|
||||
|
||||
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 1, dryRun: true }),
|
||||
})
|
||||
expect(previewRes.status).toBe(200)
|
||||
const preview = await previewRes.json() as {
|
||||
code: { available: boolean; filesChanged: string[]; insertions: number }
|
||||
}
|
||||
expect(preview.code.filesChanged.sort()).toEqual([
|
||||
createdFile,
|
||||
firstFile,
|
||||
].sort())
|
||||
expect(preview.code.insertions).toBe(2)
|
||||
|
||||
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 1 }),
|
||||
})
|
||||
expect(executeRes.status).toBe(200)
|
||||
expect(await fs.readFile(firstFile, 'utf-8')).toBe("export const STEP = 'v1'\n")
|
||||
await expect(fs.stat(createdFile)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Conversations API via /api/sessions/:id/chat
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
461
src/server/__tests__/workspace-service.test.ts
Normal file
461
src/server/__tests__/workspace-service.test.ts
Normal file
@ -0,0 +1,461 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { WorkspaceService } from '../services/workspaceService.js'
|
||||
|
||||
const cleanupDirs = new Set<string>()
|
||||
const ONE_MIB = 1024 * 1024
|
||||
|
||||
function trackDir(dir: string): string {
|
||||
cleanupDirs.add(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
return trackDir(await fs.mkdtemp(path.join(os.tmpdir(), prefix)))
|
||||
}
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
async function createGitWorkspace(): Promise<string> {
|
||||
const repoDir = await makeTempDir('workspace-service-git-')
|
||||
|
||||
git(repoDir, 'init')
|
||||
git(repoDir, 'config', 'user.email', 'workspace-service@example.com')
|
||||
git(repoDir, 'config', 'user.name', 'Workspace Service')
|
||||
|
||||
await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'before\n')
|
||||
await fs.writeFile(path.join(repoDir, 'deleted.txt'), 'delete me\n')
|
||||
await fs.writeFile(path.join(repoDir, 'clean.txt'), 'clean\n')
|
||||
git(repoDir, 'add', 'tracked.txt', 'deleted.txt', 'clean.txt')
|
||||
git(repoDir, 'commit', '-m', 'initial')
|
||||
|
||||
await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'before\nafter\n')
|
||||
await fs.writeFile(path.join(repoDir, 'new.txt'), 'new file\n')
|
||||
git(repoDir, 'add', 'new.txt')
|
||||
await fs.unlink(path.join(repoDir, 'deleted.txt'))
|
||||
await fs.writeFile(path.join(repoDir, 'untracked.txt'), 'still untracked\n')
|
||||
|
||||
return repoDir
|
||||
}
|
||||
|
||||
async function createNestedGitWorkspace(): Promise<{
|
||||
repoDir: string
|
||||
workDir: string
|
||||
}> {
|
||||
const repoDir = await makeTempDir('workspace-service-nested-git-')
|
||||
const workDir = path.join(repoDir, 'subdir')
|
||||
|
||||
git(repoDir, 'init')
|
||||
git(repoDir, 'config', 'user.email', 'workspace-service@example.com')
|
||||
git(repoDir, 'config', 'user.name', 'Workspace Service')
|
||||
|
||||
await fs.mkdir(workDir)
|
||||
await fs.writeFile(path.join(repoDir, 'root.txt'), 'root original\n')
|
||||
await fs.writeFile(path.join(workDir, 'sub.txt'), 'sub original\n')
|
||||
git(repoDir, 'add', 'root.txt', 'subdir/sub.txt')
|
||||
git(repoDir, 'commit', '-m', 'initial')
|
||||
|
||||
await fs.writeFile(path.join(repoDir, 'root.txt'), 'root original\nroot changed\n')
|
||||
await fs.writeFile(path.join(workDir, 'sub.txt'), 'sub original\nsub changed\n')
|
||||
|
||||
return { repoDir, workDir }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of cleanupDirs) {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
cleanupDirs.clear()
|
||||
})
|
||||
|
||||
describe('WorkspaceService', () => {
|
||||
it('returns git status for modified, added, deleted, and untracked files', async () => {
|
||||
const repoDir = await createGitWorkspace()
|
||||
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null)
|
||||
|
||||
const result = await service.getStatus('session-1')
|
||||
|
||||
expect(result.state).toBe('ok')
|
||||
expect(result.workDir).toBe(repoDir)
|
||||
expect(result.isGitRepo).toBe(true)
|
||||
expect(result.repoName).toBe(path.basename(repoDir))
|
||||
expect(result.branch).toBeTruthy()
|
||||
|
||||
const files = new Map(result.changedFiles.map((file) => [file.path, file]))
|
||||
expect(Array.from(files.keys()).sort()).toEqual([
|
||||
'deleted.txt',
|
||||
'new.txt',
|
||||
'tracked.txt',
|
||||
'untracked.txt',
|
||||
])
|
||||
expect(files.get('tracked.txt')?.status).toBe('modified')
|
||||
expect(files.get('tracked.txt')?.additions).toBeGreaterThan(0)
|
||||
expect(files.get('new.txt')?.status).toBe('added')
|
||||
expect(files.get('new.txt')?.additions).toBeGreaterThan(0)
|
||||
expect(files.get('deleted.txt')?.status).toBe('deleted')
|
||||
expect(files.get('deleted.txt')?.deletions).toBeGreaterThan(0)
|
||||
expect(files.get('untracked.txt')).toMatchObject({
|
||||
status: 'untracked',
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('scopes git status and diff paths to a nested workDir inside a repo', async () => {
|
||||
const { repoDir, workDir } = await createNestedGitWorkspace()
|
||||
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? workDir : null)
|
||||
|
||||
const status = await service.getStatus('session-1')
|
||||
|
||||
expect(status.state).toBe('ok')
|
||||
expect(status.workDir).toBe(workDir)
|
||||
expect(status.repoName).toBe(path.basename(repoDir))
|
||||
expect(status.changedFiles).toHaveLength(1)
|
||||
expect(status.changedFiles[0]).toMatchObject({
|
||||
path: 'sub.txt',
|
||||
status: 'modified',
|
||||
})
|
||||
expect(status.changedFiles[0]?.additions).toBeGreaterThan(0)
|
||||
expect(status.changedFiles[0]?.deletions).toBeGreaterThanOrEqual(0)
|
||||
expect(status.changedFiles.some((file) => file.path === 'root.txt')).toBe(false)
|
||||
|
||||
const diff = await service.getDiff('session-1', 'sub.txt')
|
||||
expect(diff.state).toBe('ok')
|
||||
expect(diff.diff).toContain('subdir/sub.txt')
|
||||
expect(diff.diff?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns explicit non-git and missing-workdir states', async () => {
|
||||
const nonGitDir = await makeTempDir('workspace-service-non-git-')
|
||||
const missingDir = path.join(await makeTempDir('workspace-service-missing-parent-'), 'missing')
|
||||
const service = new WorkspaceService(async (sessionId) => {
|
||||
if (sessionId === 'non-git') return nonGitDir
|
||||
if (sessionId === 'missing') return missingDir
|
||||
return null
|
||||
})
|
||||
|
||||
await expect(service.getStatus('unknown')).rejects.toThrow('Session not found: unknown')
|
||||
|
||||
await expect(service.getStatus('non-git')).resolves.toMatchObject({
|
||||
state: 'ok',
|
||||
workDir: nonGitDir,
|
||||
repoName: path.basename(nonGitDir),
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
|
||||
await expect(service.getStatus('missing')).resolves.toMatchObject({
|
||||
state: 'missing_workdir',
|
||||
workDir: missingDir,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports session tool edits without requiring a git repository', async () => {
|
||||
const nonGitDir = await makeTempDir('workspace-service-session-changes-')
|
||||
await fs.mkdir(path.join(nonGitDir, 'src'))
|
||||
await fs.writeFile(path.join(nonGitDir, 'src/App.jsx'), 'export default function App() { return <main>New</main> }\n')
|
||||
|
||||
const service = new WorkspaceService(
|
||||
async () => nonGitDir,
|
||||
async () => [{
|
||||
id: 'assistant-1',
|
||||
type: 'tool_use',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
name: 'Edit',
|
||||
input: {
|
||||
file_path: 'src/App.jsx',
|
||||
old_string: 'export default function App() { return <main>Old</main> }\n',
|
||||
new_string: 'export default function App() { return <main>New</main> }\n',
|
||||
},
|
||||
}],
|
||||
}],
|
||||
)
|
||||
|
||||
const status = await service.getStatus('session-1')
|
||||
|
||||
expect(status).toMatchObject({
|
||||
state: 'ok',
|
||||
workDir: nonGitDir,
|
||||
isGitRepo: false,
|
||||
changedFiles: [{
|
||||
path: 'src/App.jsx',
|
||||
status: 'modified',
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
}],
|
||||
})
|
||||
|
||||
const diff = await service.getDiff('session-1', 'src/App.jsx')
|
||||
expect(diff.state).toBe('ok')
|
||||
expect(diff.diff).toContain('diff --session a/src/App.jsx b/src/App.jsx')
|
||||
expect(diff.diff).toContain('-export default function App() { return <main>Old</main> }')
|
||||
expect(diff.diff).toContain('+export default function App() { return <main>New</main> }')
|
||||
})
|
||||
|
||||
it('rejects traversal attempts for file, diff, and tree access', async () => {
|
||||
const repoDir = await createGitWorkspace()
|
||||
const service = new WorkspaceService(async () => repoDir)
|
||||
|
||||
await expect(service.readFile('session-1', '../outside.txt')).rejects.toThrow(/outside workspace/)
|
||||
await expect(service.getDiff('session-1', '../outside.txt')).resolves.toMatchObject({
|
||||
state: 'error',
|
||||
path: '../outside.txt',
|
||||
})
|
||||
await expect(service.readTree('session-1', '../outside')).rejects.toThrow(/outside workspace/)
|
||||
})
|
||||
|
||||
it('rejects symlink targets that escape the workspace root', async () => {
|
||||
const workDir = await makeTempDir('workspace-service-symlink-')
|
||||
const outsideDir = await makeTempDir('workspace-service-symlink-outside-')
|
||||
const outsideFile = path.join(outsideDir, 'secret.txt')
|
||||
await fs.writeFile(outsideFile, 'top secret\n')
|
||||
await fs.symlink(outsideFile, path.join(workDir, 'escape.txt'))
|
||||
|
||||
const service = new WorkspaceService(async () => workDir)
|
||||
|
||||
await expect(service.readFile('session-1', 'escape.txt')).rejects.toThrow(/outside workspace/)
|
||||
})
|
||||
|
||||
it('returns error for an untracked symlink that escapes the workspace root', async () => {
|
||||
const repoDir = await makeTempDir('workspace-service-symlink-git-')
|
||||
const outsideDir = await makeTempDir('workspace-service-symlink-git-outside-')
|
||||
const outsideFile = path.join(outsideDir, 'secret.txt')
|
||||
|
||||
git(repoDir, 'init')
|
||||
git(repoDir, 'config', 'user.email', 'workspace-service@example.com')
|
||||
git(repoDir, 'config', 'user.name', 'Workspace Service')
|
||||
await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'tracked\n')
|
||||
git(repoDir, 'add', 'tracked.txt')
|
||||
git(repoDir, 'commit', '-m', 'initial')
|
||||
|
||||
await fs.writeFile(outsideFile, 'top secret\n')
|
||||
await fs.symlink(outsideFile, path.join(repoDir, 'escape.txt'))
|
||||
|
||||
const service = new WorkspaceService(async () => repoDir)
|
||||
|
||||
const status = await service.getStatus('session-1')
|
||||
expect(status.state).toBe('error')
|
||||
expect(status.error).toMatch(/outside workspace/)
|
||||
|
||||
await expect(service.getDiff('session-1', 'escape.txt')).resolves.toMatchObject({
|
||||
state: 'error',
|
||||
path: 'escape.txt',
|
||||
})
|
||||
const diffOutcome = await service.getDiff('session-1', 'escape.txt')
|
||||
expect(diffOutcome.error).toMatch(/outside workspace/)
|
||||
})
|
||||
|
||||
it('returns explicit readFile states for text, binary, large, and missing targets', async () => {
|
||||
const workDir = await makeTempDir('workspace-service-files-')
|
||||
const service = new WorkspaceService(async () => workDir)
|
||||
|
||||
await fs.writeFile(path.join(workDir, 'note.ts'), 'export const answer = 42\n')
|
||||
await fs.writeFile(path.join(workDir, 'binary.bin'), Buffer.from([0, 1, 2, 3]))
|
||||
await fs.writeFile(path.join(workDir, 'image.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]))
|
||||
await fs.writeFile(
|
||||
path.join(workDir, 'large-image.png'),
|
||||
Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), Buffer.alloc(ONE_MIB + 1, 0xff)]),
|
||||
)
|
||||
await fs.writeFile(path.join(workDir, 'large.txt'), Buffer.alloc(ONE_MIB + 1, 'a'))
|
||||
await fs.mkdir(path.join(workDir, 'folder'))
|
||||
|
||||
await expect(service.readFile('session-1', 'note.ts')).resolves.toMatchObject({
|
||||
state: 'ok',
|
||||
language: 'typescript',
|
||||
size: 25,
|
||||
content: 'export const answer = 42\n',
|
||||
})
|
||||
await expect(service.readFile('session-1', 'binary.bin')).resolves.toMatchObject({
|
||||
state: 'binary',
|
||||
language: 'binary',
|
||||
size: 4,
|
||||
})
|
||||
await expect(service.readFile('session-1', 'image.png')).resolves.toMatchObject({
|
||||
state: 'ok',
|
||||
previewType: 'image',
|
||||
language: 'image',
|
||||
mimeType: 'image/png',
|
||||
dataUrl: 'data:image/png;base64,iVBORwA=',
|
||||
size: 5,
|
||||
})
|
||||
const largeImage = await service.readFile('session-1', 'large-image.png')
|
||||
expect(largeImage).toMatchObject({
|
||||
state: 'ok',
|
||||
previewType: 'image',
|
||||
language: 'image',
|
||||
mimeType: 'image/png',
|
||||
size: ONE_MIB + 5,
|
||||
})
|
||||
expect(largeImage.dataUrl).toStartWith('data:image/png;base64,')
|
||||
await expect(service.readFile('session-1', 'large.txt')).resolves.toMatchObject({
|
||||
state: 'ok',
|
||||
previewType: 'text',
|
||||
language: 'text',
|
||||
size: ONE_MIB + 1,
|
||||
readBytes: ONE_MIB,
|
||||
truncated: true,
|
||||
content: 'a'.repeat(ONE_MIB),
|
||||
})
|
||||
await expect(service.readFile('session-1', 'missing.txt')).resolves.toMatchObject({
|
||||
state: 'missing',
|
||||
})
|
||||
await expect(service.readFile('session-1', 'folder')).resolves.toMatchObject({
|
||||
state: 'missing',
|
||||
})
|
||||
})
|
||||
|
||||
it('lists a single directory level with dotfiles excluded and directories first', async () => {
|
||||
const workDir = await makeTempDir('workspace-service-tree-')
|
||||
const service = new WorkspaceService(async () => workDir)
|
||||
|
||||
await fs.mkdir(path.join(workDir, 'b-dir'))
|
||||
await fs.mkdir(path.join(workDir, 'a-dir'))
|
||||
await fs.mkdir(path.join(workDir, 'a-dir', 'inner'))
|
||||
await fs.writeFile(path.join(workDir, 'a-dir', 'note.txt'), 'nested\n')
|
||||
await fs.writeFile(path.join(workDir, 'z-file.txt'), 'root file\n')
|
||||
await fs.writeFile(path.join(workDir, '.hidden.txt'), 'ignore\n')
|
||||
|
||||
await expect(service.readTree('session-1')).resolves.toMatchObject({
|
||||
state: 'ok',
|
||||
path: '',
|
||||
entries: [
|
||||
{ name: 'a-dir', path: 'a-dir', isDirectory: true },
|
||||
{ name: 'b-dir', path: 'b-dir', isDirectory: true },
|
||||
{ name: 'z-file.txt', path: 'z-file.txt', isDirectory: false },
|
||||
],
|
||||
})
|
||||
|
||||
await expect(service.readTree('session-1', 'a-dir')).resolves.toMatchObject({
|
||||
state: 'ok',
|
||||
path: 'a-dir',
|
||||
entries: [
|
||||
{ name: 'inner', path: 'a-dir/inner', isDirectory: true },
|
||||
{ name: 'note.txt', path: 'a-dir/note.txt', isDirectory: false },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns diffs for modified, added, deleted, and untracked files', async () => {
|
||||
const repoDir = await createGitWorkspace()
|
||||
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null)
|
||||
|
||||
const modified = await service.getDiff('session-1', 'tracked.txt')
|
||||
expect(modified.state).toBe('ok')
|
||||
expect(modified.diff).toContain('tracked.txt')
|
||||
expect(modified.diff.length).toBeGreaterThan(0)
|
||||
|
||||
const added = await service.getDiff('session-1', 'new.txt')
|
||||
expect(added.state).toBe('ok')
|
||||
expect(added.diff).toContain('new.txt')
|
||||
expect(added.diff.length).toBeGreaterThan(0)
|
||||
|
||||
const deleted = await service.getDiff('session-1', 'deleted.txt')
|
||||
expect(deleted.state).toBe('ok')
|
||||
expect(deleted.diff).toContain('deleted.txt')
|
||||
expect(deleted.diff.length).toBeGreaterThan(0)
|
||||
|
||||
const untracked = await service.getDiff('session-1', 'untracked.txt')
|
||||
expect(untracked.state).toBe('ok')
|
||||
expect(untracked.diff).toContain('untracked.txt')
|
||||
expect(untracked.diff.length).toBeGreaterThan(0)
|
||||
|
||||
await expect(service.getDiff('session-1', 'clean.txt')).resolves.toMatchObject({
|
||||
state: 'missing',
|
||||
path: 'clean.txt',
|
||||
})
|
||||
|
||||
const nonGitDir = await makeTempDir('workspace-service-diff-non-git-')
|
||||
const nonGitService = new WorkspaceService(async () => nonGitDir)
|
||||
await expect(nonGitService.getDiff('session-1', 'whatever.txt')).resolves.toMatchObject({
|
||||
state: 'not_git_repo',
|
||||
path: 'whatever.txt',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns explicit error state when git status fails instead of ok-empty', async () => {
|
||||
const repoDir = await createGitWorkspace()
|
||||
const service = new WorkspaceService(async () => repoDir) as WorkspaceService & {
|
||||
runGit: (workDir: string, args: string[]) => Promise<{
|
||||
stdout: string
|
||||
stderr: string
|
||||
code: number
|
||||
}>
|
||||
}
|
||||
|
||||
service.runGit = async (workDir, args) => {
|
||||
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') {
|
||||
return { stdout: `${workDir}\n`, stderr: '', code: 0 }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
|
||||
return { stdout: 'main\n', stderr: '', code: 0 }
|
||||
}
|
||||
if (args[0] === 'status') {
|
||||
return { stdout: '', stderr: 'fatal: synthetic git failure', code: 1 }
|
||||
}
|
||||
return { stdout: '', stderr: 'unexpected call', code: 1 }
|
||||
}
|
||||
|
||||
await expect(service.getStatus('session-1')).resolves.toMatchObject({
|
||||
state: 'error',
|
||||
isGitRepo: true,
|
||||
})
|
||||
|
||||
const result = await service.getStatus('session-1')
|
||||
expect(result.state).toBe('error')
|
||||
expect(result.changedFiles).toEqual([])
|
||||
expect(result.error).toContain('Failed to read git status')
|
||||
expect(result.error).toContain('synthetic git failure')
|
||||
})
|
||||
|
||||
it('reads tracked diff stats in one bulk git call', async () => {
|
||||
const repoDir = await makeTempDir('workspace-service-bulk-stats-')
|
||||
await fs.writeFile(path.join(repoDir, 'a.txt'), 'a\n')
|
||||
await fs.writeFile(path.join(repoDir, 'b.txt'), 'b\n')
|
||||
const diffStatCalls: string[][] = []
|
||||
const service = new WorkspaceService(async () => repoDir) as WorkspaceService & {
|
||||
runGit: (workDir: string, args: string[]) => Promise<{
|
||||
stdout: string
|
||||
stderr: string
|
||||
code: number
|
||||
}>
|
||||
}
|
||||
|
||||
service.runGit = async (_workDir, args) => {
|
||||
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') {
|
||||
return { stdout: `${repoDir}\n`, stderr: '', code: 0 }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
|
||||
return { stdout: 'main\n', stderr: '', code: 0 }
|
||||
}
|
||||
if (args[0] === 'status') {
|
||||
return { stdout: ' M a.txt\0 M b.txt\0', stderr: '', code: 0 }
|
||||
}
|
||||
if (args[0] === 'diff' && args.includes('--numstat')) {
|
||||
diffStatCalls.push(args)
|
||||
return { stdout: '1\t0\ta.txt\n2\t3\tb.txt\n', stderr: '', code: 0 }
|
||||
}
|
||||
return { stdout: '', stderr: `unexpected git call: ${args.join(' ')}`, code: 1 }
|
||||
}
|
||||
|
||||
const result = await service.getStatus('session-1')
|
||||
|
||||
expect(result.state).toBe('ok')
|
||||
expect(diffStatCalls).toHaveLength(1)
|
||||
expect(result.changedFiles).toEqual([
|
||||
{ path: 'a.txt', oldPath: undefined, status: 'modified', additions: 1, deletions: 0 },
|
||||
{ path: 'b.txt', oldPath: undefined, status: 'modified', additions: 2, deletions: 3 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -18,12 +18,18 @@ import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { getSlashCommands } from '../ws/handler.js'
|
||||
import { getCommandName } from '../../commands.js'
|
||||
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
|
||||
import { WorkspaceService } from '../services/workspaceService.js'
|
||||
import {
|
||||
executeSessionRewind,
|
||||
previewSessionRewind,
|
||||
type RewindTargetSelector,
|
||||
} from '../services/sessionRewindService.js'
|
||||
|
||||
const workspaceService = new WorkspaceService(async (sessionId) => (
|
||||
conversationService.getSessionWorkDir(sessionId) ||
|
||||
await sessionService.getSessionWorkDir(sessionId)
|
||||
), async (sessionId) => sessionService.getSessionMessages(sessionId))
|
||||
|
||||
export async function handleSessionsApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
@ -109,6 +115,16 @@ export async function handleSessionsApi(
|
||||
return await getSessionInspection(sessionId, url)
|
||||
}
|
||||
|
||||
if (subResource === 'workspace') {
|
||||
if (req.method !== 'GET') {
|
||||
return Response.json(
|
||||
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
return await handleSessionWorkspaceRoute(sessionId, url, segments[4])
|
||||
}
|
||||
|
||||
// Route to conversations handler if sub-resource is 'chat'
|
||||
if (subResource === 'chat') {
|
||||
// This is handled by the conversations API, but in case the router
|
||||
@ -174,6 +190,36 @@ async function getSessionMessages(sessionId: string): Promise<Response> {
|
||||
return Response.json({ messages })
|
||||
}
|
||||
|
||||
async function handleSessionWorkspaceRoute(
|
||||
sessionId: string,
|
||||
url: URL,
|
||||
workspaceResource?: string,
|
||||
): Promise<Response> {
|
||||
await requireSessionWorkspace(sessionId)
|
||||
|
||||
switch (workspaceResource) {
|
||||
case 'status':
|
||||
return Response.json(await workspaceService.getStatus(sessionId))
|
||||
case 'tree':
|
||||
return await runWorkspaceRequest(() => workspaceService.readTree(
|
||||
sessionId,
|
||||
url.searchParams.get('path') || '',
|
||||
))
|
||||
case 'file':
|
||||
return await runWorkspaceRequest(() => workspaceService.readFile(
|
||||
sessionId,
|
||||
requireWorkspacePath(url, 'file'),
|
||||
))
|
||||
case 'diff':
|
||||
return await runWorkspaceDiffRequest(() => workspaceService.getDiff(
|
||||
sessionId,
|
||||
requireWorkspacePath(url, 'diff'),
|
||||
))
|
||||
default:
|
||||
throw ApiError.notFound(`Unknown workspace resource: ${workspaceResource || 'workspace'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(req: Request): Promise<Response> {
|
||||
let body: { workDir?: string }
|
||||
try {
|
||||
@ -190,6 +236,61 @@ async function createSession(req: Request): Promise<Response> {
|
||||
return Response.json(result, { status: 201 })
|
||||
}
|
||||
|
||||
async function requireSessionWorkspace(sessionId: string): Promise<string> {
|
||||
const workDir =
|
||||
conversationService.getSessionWorkDir(sessionId) ||
|
||||
await sessionService.getSessionWorkDir(sessionId)
|
||||
|
||||
if (!workDir) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
return workDir
|
||||
}
|
||||
|
||||
function requireWorkspacePath(url: URL, route: 'file' | 'diff'): string {
|
||||
const filePath = url.searchParams.get('path')
|
||||
if (!filePath) {
|
||||
throw ApiError.badRequest(`path query parameter is required for workspace ${route}`)
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function runWorkspaceRequest<T>(operation: () => Promise<T>): Promise<Response> {
|
||||
try {
|
||||
return Response.json(await operation())
|
||||
} catch (error) {
|
||||
if (isOutsideWorkspaceError(error)) {
|
||||
throw new ApiError(403, error.message, 'FORBIDDEN')
|
||||
}
|
||||
if (isSessionNotFoundError(error)) {
|
||||
throw ApiError.notFound(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function runWorkspaceDiffRequest<T extends { state?: string; error?: string }>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<Response> {
|
||||
const result = await runWorkspaceRequest(operation)
|
||||
const body = await result.clone().json() as T
|
||||
|
||||
if (body.state === 'error' && typeof body.error === 'string' && body.error.includes('outside workspace')) {
|
||||
throw new ApiError(403, body.error, 'FORBIDDEN')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isOutsideWorkspaceError(error: unknown): error is Error {
|
||||
return error instanceof Error && error.message.includes('outside workspace')
|
||||
}
|
||||
|
||||
function isSessionNotFoundError(error: unknown): error is Error {
|
||||
return error instanceof Error && error.message.startsWith('Session not found:')
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: string): Promise<Response> {
|
||||
await sessionService.deleteSession(sessionId)
|
||||
return Response.json({ ok: true })
|
||||
|
||||
@ -15,6 +15,7 @@ import { handleProxyRequest } from './proxy/handler.js'
|
||||
import { ProviderService } from './services/providerService.js'
|
||||
import { handleHahaOAuthCallback } from './api/haha-oauth.js'
|
||||
import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js'
|
||||
import { enableConfigs } from '../utils/config.js'
|
||||
|
||||
function readArgValue(flag: string): string | undefined {
|
||||
const args = process.argv.slice(2)
|
||||
@ -46,6 +47,7 @@ const PORT = SERVER_OPTIONS.port
|
||||
const HOST = SERVER_OPTIONS.host
|
||||
|
||||
export function startServer(port = PORT, host = HOST) {
|
||||
enableConfigs()
|
||||
ProviderService.setServerPort(port)
|
||||
const localConnectHost =
|
||||
host === '0.0.0.0' || host === '127.0.0.1' || host === 'localhost'
|
||||
|
||||
@ -215,6 +215,19 @@ function getBackupFileNameFirstVersion(
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getBackupFileNameForTarget(
|
||||
trackingPath: string,
|
||||
snapshots: FileHistorySnapshot[],
|
||||
targetSnapshot: FileHistorySnapshot,
|
||||
): string | null | undefined {
|
||||
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
|
||||
if (targetBackup && 'backupFileName' in targetBackup) {
|
||||
return targetBackup.backupFileName
|
||||
}
|
||||
|
||||
return getBackupFileNameFirstVersion(trackingPath, snapshots)
|
||||
}
|
||||
|
||||
async function readFileOrNull(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8')
|
||||
@ -223,6 +236,12 @@ async function readFileOrNull(filePath: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function countInsertedLines(content: string): number {
|
||||
return diffLines('', content).reduce((total, change) => (
|
||||
change.added ? total + (change.count || 0) : total
|
||||
), 0)
|
||||
}
|
||||
|
||||
async function hasFileChanged(
|
||||
filePath: string,
|
||||
backupFilePath: string,
|
||||
@ -265,7 +284,7 @@ async function restoreBackupFile(
|
||||
|
||||
async function buildCodePreview(
|
||||
sessionId: string,
|
||||
workDir: string,
|
||||
checkpointBaseDir: string,
|
||||
targetUserMessageId: string,
|
||||
): Promise<{
|
||||
snapshots: FileHistorySnapshot[] | null
|
||||
@ -305,21 +324,21 @@ async function buildCodePreview(
|
||||
let deletions = 0
|
||||
|
||||
for (const trackingPath of trackedPaths) {
|
||||
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
|
||||
const backupFileName =
|
||||
targetBackup?.backupFileName ??
|
||||
getBackupFileNameFirstVersion(trackingPath, snapshots)
|
||||
const backupFileName = getBackupFileNameForTarget(
|
||||
trackingPath,
|
||||
snapshots,
|
||||
targetSnapshot,
|
||||
)
|
||||
|
||||
if (backupFileName === undefined) continue
|
||||
|
||||
const absolutePath = expandTrackingPath(workDir, trackingPath)
|
||||
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
|
||||
|
||||
if (backupFileName === null) {
|
||||
try {
|
||||
await stat(absolutePath)
|
||||
const currentContent = await readFileOrNull(absolutePath)
|
||||
if (currentContent !== null) {
|
||||
filesChanged.push(absolutePath)
|
||||
} catch {
|
||||
// File is already absent.
|
||||
insertions += countInsertedLines(currentContent)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@ -365,9 +384,12 @@ export async function previewSessionRewind(
|
||||
: null) ||
|
||||
(await sessionService.getSessionWorkDir(sessionId)) ||
|
||||
process.cwd()
|
||||
const checkpointBaseDir =
|
||||
(await sessionService.getSessionMessageCwd(sessionId, target.targetUserMessageId)) ||
|
||||
workDir
|
||||
const { preview } = await buildCodePreview(
|
||||
sessionId,
|
||||
workDir,
|
||||
checkpointBaseDir,
|
||||
target.targetUserMessageId,
|
||||
)
|
||||
|
||||
@ -395,9 +417,12 @@ export async function executeSessionRewind(
|
||||
: null) ||
|
||||
(await sessionService.getSessionWorkDir(sessionId)) ||
|
||||
process.cwd()
|
||||
const checkpointBaseDir =
|
||||
(await sessionService.getSessionMessageCwd(sessionId, target.targetUserMessageId)) ||
|
||||
workDir
|
||||
const { snapshots, preview } = await buildCodePreview(
|
||||
sessionId,
|
||||
workDir,
|
||||
checkpointBaseDir,
|
||||
target.targetUserMessageId,
|
||||
)
|
||||
|
||||
@ -412,14 +437,15 @@ export async function executeSessionRewind(
|
||||
}
|
||||
|
||||
for (const trackingPath of collectTrackedPaths(snapshots)) {
|
||||
const targetBackup = targetSnapshot.trackedFileBackups[trackingPath]
|
||||
const backupFileName =
|
||||
targetBackup?.backupFileName ??
|
||||
getBackupFileNameFirstVersion(trackingPath, snapshots)
|
||||
const backupFileName = getBackupFileNameForTarget(
|
||||
trackingPath,
|
||||
snapshots,
|
||||
targetSnapshot,
|
||||
)
|
||||
|
||||
if (backupFileName === undefined) continue
|
||||
|
||||
const absolutePath = expandTrackingPath(workDir, trackingPath)
|
||||
const absolutePath = expandTrackingPath(checkpointBaseDir, trackingPath)
|
||||
|
||||
if (backupFileName === null) {
|
||||
try {
|
||||
|
||||
@ -1224,6 +1224,18 @@ export class SessionService {
|
||||
return this.resolveWorkDirFromEntries(entries, found.projectDir)
|
||||
}
|
||||
|
||||
async getSessionMessageCwd(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
): Promise<string | null> {
|
||||
const found = await this.findSessionFile(sessionId)
|
||||
if (!found) return null
|
||||
|
||||
const entries = await this.readJsonlFile(found.filePath)
|
||||
const entry = entries.find((candidate) => candidate.uuid === messageId)
|
||||
return typeof entry?.cwd === 'string' && entry.cwd.trim() ? entry.cwd : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect how a session should be launched.
|
||||
* Placeholder desktop-created sessions have zero transcript messages.
|
||||
|
||||
1490
src/server/services/workspaceService.ts
Normal file
1490
src/server/services/workspaceService.ts
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user