mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
This commit is contained in:
parent
76823047f4
commit
261a85ab76
@ -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(<ActiveSession />)
|
||||
|
||||
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({
|
||||
|
||||
@ -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(<Settings />)
|
||||
switchToSkillsTab()
|
||||
|
||||
expect(fetchSkills).toHaveBeenCalledWith('/workspace/project')
|
||||
})
|
||||
|
||||
it('opens skill detail with metadata cards and parsed markdown body', () => {
|
||||
useSkillStore.setState({
|
||||
selectedSkill: {
|
||||
|
||||
@ -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()}`)
|
||||
},
|
||||
}
|
||||
|
||||
@ -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<Attachment[]>([])
|
||||
@ -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 (
|
||||
<div className="bg-[var(--color-surface)] px-4 py-4">
|
||||
<div className="mx-auto max-w-[860px]">
|
||||
<div className={isHeroComposer ? 'bg-[var(--color-surface)] px-8 pb-4' : 'bg-[var(--color-surface)] px-4 py-4'}>
|
||||
<div className={isHeroComposer ? 'mx-auto flex w-full max-w-3xl flex-col gap-2' : 'mx-auto max-w-[860px]'}>
|
||||
<div
|
||||
className="glass-panel relative rounded-xl p-4 transition-colors"
|
||||
className={isHeroComposer
|
||||
? 'glass-panel relative flex flex-col gap-3 rounded-xl p-4 transition-colors'
|
||||
: 'glass-panel relative rounded-xl p-4 transition-colors'}
|
||||
onDragOver={(event) => 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)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
/{command.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate text-right text-xs text-[var(--color-text-tertiary)]">
|
||||
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
/{command.name}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">
|
||||
{command.description}
|
||||
</span>
|
||||
</button>
|
||||
@ -465,32 +482,50 @@ export function ChatInput() {
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-3 pt-3">
|
||||
isHeroComposer ? (
|
||||
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 pt-3">
|
||||
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
onPaste={handlePaste}
|
||||
placeholder={
|
||||
isWorkspaceMissing
|
||||
? t('chat.placeholderMissing')
|
||||
: isMemberSession
|
||||
? t('teams.memberPlaceholder')
|
||||
: t('chat.placeholder')
|
||||
}
|
||||
disabled={isWorkspaceMissing}
|
||||
rows={1}
|
||||
className="w-full resize-none bg-transparent py-2 pb-12 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
|
||||
/>
|
||||
{isHeroComposer ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
onPaste={handlePaste}
|
||||
placeholder={composerPlaceholder}
|
||||
disabled={isWorkspaceMissing}
|
||||
rows={2}
|
||||
className="flex-1 resize-none border-none bg-transparent py-2 leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
onPaste={handlePaste}
|
||||
placeholder={composerPlaceholder}
|
||||
disabled={isWorkspaceMissing}
|
||||
rows={1}
|
||||
className="w-full resize-none bg-transparent py-2 pb-12 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] px-3 py-3">
|
||||
<div className={isHeroComposer
|
||||
? 'flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3'
|
||||
: 'absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[var(--color-border-separator)] px-3 py-3'}>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isMemberSession && (
|
||||
<>
|
||||
@ -513,14 +548,14 @@ export function ChatInput() {
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.addFiles')}</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{addFilesLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={insertSlashCommand}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<span className="w-[24px] text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.slashCommands')}</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)]">{slashCommandsLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -39,14 +39,18 @@ import { useUIStore } from '../../stores/uiStore'
|
||||
|
||||
describe('Sidebar', () => {
|
||||
const connectToSession = vi.fn()
|
||||
const disconnectSession = vi.fn()
|
||||
const fetchSessions = vi.fn()
|
||||
const createSession = vi.fn()
|
||||
const deleteSession = vi.fn()
|
||||
const addToast = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
connectToSession.mockReset()
|
||||
disconnectSession.mockReset()
|
||||
fetchSessions.mockReset()
|
||||
createSession.mockReset()
|
||||
deleteSession.mockReset()
|
||||
addToast.mockReset()
|
||||
|
||||
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||
@ -59,9 +63,11 @@ describe('Sidebar', () => {
|
||||
availableProjects: [],
|
||||
fetchSessions,
|
||||
createSession,
|
||||
deleteSession,
|
||||
})
|
||||
useChatStore.setState({
|
||||
connectToSession,
|
||||
disconnectSession,
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
useUIStore.setState({
|
||||
addToast,
|
||||
@ -111,4 +117,42 @@ describe('Sidebar', () => {
|
||||
|
||||
expect(useTabStore.getState().tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('removes the matching tab when deleting a session from the sidebar', async () => {
|
||||
deleteSession.mockResolvedValue(undefined)
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
{
|
||||
id: 'session-1',
|
||||
title: 'Open Session',
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
messageCount: 1,
|
||||
projectPath: '/workspace/project',
|
||||
workDir: '/workspace/project',
|
||||
workDirExists: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
useTabStore.setState({
|
||||
tabs: [{ sessionId: 'session-1', title: 'Open Session', type: 'session', status: 'idle' }],
|
||||
activeTabId: 'session-1',
|
||||
})
|
||||
|
||||
render(<Sidebar />)
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole('button', { name: /Open Session/ }))
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteSession).toHaveBeenCalledWith('session-1')
|
||||
expect(disconnectSession).toHaveBeenCalledWith('session-1')
|
||||
})
|
||||
|
||||
expect(useTabStore.getState().tabs).toEqual([])
|
||||
expect(useTabStore.getState().activeTabId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -23,6 +23,8 @@ export function Sidebar() {
|
||||
const renameSession = useSessionStore((s) => s.renameSession)
|
||||
const addToast = useUIStore((s) => s.addToast)
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const closeTab = useTabStore((s) => s.closeTab)
|
||||
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||
@ -64,7 +66,9 @@ export function Sidebar() {
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
setContextMenu(null)
|
||||
await deleteSession(id)
|
||||
}, [deleteSession])
|
||||
disconnectSession(id)
|
||||
closeTab(id)
|
||||
}, [closeTab, deleteSession, disconnectSession])
|
||||
|
||||
const handleStartRename = useCallback((id: string, currentTitle: string) => {
|
||||
setContextMenu(null)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useSkillStore } from '../../stores/skillStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SkillMeta, SkillSource } from '../../types/skill'
|
||||
|
||||
@ -28,11 +29,15 @@ function estimateTokens(contentLength: number) {
|
||||
export function SkillList() {
|
||||
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
|
||||
useSkillStore()
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||
const t = useTranslation()
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
||||
const currentWorkDir = activeSession?.workDir || undefined
|
||||
|
||||
useEffect(() => {
|
||||
fetchSkills()
|
||||
}, [fetchSkills])
|
||||
fetchSkills(currentWorkDir)
|
||||
}, [fetchSkills, currentWorkDir])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const result: Partial<Record<SkillSource, SkillMeta[]>> = {}
|
||||
@ -175,7 +180,7 @@ export function SkillList() {
|
||||
key={`${skill.source}-${skill.name}`}
|
||||
onClick={() =>
|
||||
skill.hasDirectory &&
|
||||
fetchSkillDetail(skill.source, skill.name)
|
||||
fetchSkillDetail(skill.source, skill.name, currentWorkDir)
|
||||
}
|
||||
disabled={!skill.hasDirectory}
|
||||
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-transparent"
|
||||
|
||||
@ -204,7 +204,7 @@ export function ActiveSession() {
|
||||
|
||||
<TeamStatusBar />
|
||||
|
||||
<ChatInput />
|
||||
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -390,12 +390,12 @@ export function EmptySession() {
|
||||
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)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
|
||||
<span className="truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
|
||||
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -9,8 +9,8 @@ type SkillStore = {
|
||||
isDetailLoading: boolean
|
||||
error: string | null
|
||||
|
||||
fetchSkills: () => Promise<void>
|
||||
fetchSkillDetail: (source: string, name: string) => Promise<void>
|
||||
fetchSkills: (cwd?: string) => Promise<void>
|
||||
fetchSkillDetail: (source: string, name: string, cwd?: string) => Promise<void>
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
@ -21,10 +21,10 @@ export const useSkillStore = create<SkillStore>((set) => ({
|
||||
isDetailLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchSkills: async () => {
|
||||
fetchSkills: async (cwd) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const { skills } = await skillsApi.list()
|
||||
const { skills } = await skillsApi.list(cwd)
|
||||
set({ skills, isLoading: false })
|
||||
} catch (err) {
|
||||
set({
|
||||
@ -34,10 +34,10 @@ export const useSkillStore = create<SkillStore>((set) => ({
|
||||
}
|
||||
},
|
||||
|
||||
fetchSkillDetail: async (source, name) => {
|
||||
fetchSkillDetail: async (source, name, cwd) => {
|
||||
set({ isDetailLoading: true, error: null })
|
||||
try {
|
||||
const { detail } = await skillsApi.detail(source, name)
|
||||
const { detail } = await skillsApi.detail(source, name, cwd)
|
||||
set({ selectedSkill: detail, isDetailLoading: false })
|
||||
} catch (err) {
|
||||
set({
|
||||
|
||||
@ -7,6 +7,7 @@ import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { SessionService } from '../services/sessionService.js'
|
||||
import { clearCommandsCache } from '../../commands.js'
|
||||
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
|
||||
|
||||
// ============================================================================
|
||||
@ -45,6 +46,20 @@ async function writeSessionFile(
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function writeSkill(
|
||||
rootDir: string,
|
||||
skillName: string,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
const skillDir = path.join(rootDir, skillName)
|
||||
await fs.mkdir(skillDir, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
['---', `description: ${description}`, '---', '', `# ${skillName}`].join('\n'),
|
||||
'utf-8',
|
||||
)
|
||||
}
|
||||
|
||||
// Sample entries matching real CLI format
|
||||
function makeSnapshotEntry(): Record<string, unknown> {
|
||||
return {
|
||||
@ -122,6 +137,7 @@ describe('SessionService', () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearCommandsCache()
|
||||
await cleanupTmpDir()
|
||||
})
|
||||
|
||||
@ -692,6 +708,37 @@ describe('Sessions API', () => {
|
||||
expect(detail.title).toBe('New Custom Title')
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id/slash-commands should include user and project skills before CLI init', async () => {
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const workDir = path.join(tmpDir, 'workspace', 'app')
|
||||
|
||||
await fs.mkdir(path.join(workDir, '.claude', 'skills'), { recursive: true })
|
||||
await fs.mkdir(path.join(tmpDir, 'skills'), { recursive: true })
|
||||
await writeSkill(path.join(tmpDir, 'skills'), 'user-skill', 'User skill description')
|
||||
await writeSkill(path.join(workDir, '.claude', 'skills'), 'project-skill', 'Project skill description')
|
||||
|
||||
await writeSessionFile('-tmp-api-test', sessionId, [
|
||||
makeSnapshotEntry(),
|
||||
makeSessionMetaEntry(workDir),
|
||||
])
|
||||
|
||||
clearCommandsCache()
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/slash-commands`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
commands: Array<{ name: string; description: string }>
|
||||
}
|
||||
|
||||
expect(body.commands).toContainEqual(
|
||||
expect.objectContaining({ name: 'user-skill', description: 'User skill description' }),
|
||||
)
|
||||
expect(body.commands).toContainEqual(
|
||||
expect.objectContaining({ name: 'project-skill', description: 'Project skill description' }),
|
||||
)
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Conversations API via /api/sessions/:id/chat
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
125
src/server/__tests__/skills.test.ts
Normal file
125
src/server/__tests__/skills.test.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { getCwdState, setCwdState } from '../../bootstrap/state.js'
|
||||
import { handleSkillsApi } from '../api/skills.js'
|
||||
|
||||
let tmpHome: string
|
||||
let originalHome: string | undefined
|
||||
let originalUserProfile: string | undefined
|
||||
let originalClaudeConfigDir: string | undefined
|
||||
let originalCwdState: string
|
||||
|
||||
function makeRequest(urlStr: string): { req: Request; url: URL; segments: string[] } {
|
||||
const url = new URL(urlStr, 'http://localhost:3456')
|
||||
const req = new Request(url.toString(), { method: 'GET' })
|
||||
return {
|
||||
req,
|
||||
url,
|
||||
segments: url.pathname.split('/').filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSkill(root: string, skillName: string, content: string): Promise<void> {
|
||||
const skillDir = path.join(root, skillName)
|
||||
await fs.mkdir(skillDir, { recursive: true })
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8')
|
||||
}
|
||||
|
||||
describe('Skills API', () => {
|
||||
beforeEach(async () => {
|
||||
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-skills-test-'))
|
||||
originalHome = process.env.HOME
|
||||
originalUserProfile = process.env.USERPROFILE
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
originalCwdState = getCwdState()
|
||||
|
||||
process.env.HOME = tmpHome
|
||||
process.env.USERPROFILE = tmpHome
|
||||
process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude')
|
||||
setCwdState(tmpHome)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = originalHome
|
||||
}
|
||||
|
||||
if (originalUserProfile === undefined) {
|
||||
delete process.env.USERPROFILE
|
||||
} else {
|
||||
process.env.USERPROFILE = originalUserProfile
|
||||
}
|
||||
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
|
||||
}
|
||||
|
||||
setCwdState(originalCwdState)
|
||||
await fs.rm(tmpHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('lists user and project skills for the requested cwd', async () => {
|
||||
const userSkillsRoot = path.join(tmpHome, '.claude', 'skills')
|
||||
const projectRoot = path.join(tmpHome, 'workspace')
|
||||
const cwd = path.join(projectRoot, 'packages', 'app')
|
||||
|
||||
await writeSkill(
|
||||
userSkillsRoot,
|
||||
'user-skill',
|
||||
['---', 'description: User scope', '---', '', '# User skill'].join('\n'),
|
||||
)
|
||||
await writeSkill(
|
||||
path.join(projectRoot, '.claude', 'skills'),
|
||||
'project-skill',
|
||||
['---', 'description: Project scope', '---', '', '# Project skill'].join('\n'),
|
||||
)
|
||||
|
||||
const { req, url, segments } = makeRequest(`/api/skills?cwd=${encodeURIComponent(cwd)}`)
|
||||
const res = await handleSkillsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { skills: Array<{ name: string; source: string }> }
|
||||
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'user-skill', source: 'user' }))
|
||||
expect(body.skills).toContainEqual(expect.objectContaining({ name: 'project-skill', source: 'project' }))
|
||||
})
|
||||
|
||||
it('resolves project skill details from the nearest project skills directory', async () => {
|
||||
const projectRoot = path.join(tmpHome, 'workspace')
|
||||
const nestedRoot = path.join(projectRoot, 'packages', 'app')
|
||||
const nestedSkillsRoot = path.join(nestedRoot, '.claude', 'skills')
|
||||
const parentSkillsRoot = path.join(projectRoot, '.claude', 'skills')
|
||||
|
||||
await writeSkill(
|
||||
parentSkillsRoot,
|
||||
'shared-skill',
|
||||
['---', 'description: Parent version', '---', '', 'parent body'].join('\n'),
|
||||
)
|
||||
await writeSkill(
|
||||
nestedSkillsRoot,
|
||||
'shared-skill',
|
||||
['---', 'description: Child version', '---', '', 'child body'].join('\n'),
|
||||
)
|
||||
|
||||
const { req, url, segments } = makeRequest(
|
||||
`/api/skills/detail?source=project&name=shared-skill&cwd=${encodeURIComponent(nestedRoot)}`,
|
||||
)
|
||||
const res = await handleSkillsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {
|
||||
detail: { meta: { description: string }; skillRoot: string; files: Array<{ path: string; body?: string }> }
|
||||
}
|
||||
|
||||
expect(body.detail.meta.description).toBe('Child version')
|
||||
expect(body.detail.skillRoot).toBe(path.join(nestedSkillsRoot, 'shared-skill'))
|
||||
expect(body.detail.files).toContainEqual(
|
||||
expect.objectContaining({ path: 'SKILL.md', body: 'child body' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -15,6 +15,8 @@
|
||||
import { sessionService } from '../services/sessionService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { getSlashCommands } from '../ws/handler.js'
|
||||
import { getCommandName } from '../../commands.js'
|
||||
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
|
||||
|
||||
export async function handleSessionsApi(
|
||||
req: Request,
|
||||
@ -78,7 +80,7 @@ export async function handleSessionsApi(
|
||||
{ status: 405 }
|
||||
)
|
||||
}
|
||||
return Response.json({ commands: getSlashCommands(sessionId) })
|
||||
return await getSessionSlashCommands(sessionId)
|
||||
}
|
||||
|
||||
// Route to conversations handler if sub-resource is 'chat'
|
||||
@ -167,6 +169,28 @@ async function deleteSession(sessionId: string): Promise<Response> {
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
async function getSessionSlashCommands(sessionId: string): Promise<Response> {
|
||||
const cachedCommands = getSlashCommands(sessionId)
|
||||
if (cachedCommands.length > 0) {
|
||||
return Response.json({ commands: cachedCommands })
|
||||
}
|
||||
|
||||
const workDir = await sessionService.getSessionWorkDir(sessionId)
|
||||
if (!workDir) {
|
||||
throw ApiError.notFound(`Session not found: ${sessionId}`)
|
||||
}
|
||||
|
||||
const commands = await getSkillDirCommands(workDir)
|
||||
const slashCommands = commands
|
||||
.filter((command) => command.userInvocable !== false)
|
||||
.map((command) => ({
|
||||
name: getCommandName(command),
|
||||
description: command.description || '',
|
||||
}))
|
||||
|
||||
return Response.json({ commands: slashCommands })
|
||||
}
|
||||
|
||||
async function getGitInfo(sessionId: string): Promise<Response> {
|
||||
const workDir = await sessionService.getSessionWorkDir(sessionId)
|
||||
if (!workDir) {
|
||||
|
||||
@ -6,10 +6,12 @@
|
||||
* ?source=user&name=xxx
|
||||
*/
|
||||
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs/promises'
|
||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { getProjectDirsUpToHome } from '../../utils/markdownConfigLoader.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
@ -25,6 +27,8 @@ type SkillMeta = {
|
||||
hasDirectory: boolean
|
||||
}
|
||||
|
||||
type SkillSource = SkillMeta['source']
|
||||
|
||||
type FileTreeNode = {
|
||||
name: string
|
||||
path: string
|
||||
@ -74,7 +78,15 @@ function normalizeFrontmatter(content: string, sourcePath?: string): {
|
||||
}
|
||||
|
||||
function getUserSkillsDir(): string {
|
||||
return path.join(os.homedir(), '.claude', 'skills')
|
||||
return path.join(getClaudeConfigHomeDir(), 'skills')
|
||||
}
|
||||
|
||||
function getRequestedCwd(url: URL): string {
|
||||
return url.searchParams.get('cwd') || getCwd()
|
||||
}
|
||||
|
||||
function getProjectSkillsDirs(cwd: string): string[] {
|
||||
return getProjectDirsUpToHome('skills', cwd)
|
||||
}
|
||||
|
||||
async function loadSkillMeta(
|
||||
@ -191,6 +203,60 @@ async function buildFileTree(
|
||||
return { tree, files }
|
||||
}
|
||||
|
||||
async function collectSkillsFromRoots(
|
||||
skillRoots: string[],
|
||||
source: SkillSource,
|
||||
): Promise<SkillMeta[]> {
|
||||
const skills: SkillMeta[] = []
|
||||
const seenNames = new Set<string>()
|
||||
|
||||
for (const root of skillRoots) {
|
||||
let entries: import('fs').Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(root, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.') || seenNames.has(entry.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const meta = await loadSkillMeta(path.join(root, entry.name), entry.name, source)
|
||||
if (!meta) continue
|
||||
|
||||
seenNames.add(entry.name)
|
||||
skills.push(meta)
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
async function resolveSkillDir(
|
||||
source: SkillSource,
|
||||
name: string,
|
||||
cwd: string,
|
||||
): Promise<string | null> {
|
||||
const skillRoots =
|
||||
source === 'user' ? [getUserSkillsDir()] : getProjectSkillsDirs(cwd)
|
||||
|
||||
for (const root of skillRoots) {
|
||||
const skillDir = path.join(root, name)
|
||||
try {
|
||||
const stat = await fs.stat(skillDir)
|
||||
if (stat.isDirectory()) {
|
||||
return skillDir
|
||||
}
|
||||
} catch {
|
||||
// Try the next candidate root.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleSkillsApi(
|
||||
@ -207,7 +273,7 @@ export async function handleSkillsApi(
|
||||
|
||||
switch (sub) {
|
||||
case undefined:
|
||||
return await listSkills()
|
||||
return await listSkills(url)
|
||||
case 'detail':
|
||||
return await getSkillDetail(url)
|
||||
default:
|
||||
@ -220,25 +286,14 @@ export async function handleSkillsApi(
|
||||
|
||||
// ─── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function listSkills(): Promise<Response> {
|
||||
const skillsDir = getUserSkillsDir()
|
||||
const skills: SkillMeta[] = []
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
||||
const meta = await loadSkillMeta(
|
||||
path.join(skillsDir, entry.name),
|
||||
entry.name,
|
||||
'user',
|
||||
)
|
||||
if (meta) skills.push(meta)
|
||||
}
|
||||
} catch {
|
||||
// skills dir doesn't exist — return empty list
|
||||
}
|
||||
async function listSkills(url: URL): Promise<Response> {
|
||||
const cwd = getRequestedCwd(url)
|
||||
const [userSkills, projectSkills] = await Promise.all([
|
||||
collectSkillsFromRoots([getUserSkillsDir()], 'user'),
|
||||
collectSkillsFromRoots(getProjectSkillsDirs(cwd), 'project'),
|
||||
])
|
||||
|
||||
const skills = [...userSkills, ...projectSkills]
|
||||
skills.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return Response.json({ skills })
|
||||
}
|
||||
@ -256,21 +311,17 @@ async function getSkillDetail(url: URL): Promise<Response> {
|
||||
throw ApiError.badRequest('Invalid skill name')
|
||||
}
|
||||
|
||||
let skillDir: string
|
||||
if (source === 'user') {
|
||||
skillDir = path.join(getUserSkillsDir(), name)
|
||||
} else {
|
||||
if (source !== 'user' && source !== 'project') {
|
||||
throw ApiError.badRequest(`Unsupported source: ${source}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(skillDir)
|
||||
if (!stat.isDirectory()) throw new Error()
|
||||
} catch {
|
||||
const cwd = getRequestedCwd(url)
|
||||
const skillDir = await resolveSkillDir(source, name, cwd)
|
||||
if (!skillDir) {
|
||||
throw ApiError.notFound(`Skill not found: ${name}`)
|
||||
}
|
||||
|
||||
const meta = await loadSkillMeta(skillDir, name, source as 'user')
|
||||
const meta = await loadSkillMeta(skillDir, name, source)
|
||||
if (!meta) {
|
||||
throw ApiError.notFound(`Skill missing SKILL.md: ${name}`)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user