From 261a85ab76c7e62c8e30a00a2ee9e02cb7b3c5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 20 Apr 2026 13:36:19 +0800 Subject: [PATCH] Restore desktop project-skill discovery and align empty-session composer The desktop app previously lost project-level skills in two places: the settings browser only queried user skills, and the slash-command picker for a fresh session depended on CLI init state that does not exist before the first turn. This change makes the skills APIs cwd-aware, falls back to project skill loading before CLI init, syncs sidebar session deletion with open tabs, and aligns the empty-session composer styling so the pre-message session UI stays consistent. Constraint: Fresh desktop sessions need slash-command discovery before the CLI websocket emits system/init Constraint: Only repository code should be committed; build artifacts, installs, and /tmp smoke fixtures stay out of git Rejected: Rebuild slash-command listings from full getCommands() in the sessions API | introduced auth-gated command dependencies unrelated to local skill discovery Rejected: Unify EmptySession and ChatInput into one full component now | higher regression risk for normal chat interactions than a visual-only hero variant Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep pre-message skill discovery keyed to session workDir so project-level .claude/skills remain visible before the first turn Tested: bun test src/server/__tests__/sessions.test.ts; cd desktop && bun run test -- --run src/__tests__/skillsSettings.test.tsx src/components/layout/Sidebar.test.tsx src/__tests__/pages.test.tsx src/pages/ActiveSession.test.tsx; cd desktop && bun run lint Not-tested: Manual desktop smoke test against the rebuilt .app bundle in the local GUI --- desktop/src/__tests__/pages.test.tsx | 61 ++++++++- desktop/src/__tests__/skillsSettings.test.tsx | 45 +++++++ desktop/src/api/skills.ts | 18 ++- desktop/src/components/chat/ChatInput.tsx | 105 ++++++++++----- .../src/components/layout/Sidebar.test.tsx | 44 ++++++ desktop/src/components/layout/Sidebar.tsx | 6 +- desktop/src/components/skills/SkillList.tsx | 11 +- desktop/src/pages/ActiveSession.tsx | 2 +- desktop/src/pages/EmptySession.tsx | 6 +- desktop/src/stores/skillStore.ts | 12 +- src/server/__tests__/sessions.test.ts | 47 +++++++ src/server/__tests__/skills.test.ts | 125 ++++++++++++++++++ src/server/api/sessions.ts | 26 +++- src/server/api/skills.ts | 111 +++++++++++----- 14 files changed, 533 insertions(+), 86 deletions(-) create mode 100644 src/server/__tests__/skills.test.ts diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index b38dd120..831be9f7 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -13,6 +13,7 @@ import { ToolInspection } from '../pages/ToolInspection' import { Sidebar } from '../components/layout/Sidebar' import { UserMessage } from '../components/chat/UserMessage' import { useChatStore } from '../stores/chatStore' +import { useSessionStore } from '../stores/sessionStore' import { useTabStore } from '../stores/tabStore' /** @@ -62,13 +63,71 @@ describe('Content-only pages render without errors', () => { // With empty messages, the hero is shown expect(container.innerHTML).toContain('New session') // ChatInput has a textarea - expect(container.querySelector('textarea')).toBeInTheDocument() + const textarea = container.querySelector('textarea') + expect(textarea).toBeInTheDocument() + expect(textarea).toHaveAttribute('placeholder', 'Ask anything...') + expect(textarea).toHaveAttribute('rows', '2') expect(container.innerHTML).not.toContain('Preview') // Cleanup useTabStore.setState({ tabs: [], activeTabId: null }) useChatStore.setState({ sessions: {} }) }) + it('ActiveSession keeps the compact composer once messages exist', () => { + const SESSION_ID = 'test-active-session-with-messages' + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [{ + id: 'msg-1', + type: 'user_text', + content: 'hello', + timestamp: Date.now(), + }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 1, + projectPath: '', + workDir: null, + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + + render() + + const textarea = screen.getByPlaceholderText('Ask Claude to edit, debug or explain...') + expect(textarea).toHaveAttribute('rows', '1') + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + it('ActiveSession shows a single primary action button while a turn is active', () => { useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] }) useChatStore.setState({ diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index 1c522862..19e46203 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -5,6 +5,8 @@ import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' import { useSkillStore } from '../stores/skillStore' import { useSettingsStore } from '../stores/settingsStore' +import { useSessionStore } from '../stores/sessionStore' +import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore' vi.mock('../api/agents', () => ({ agentsApi: { @@ -60,6 +62,26 @@ describe('Settings > Skills tab', () => { beforeEach(() => { vi.clearAllMocks() useSettingsStore.setState({ locale: 'en' }) + useSessionStore.setState({ + sessions: [ + { + id: 'session-1', + title: 'Active session', + createdAt: '2026-04-20T00:00:00.000Z', + modifiedAt: '2026-04-20T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }, + ], + activeSessionId: 'session-1', + isLoading: false, + error: null, + selectedProjects: [], + availableProjects: ['/workspace/project'], + }) + useTabStore.setState({ tabs: [], activeTabId: null }) useSkillStore.setState({ skills: [], selectedSkill: null, @@ -106,6 +128,29 @@ describe('Settings > Skills tab', () => { expect(screen.getByText('Second skill description')).toBeInTheDocument() }) + it('uses the active session workDir even when settings tab is focused', () => { + const fetchSkills = vi.fn() + useSkillStore.setState({ + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills, + fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL, + clearSelection: MOCK_CLEAR_SELECTION, + }) + useTabStore.setState({ + activeTabId: SETTINGS_TAB_ID, + tabs: [{ sessionId: SETTINGS_TAB_ID, title: 'Settings', type: 'settings', status: 'idle' }], + }) + + render() + switchToSkillsTab() + + expect(fetchSkills).toHaveBeenCalledWith('/workspace/project') + }) + it('opens skill detail with metadata cards and parsed markdown body', () => { useSkillStore.setState({ selectedSkill: { diff --git a/desktop/src/api/skills.ts b/desktop/src/api/skills.ts index 3ad50b04..18b0c878 100644 --- a/desktop/src/api/skills.ts +++ b/desktop/src/api/skills.ts @@ -2,10 +2,18 @@ import { api } from './client' import type { SkillMeta, SkillDetail } from '../types/skill' export const skillsApi = { - list: () => api.get<{ skills: SkillMeta[] }>('/api/skills'), + list: (cwd?: string) => { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`) + }, - detail: (source: string, name: string) => - api.get<{ detail: SkillDetail }>( - `/api/skills/detail?source=${encodeURIComponent(source)}&name=${encodeURIComponent(name)}`, - ), + detail: (source: string, name: string, cwd?: string) => { + const query = new URLSearchParams({ + source, + name, + }) + if (cwd) query.set('cwd', cwd) + + return api.get<{ detail: SkillDetail }>(`/api/skills/detail?${query.toString()}`) + }, } diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 6f4cb8e1..b9cff77a 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -30,7 +30,11 @@ type Attachment = { data?: string } -export function ChatInput() { +type ChatInputProps = { + variant?: 'default' | 'hero' +} + +export function ChatInput({ variant = 'default' }: ChatInputProps) { const t = useTranslation() const [input, setInput] = useState('') const [attachments, setAttachments] = useState([]) @@ -62,6 +66,7 @@ export function ChatInput() { 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 useEffect(() => { textareaRef.current?.focus() @@ -393,11 +398,25 @@ export function ChatInput() { }) } + const composerPlaceholder = + isHeroComposer + ? t('empty.placeholder') + : isWorkspaceMissing + ? t('chat.placeholderMissing') + : isMemberSession + ? t('teams.memberPlaceholder') + : t('chat.placeholder') + + const addFilesLabel = isHeroComposer ? t('empty.addFiles') : t('chat.addFiles') + const slashCommandsLabel = isHeroComposer ? t('empty.slashCommands') : t('chat.slashCommands') + return ( -
-
+
+
event.preventDefault()} onDrop={handleDrop} > @@ -436,18 +455,16 @@ export function ChatInput() { ref={(el) => { slashItemRefs.current[index] = el }} onClick={() => selectSlashCommand(command.name)} onMouseEnter={() => setSlashSelectedIndex(index)} - className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left transition-colors ${ + className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${ index === slashSelectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]' }`} > -
- - /{command.name} - -
- + + /{command.name} + + {command.description} @@ -465,32 +482,50 @@ export function ChatInput() { )} {attachments.length > 0 && ( -
+ isHeroComposer ? ( -
+ ) : ( +
+ +
+ ) )} -