From efcf7af988ae8eee0118a07c1d8fa5787d421e86 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 14:55:39 +0800 Subject: [PATCH] Cut desktop release 0.1.3 with empty-session slash discovery The empty-session composer now loads user and project slash commands before the first turn instead of falling back to the built-in list, which keeps the packaged desktop app aligned with the web UI. This commit also bumps the desktop release version metadata to 0.1.3 so the release workflow publishes the correct artifacts. Constraint: Desktop release automation is triggered by semantic version tags and reads the version from desktop release metadata Rejected: Tagging v0.1.3 without a release commit | would publish stale 0.1.2 metadata and miss the slash-command fix Confidence: high Scope-risk: narrow Directive: Keep EmptySession slash-command loading aligned with ChatInput so packaged and web entry flows do not diverge again Tested: cd desktop && bun run test -- --run src/__tests__/pages.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: Full GitHub Actions release-desktop workflow after tag push --- desktop/package.json | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- desktop/src/__tests__/pages.test.tsx | 42 +++++++++++++++++++++++- desktop/src/pages/EmptySession.tsx | 49 ++++++++++++++++++++++++---- 5 files changed, 86 insertions(+), 11 deletions(-) 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])