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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-20 14:55:39 +08:00
parent 921a352517
commit efcf7af988
5 changed files with 86 additions and 11 deletions

View File

@ -1,7 +1,7 @@
{
"name": "claude-code-desktop",
"private": true,
"version": "0.1.2",
"version": "0.1.3",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -1,6 +1,6 @@
[package]
name = "claude-code-desktop"
version = "0.1.2"
version = "0.1.3"
edition = "2021"
[lib]

View File

@ -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",

View File

@ -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(<EmptySession />)
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(<EmptySession />)
expect(container.querySelector('textarea')).toBeInTheDocument()

View File

@ -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<SlashCommandOption[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const plusMenuRef = useRef<HTMLDivElement>(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])