diff --git a/desktop/package.json b/desktop/package.json index 22c71ce7..25557b6b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "claude-code-desktop", "private": true, - "version": "0.1.2", + "version": "0.1.3", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 5d5c8b26..ef390f63 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claude-code-desktop" -version = "0.1.2" +version = "0.1.3" edition = "2021" [lib] diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 20269144..861fb7fe 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json", "productName": "Claude Code Haha", - "version": "0.1.2", + "version": "0.1.3", "identifier": "com.claude-code-haha.desktop", "build": { "frontendDist": "../dist", diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index bef36c86..ee04748b 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -1,7 +1,15 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import '@testing-library/jest-dom' +import { skillsApi } from '../api/skills' + +vi.mock('../api/skills', () => ({ + skillsApi: { + list: vi.fn(async () => ({ skills: [] })), + }, +})) + // Import all pages import { EmptySession } from '../pages/EmptySession' import { ActiveSession } from '../pages/ActiveSession' @@ -21,6 +29,38 @@ import { useTabStore } from '../stores/tabStore' * and contain key structural elements from the prototype. */ describe('Content-only pages render without errors', () => { + it('EmptySession slash picker includes dynamic skills before the first session starts', async () => { + vi.mocked(skillsApi.list).mockResolvedValueOnce({ + skills: [ + { + name: 'lark-mail', + description: 'Draft, send, and search emails', + source: 'user', + userInvocable: true, + contentLength: 120, + hasDirectory: true, + }, + { + name: 'internal-only', + description: 'Should stay hidden', + source: 'user', + userInvocable: false, + contentLength: 60, + hasDirectory: true, + }, + ], + }) + + render() + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '/', selectionStart: 1 }, + }) + + expect(await screen.findByText('/lark-mail')).toBeInTheDocument() + expect(screen.queryByText('/internal-only')).not.toBeInTheDocument() + }) + it('EmptySession renders mascot and composer', () => { const { container } = render() expect(container.querySelector('textarea')).toBeInTheDocument() diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index e54fe5a6..58bf0403 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { skillsApi } from '../api/skills' import { useTranslation } from '../i18n' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' @@ -13,9 +14,11 @@ import { FALLBACK_SLASH_COMMANDS, findSlashToken, insertSlashTrigger, + mergeSlashCommands, replaceSlashCommand, } from '../components/chat/composerUtils' import type { AttachmentRef } from '../types/chat' +import type { SlashCommandOption } from '../components/chat/composerUtils' type Attachment = { id: string @@ -39,6 +42,7 @@ export function EmptySession() { const [atCursorPos, setAtCursorPos] = useState(-1) const [slashFilter, setSlashFilter] = useState('') const [slashSelectedIndex, setSlashSelectedIndex] = useState(0) + const [slashCommands, setSlashCommands] = useState([]) const textareaRef = useRef(null) const fileInputRef = useRef(null) const plusMenuRef = useRef(null) @@ -99,19 +103,50 @@ export function EmptySession() { return () => document.removeEventListener('mousedown', handleClick) }, [fileSearchOpen]) - const filteredCommands = FALLBACK_SLASH_COMMANDS.filter((command) => { - if (!slashFilter) return true + useEffect(() => { + let cancelled = false + + skillsApi.list(workDir || undefined) + .then(({ skills }) => { + if (cancelled) return + setSlashCommands( + skills + .filter((skill) => skill.userInvocable) + .map((skill) => ({ + name: skill.name, + description: skill.description, + })), + ) + }) + .catch(() => { + if (!cancelled) { + setSlashCommands([]) + } + }) + + return () => { + cancelled = true + } + }, [workDir]) + + const filteredCommands = useMemo(() => { + const source = mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS) + if (!slashFilter) return source const lower = slashFilter.toLowerCase() - return command.name.toLowerCase().includes(lower) || command.description.toLowerCase().includes(lower) - }) + return source.filter((command) => ( + command.name.toLowerCase().includes(lower) || + command.description.toLowerCase().includes(lower) + )) + }, [slashCommands, slashFilter]) useEffect(() => { setSlashSelectedIndex(0) }, [slashFilter]) useEffect(() => { - if (slashMenuOpen && slashItemRefs.current[slashSelectedIndex]) { - slashItemRefs.current[slashSelectedIndex]?.scrollIntoView({ block: 'nearest' }) + const activeItem = slashMenuOpen ? slashItemRefs.current[slashSelectedIndex] : null + if (typeof activeItem?.scrollIntoView === 'function') { + activeItem.scrollIntoView({ block: 'nearest' }) } }, [slashMenuOpen, slashSelectedIndex])