mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: make desktop slash commands behave like the CLI
Desktop had no reliable handling for common local slash commands, so commands like /clear, /help, /context, /cost, and /compact either looked unresponsive or lost their CLI-specific side effects. This routes desktop commands by behavior: local panels stay local, stateful clear resets the session transcript, and CLI-local outputs are rendered in chat. Constraint: Preserve existing CLI semantics for prompt, local, local-jsx, and compact slash commands Rejected: Send every slash command through the normal chat path | local commands need desktop UI or session state side effects Confidence: high Scope-risk: moderate Directive: Keep hidden aliases such as /plugins out of the visible command list unless they become canonical CLI commands Tested: cd desktop && bun run test src/components/chat/composerUtils.test.ts src/stores/chatStore.test.ts --run Tested: cd desktop && bun run test src/__tests__/pages.test.tsx --run -t "ActiveSession routes /plugin|ActiveSession routes /help|EmptySession slash picker includes dynamic skills" Tested: bun test src/server/__tests__/conversations.test.ts Tested: cd desktop && bun run lint Tested: agent-browser E2E for /help, /plugin, /plugins, /cost, /clear, /context, /compact, /mcp, and normal text input Not-tested: Root tsc project because it currently includes generated Tauri target assets and extracted native files
This commit is contained in:
parent
08e00e7f7c
commit
caa164b62c
@ -85,8 +85,10 @@ describe('Content-only pages render without errors', () => {
|
||||
expect(await screen.findByText('/lark-mail')).toBeInTheDocument()
|
||||
expect(screen.getByText('/mcp')).toBeInTheDocument()
|
||||
expect(screen.getByText('/skills')).toBeInTheDocument()
|
||||
expect(screen.getByText('/help')).toBeInTheDocument()
|
||||
expect(screen.getByText('/plugin')).toBeInTheDocument()
|
||||
expect(screen.getByText('/plugins')).toBeInTheDocument()
|
||||
expect(screen.getByText('/context')).toBeInTheDocument()
|
||||
expect(screen.queryByText('/plugins')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('/internal-only')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -294,7 +296,7 @@ describe('Content-only pages render without errors', () => {
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Ask anything...')
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '/mcp', selectionStart: 4 } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
@ -366,7 +368,7 @@ describe('Content-only pages render without errors', () => {
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Ask anything...')
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '/skills', selectionStart: 7 } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
@ -424,7 +426,7 @@ describe('Content-only pages render without errors', () => {
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Ask anything...')
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '/plugin', selectionStart: 7 } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
@ -437,6 +439,65 @@ describe('Content-only pages render without errors', () => {
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
it('ActiveSession routes /help to the local command panel', () => {
|
||||
const SESSION_ID = 'help-panel-session'
|
||||
const sendMessage = vi.fn()
|
||||
useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID })
|
||||
useSessionStore.setState({
|
||||
sessions: [{
|
||||
id: SESSION_ID,
|
||||
title: 'Test',
|
||||
createdAt: '2026-04-10T00:00:00.000Z',
|
||||
modifiedAt: '2026-04-10T00:00:00.000Z',
|
||||
messageCount: 0,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
}],
|
||||
activeSessionId: SESSION_ID,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[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: [{ name: 'cost', description: 'Show token usage and costs' }],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
sendMessage,
|
||||
})
|
||||
|
||||
render(<ActiveSession />)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '/help', selectionStart: 5 } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Slash commands')).toBeInTheDocument()
|
||||
expect(screen.getByText('/clear')).toBeInTheDocument()
|
||||
expect(screen.getByText('/cost')).toBeInTheDocument()
|
||||
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
})
|
||||
|
||||
it('AgentTeams renders team strip and members', () => {
|
||||
const { container } = render(<AgentTeams />)
|
||||
expect(container.innerHTML).toContain('Architect')
|
||||
|
||||
@ -197,15 +197,20 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [fileSearchOpen])
|
||||
|
||||
const allSlashCommands = useMemo(
|
||||
() => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS),
|
||||
[slashCommands],
|
||||
)
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const source = mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS)
|
||||
const source = allSlashCommands
|
||||
if (!slashFilter) return source
|
||||
const lower = slashFilter.toLowerCase()
|
||||
return source.filter((command) => (
|
||||
command.name.toLowerCase().includes(lower) ||
|
||||
command.description.toLowerCase().includes(lower)
|
||||
))
|
||||
}, [slashCommands, slashFilter])
|
||||
}, [allSlashCommands, slashFilter])
|
||||
|
||||
const exactSlashCommand = useMemo(() => {
|
||||
const normalized = slashFilter.trim().toLowerCase()
|
||||
@ -541,6 +546,7 @@ export function ChatInput({ variant = 'default' }: ChatInputProps) {
|
||||
<LocalSlashCommandPanel
|
||||
command={localSlashPanel}
|
||||
cwd={resolvedWorkDir}
|
||||
commands={allSlashCommands}
|
||||
onClose={() => setLocalSlashPanel(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -8,12 +8,14 @@ import { useMcpStore } from '../../stores/mcpStore'
|
||||
import { useSkillStore } from '../../stores/skillStore'
|
||||
import type { McpServerRecord } from '../../types/mcp'
|
||||
import type { SkillMeta } from '../../types/skill'
|
||||
import type { SlashCommandOption } from './composerUtils'
|
||||
|
||||
export type LocalSlashCommandName = 'mcp' | 'skills'
|
||||
export type LocalSlashCommandName = 'mcp' | 'skills' | 'help'
|
||||
|
||||
type Props = {
|
||||
command: LocalSlashCommandName
|
||||
cwd?: string
|
||||
commands?: SlashCommandOption[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@ -283,7 +285,85 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function LocalSlashCommandPanel({ command, cwd, onClose }: Props) {
|
||||
if (command === 'mcp') return <McpPanel cwd={cwd} onClose={onClose} />
|
||||
return <SkillsPanel cwd={cwd} onClose={onClose} />
|
||||
const COMMAND_GROUPS = [
|
||||
{
|
||||
title: 'Context',
|
||||
names: ['clear', 'compact', 'context', 'cost'],
|
||||
},
|
||||
{
|
||||
title: 'Project',
|
||||
names: ['init', 'review', 'commit', 'pr'],
|
||||
},
|
||||
{
|
||||
title: 'Desktop',
|
||||
names: ['mcp', 'skills', 'plugin', 'help'],
|
||||
},
|
||||
]
|
||||
|
||||
function HelpPanel({
|
||||
commands,
|
||||
onClose,
|
||||
}: {
|
||||
commands?: SlashCommandOption[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const commandMap = useMemo(() => {
|
||||
const map = new Map<string, SlashCommandOption>()
|
||||
for (const command of commands ?? []) {
|
||||
map.set(command.name, command)
|
||||
}
|
||||
return map
|
||||
}, [commands])
|
||||
|
||||
const groupedNames = new Set(COMMAND_GROUPS.flatMap((group) => group.names))
|
||||
const otherCommands = (commands ?? [])
|
||||
.filter((command) => !groupedNames.has(command.name))
|
||||
.slice(0, 12)
|
||||
|
||||
const renderCommand = (command: SlashCommandOption) => (
|
||||
<div key={command.name} className="flex min-w-0 items-start gap-3 border-t border-[var(--color-border)] px-4 py-3 first:border-t-0">
|
||||
<div className="shrink-0 font-mono text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</div>
|
||||
<div className="min-w-0 flex-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{command.description}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Slash commands"
|
||||
subtitle="Run agent workflows, inspect session state, or open desktop panels."
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{COMMAND_GROUPS.map((group) => {
|
||||
const entries = group.names
|
||||
.map((name) => commandMap.get(name))
|
||||
.filter((command): command is SlashCommandOption => Boolean(command))
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<section key={group.title}>
|
||||
<div className="mb-2 text-sm font-semibold text-[var(--color-text-primary)]">{group.title}</div>
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{entries.map(renderCommand)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
{otherCommands.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-2 text-sm font-semibold text-[var(--color-text-primary)]">More</div>
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{otherCommands.map(renderCommand)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</PanelShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function LocalSlashCommandPanel({ command, cwd, commands, onClose }: Props) {
|
||||
if (command === 'mcp') return <McpPanel cwd={cwd} onClose={onClose} />
|
||||
if (command === 'skills') return <SkillsPanel cwd={cwd} onClose={onClose} />
|
||||
return <HelpPanel commands={commands} onClose={onClose} />
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
insertSlashTrigger,
|
||||
mergeSlashCommands,
|
||||
replaceSlashCommand,
|
||||
resolveSlashUiAction,
|
||||
} from './composerUtils'
|
||||
|
||||
describe('composerUtils', () => {
|
||||
@ -35,8 +36,9 @@ describe('composerUtils', () => {
|
||||
]),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ name: 'help', description: 'Show available commands' },
|
||||
{ name: 'help', description: 'Show available desktop and agent commands' },
|
||||
{ name: 'clear', description: 'Clear conversation history' },
|
||||
{ name: 'context', description: 'Show current context usage' },
|
||||
]),
|
||||
)
|
||||
})
|
||||
@ -52,4 +54,10 @@ describe('composerUtils', () => {
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves hidden settings aliases without displaying duplicate fallback rows', () => {
|
||||
expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' })
|
||||
expect(mergeSlashCommands([]).map((command) => command.name)).toContain('plugin')
|
||||
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('plugins')
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,11 +3,15 @@ import type { SettingsTab } from '../../stores/uiStore'
|
||||
export const PANEL_SLASH_COMMANDS = [
|
||||
{ name: 'mcp', description: 'Open available MCP tools for the current chat context' },
|
||||
{ name: 'skills', description: 'Browse user-invocable skills for the current chat context' },
|
||||
{ name: 'help', description: 'Show available desktop and agent commands' },
|
||||
] as const
|
||||
|
||||
export const SETTINGS_SLASH_COMMANDS = [
|
||||
{ name: 'plugin', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const },
|
||||
{ name: 'plugins', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const },
|
||||
] as const
|
||||
|
||||
export const SLASH_COMMAND_ALIASES = [
|
||||
{ name: 'plugins', target: 'plugin' },
|
||||
] as const
|
||||
|
||||
export const FALLBACK_SLASH_COMMANDS = [
|
||||
@ -15,13 +19,13 @@ export const FALLBACK_SLASH_COMMANDS = [
|
||||
...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })),
|
||||
{ name: 'compact', description: 'Compact conversation context' },
|
||||
{ name: 'clear', description: 'Clear conversation history' },
|
||||
{ name: 'help', description: 'Show available commands' },
|
||||
{ name: 'review', description: 'Review code changes' },
|
||||
{ name: 'commit', description: 'Create a git commit' },
|
||||
{ name: 'pr', description: 'Create a pull request' },
|
||||
{ name: 'init', description: 'Initialize project CLAUDE.md' },
|
||||
{ name: 'bug', description: 'Report a bug' },
|
||||
{ name: 'config', description: 'Open configuration' },
|
||||
{ name: 'context', description: 'Show current context usage' },
|
||||
{ name: 'cost', description: 'Show token usage and costs' },
|
||||
{ name: 'doctor', description: 'Diagnose installation issues' },
|
||||
{ name: 'login', description: 'Switch Anthropic accounts' },
|
||||
@ -50,12 +54,13 @@ export type SlashUiAction =
|
||||
}
|
||||
|
||||
export function resolveSlashUiAction(value: string): SlashUiAction | null {
|
||||
const panelCommand = PANEL_SLASH_COMMANDS.find((command) => command.name === value)
|
||||
const normalizedValue = SLASH_COMMAND_ALIASES.find((alias) => alias.name === value)?.target ?? value
|
||||
const panelCommand = PANEL_SLASH_COMMANDS.find((command) => command.name === normalizedValue)
|
||||
if (panelCommand) {
|
||||
return { type: 'panel', command: panelCommand.name }
|
||||
}
|
||||
|
||||
const settingsCommand = SETTINGS_SLASH_COMMANDS.find((command) => command.name === value)
|
||||
const settingsCommand = SETTINGS_SLASH_COMMANDS.find((command) => command.name === normalizedValue)
|
||||
if (settingsCommand) {
|
||||
return { type: 'settings', tab: settingsCommand.tab }
|
||||
}
|
||||
|
||||
@ -153,15 +153,20 @@ export function EmptySession() {
|
||||
}
|
||||
}, [workDir])
|
||||
|
||||
const allSlashCommands = useMemo(
|
||||
() => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS),
|
||||
[slashCommands],
|
||||
)
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const source = mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS)
|
||||
const source = allSlashCommands
|
||||
if (!slashFilter) return source
|
||||
const lower = slashFilter.toLowerCase()
|
||||
return source.filter((command) => (
|
||||
command.name.toLowerCase().includes(lower) ||
|
||||
command.description.toLowerCase().includes(lower)
|
||||
))
|
||||
}, [slashCommands, slashFilter])
|
||||
}, [allSlashCommands, slashFilter])
|
||||
|
||||
const exactSlashCommand = useMemo(() => {
|
||||
const normalized = slashFilter.trim().toLowerCase()
|
||||
@ -501,6 +506,7 @@ export function EmptySession() {
|
||||
<LocalSlashCommandPanel
|
||||
command={localSlashPanel}
|
||||
cwd={workDir || undefined}
|
||||
commands={allSlashCommands}
|
||||
onClose={() => setLocalSlashPanel(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -481,6 +481,82 @@ describe('chatStore history mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('clears local desktop chat state when the server confirms /clear', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: {
|
||||
messages: [
|
||||
{ id: 'u1', type: 'user_text', content: '/clear', timestamp: Date.now() },
|
||||
{ id: 'a1', type: 'assistant_text', content: 'old context', timestamp: Date.now() },
|
||||
],
|
||||
chatState: 'thinking',
|
||||
connectionState: 'connected',
|
||||
streamingText: 'pending',
|
||||
streamingToolInput: 'tool',
|
||||
activeToolUseId: 'tool-1',
|
||||
activeToolName: 'Read',
|
||||
activeThinkingId: 'thinking-1',
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 12, output_tokens: 34 },
|
||||
elapsedSeconds: 5,
|
||||
statusVerb: 'Thinking',
|
||||
slashCommands: [],
|
||||
agentTaskNotifications: {},
|
||||
elapsedTimer: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'system_notification',
|
||||
subtype: 'session_cleared',
|
||||
message: 'Conversation cleared',
|
||||
})
|
||||
|
||||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||||
expect(session?.messages).toEqual([])
|
||||
expect(session?.streamingText).toBe('')
|
||||
expect(session?.chatState).toBe('idle')
|
||||
expect(session?.tokenUsage).toEqual({ input_tokens: 0, output_tokens: 0 })
|
||||
expect(clearTasksMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders compact boundary notifications as system messages', () => {
|
||||
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().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'system_notification',
|
||||
subtype: 'compact_boundary',
|
||||
message: 'Context compacted',
|
||||
})
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||
{ type: 'system', content: 'Context compacted' },
|
||||
])
|
||||
})
|
||||
|
||||
it('flushes the previous assistant draft before starting a new user turn', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
|
||||
@ -768,6 +768,44 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) {
|
||||
update(() => ({ slashCommands: msg.data as Array<{ name: string; description: string }> }))
|
||||
}
|
||||
if (msg.subtype === 'session_cleared') {
|
||||
const session = get().sessions[sessionId]
|
||||
if (session?.elapsedTimer) clearInterval(session.elapsedTimer)
|
||||
update(() => ({
|
||||
messages: [],
|
||||
streamingText: '',
|
||||
streamingToolInput: '',
|
||||
activeToolUseId: null,
|
||||
activeToolName: null,
|
||||
activeThinkingId: null,
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
chatState: 'idle',
|
||||
elapsedTimer: null,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
}))
|
||||
useCLITaskStore.getState().clearTasks()
|
||||
useSessionStore.getState().updateSessionTitle(sessionId, 'New Session')
|
||||
useTabStore.getState().updateTabTitle(sessionId, 'New Session')
|
||||
useTabStore.getState().updateTabStatus(sessionId, 'idle')
|
||||
}
|
||||
if (msg.subtype === 'compact_boundary') {
|
||||
update((session) => ({
|
||||
messages: [
|
||||
...session.messages,
|
||||
{
|
||||
id: nextId(),
|
||||
type: 'system',
|
||||
content: typeof msg.message === 'string' && msg.message.trim()
|
||||
? msg.message
|
||||
: 'Context compacted',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') {
|
||||
const data = msg.data as Record<string, unknown>
|
||||
const toolUseId =
|
||||
|
||||
@ -518,6 +518,32 @@ describe('WebSocket Chat Integration', () => {
|
||||
expect(secondTurn.some((m) => m.type === 'error')).toBe(false)
|
||||
})
|
||||
|
||||
it('should clear a desktop session without sending /clear to the CLI turn loop', async () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workDir: process.cwd() }),
|
||||
})
|
||||
expect(createRes.status).toBe(201)
|
||||
const { sessionId } = await createRes.json() as { sessionId: string }
|
||||
|
||||
const firstTurn = await runTurn(sessionId, 'message before clear')
|
||||
expect(firstTurn.some((m) => m.type === 'message_complete')).toBe(true)
|
||||
|
||||
const clearTurn = await runTurn(sessionId, '/clear')
|
||||
expect(
|
||||
clearTurn.some(
|
||||
(m) => m.type === 'system_notification' && m.subtype === 'session_cleared',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(clearTurn.some((m) => m.type === 'content_delta')).toBe(false)
|
||||
|
||||
const messagesRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/messages`)
|
||||
expect(messagesRes.status).toBe(200)
|
||||
const body = await messagesRes.json() as { messages: unknown[] }
|
||||
expect(body.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('should prewarm the CLI before the first user turn and reuse that process', async () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
|
||||
@ -789,6 +789,50 @@ export class SessionService {
|
||||
await fs.unlink(found.filePath)
|
||||
}
|
||||
|
||||
async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise<void> {
|
||||
let found = await this.findSessionFile(sessionId)
|
||||
if (!found && fallbackWorkDir) {
|
||||
const absWorkDir = path.resolve(fallbackWorkDir)
|
||||
const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir))
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
found = {
|
||||
filePath: path.join(dirPath, `${sessionId}.jsonl`),
|
||||
projectDir: this.sanitizePath(absWorkDir),
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
const entries = await this.readJsonlFile(found.filePath)
|
||||
const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || fallbackWorkDir || process.cwd()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const initialEntry = {
|
||||
type: 'file-history-snapshot',
|
||||
messageId: crypto.randomUUID(),
|
||||
snapshot: {
|
||||
messageId: crypto.randomUUID(),
|
||||
trackedFileBackups: {},
|
||||
timestamp: now,
|
||||
},
|
||||
isSnapshotUpdate: false,
|
||||
}
|
||||
|
||||
const metaEntry = {
|
||||
type: 'session-meta',
|
||||
isMeta: true,
|
||||
workDir,
|
||||
timestamp: now,
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
found.filePath,
|
||||
`${JSON.stringify(initialEntry)}\n${JSON.stringify(metaEntry)}\n`,
|
||||
'utf-8',
|
||||
)
|
||||
}
|
||||
|
||||
async appendSessionMetadata(
|
||||
sessionId: string,
|
||||
metadata: { workDir: string; customTitle?: string | null }
|
||||
|
||||
@ -18,6 +18,11 @@ import { sessionService } from '../services/sessionService.js'
|
||||
import { SettingsService } from '../services/settingsService.js'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js'
|
||||
import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
|
||||
import {
|
||||
LOCAL_COMMAND_STDERR_TAG,
|
||||
LOCAL_COMMAND_STDOUT_TAG,
|
||||
} from '../../constants/xml.js'
|
||||
|
||||
const settingsService = new SettingsService()
|
||||
const providerService = new ProviderService()
|
||||
@ -214,6 +219,11 @@ async function handleUserMessage(
|
||||
sessionStopRequested.delete(sessionId)
|
||||
clearPrewarmState(sessionId)
|
||||
|
||||
if (getDesktopSlashCommandName(message.content) === 'clear') {
|
||||
await handleDesktopClearCommand(ws)
|
||||
return
|
||||
}
|
||||
|
||||
// Send thinking status
|
||||
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
|
||||
|
||||
@ -292,6 +302,42 @@ async function handleUserMessage(
|
||||
userMessageSent = true
|
||||
}
|
||||
|
||||
async function handleDesktopClearCommand(
|
||||
ws: ServerWebSocket<WebSocketData>,
|
||||
) {
|
||||
const { sessionId } = ws.data
|
||||
|
||||
const workDir = conversationService.getSessionWorkDir(sessionId)
|
||||
conversationService.stopSession(sessionId)
|
||||
conversationService.clearOutputCallbacks(sessionId)
|
||||
sessionSlashCommands.delete(sessionId)
|
||||
sessionTitleState.delete(sessionId)
|
||||
cleanupStreamState(sessionId)
|
||||
|
||||
try {
|
||||
await sessionService.clearSessionTranscript(sessionId, workDir || undefined)
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err)
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
message: errMsg,
|
||||
code: 'SESSION_CLEAR_FAILED',
|
||||
})
|
||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||
return
|
||||
}
|
||||
|
||||
sendMessage(ws, {
|
||||
type: 'system_notification',
|
||||
subtype: 'session_cleared',
|
||||
message: 'Conversation cleared',
|
||||
})
|
||||
sendMessage(ws, {
|
||||
type: 'message_complete',
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
})
|
||||
}
|
||||
|
||||
function handlePrewarmSession(ws: ServerWebSocket<WebSocketData>) {
|
||||
const { sessionId } = ws.data
|
||||
if (conversationService.hasSession(sessionId) || sessionStartupPromises.has(sessionId)) {
|
||||
@ -772,6 +818,14 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
|
||||
// CLI 发送 type:'user' 消息,其中 content 包含 tool_result 块
|
||||
const messages: ServerMessage[] = []
|
||||
|
||||
const localCommandOutput = extractLocalCommandOutput(
|
||||
cliMsg.message?.content,
|
||||
)
|
||||
if (localCommandOutput) {
|
||||
messages.push({ type: 'content_start', blockType: 'text' })
|
||||
messages.push({ type: 'content_delta', text: localCommandOutput })
|
||||
}
|
||||
|
||||
if (cliMsg.message?.content && Array.isArray(cliMsg.message.content)) {
|
||||
for (const block of cliMsg.message.content) {
|
||||
if (block.type === 'tool_result') {
|
||||
@ -1022,6 +1076,14 @@ function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
|
||||
data: cliMsg,
|
||||
}]
|
||||
}
|
||||
if (subtype === 'compact_boundary') {
|
||||
return [{
|
||||
type: 'system_notification',
|
||||
subtype: 'compact_boundary',
|
||||
message: 'Context compacted',
|
||||
data: cliMsg.compact_metadata ?? cliMsg,
|
||||
}]
|
||||
}
|
||||
// 其他 system 消息
|
||||
return []
|
||||
}
|
||||
@ -1045,6 +1107,38 @@ function sendError(ws: ServerWebSocket<WebSocketData>, message: string, code: st
|
||||
sendMessage(ws, { type: 'error', message, code })
|
||||
}
|
||||
|
||||
function getDesktopSlashCommandName(content: string): string | null {
|
||||
const parsed = parseSlashCommand(content.trim())
|
||||
return parsed?.commandName ?? null
|
||||
}
|
||||
|
||||
function extractLocalCommandOutput(content: unknown): string | null {
|
||||
const raw = typeof content === 'string'
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? content
|
||||
.flatMap((block) => {
|
||||
if (!block || typeof block !== 'object') return []
|
||||
const text = (block as { text?: unknown }).text
|
||||
return typeof text === 'string' ? [text] : []
|
||||
})
|
||||
.join('\n')
|
||||
: ''
|
||||
|
||||
if (!raw) return null
|
||||
|
||||
const stdout = extractTaggedContent(raw, LOCAL_COMMAND_STDOUT_TAG)
|
||||
if (stdout !== null) return stdout
|
||||
|
||||
const stderr = extractTaggedContent(raw, LOCAL_COMMAND_STDERR_TAG)
|
||||
return stderr
|
||||
}
|
||||
|
||||
function extractTaggedContent(raw: string, tag: string): string | null {
|
||||
const match = raw.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))
|
||||
return match?.[1]?.trim() ?? null
|
||||
}
|
||||
|
||||
function rebindSessionOutput(
|
||||
sessionId: string,
|
||||
ws: ServerWebSocket<WebSocketData>,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user