cc-haha/desktop/src/pages/ActiveSession.tsx
程序员阿江(Relakkes) 261a85ab76 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
2026-04-20 13:36:19 +08:00

211 lines
9.1 KiB
TypeScript

import { useEffect, useMemo } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useCLITaskStore } from '../stores/cliTaskStore'
import { useTeamStore } from '../stores/teamStore'
import { useTranslation } from '../i18n'
import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
const TASK_POLL_INTERVAL_MS = 1000
export function ActiveSession() {
const activeTabId = useTabStore((s) => s.activeTabId)
const sessions = useSessionStore((s) => s.sessions)
const connectToSession = useChatStore((s) => s.connectToSession)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks)
const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId)
const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed'))
const chatState = sessionState?.chatState ?? 'idle'
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
const session = sessions.find((s) => s.id === activeTabId)
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
const activeTeam = useTeamStore((s) => s.activeTeam)
const isMemberSession = !!memberInfo
useEffect(() => {
if (activeTabId && !isMemberSession) {
connectToSession(activeTabId)
}
}, [activeTabId, isMemberSession, connectToSession])
useEffect(() => {
if (!activeTabId || isMemberSession) return
const shouldPollTasks =
chatState !== 'idle' ||
(trackedTaskSessionId === activeTabId && hasIncompleteTasks)
if (!shouldPollTasks) return
void fetchSessionTasks(activeTabId)
const timer = setInterval(() => {
void fetchSessionTasks(activeTabId)
}, TASK_POLL_INTERVAL_MS)
return () => clearInterval(timer)
}, [
activeTabId,
isMemberSession,
chatState,
trackedTaskSessionId,
hasIncompleteTasks,
fetchSessionTasks,
])
const t = useTranslation()
const messages = sessionState?.messages ?? []
const streamingText = sessionState?.streamingText ?? ''
const isEmpty = messages.length === 0 && !streamingText
const isActive = chatState !== 'idle'
const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
const lastUpdated = useMemo(() => {
if (!session?.modifiedAt) return ''
const diff = Date.now() - new Date(session.modifiedAt).getTime()
if (diff < 60000) return t('session.timeJustNow')
if (diff < 3600000) return t('session.timeMinutes', { n: Math.floor(diff / 60000) })
if (diff < 86400000) return t('session.timeHours', { n: Math.floor(diff / 3600000) })
return t('session.timeDays', { n: Math.floor(diff / 86400000) })
}, [session?.modifiedAt, t])
if (!activeTabId) return null
return (
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface">
{isMemberSession && (
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
<div className="mx-auto max-w-[860px] flex items-center justify-between gap-4 px-8 py-2">
<div className="min-w-0">
<div className="flex items-center gap-3">
{memberInfo?.status === 'running' && (
<span className="flex h-2 w-2 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
)}
{memberInfo?.status === 'completed' && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
)}
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">smart_toy</span>
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{memberInfo?.role}
</span>
{activeTeam && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
@ {activeTeam.name}
</span>
)}
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
{t('teams.memberSessionHint')}
</p>
</div>
<button
onClick={() => {
if (activeTeam?.leadSessionId) {
useTabStore.getState().openTab(
activeTeam.leadSessionId,
t('teams.leader'),
'session',
)
}
}}
disabled={!activeTeam?.leadSessionId}
className="flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:hover:text-[var(--color-text-secondary)]"
>
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
{t('teams.backToLeader')}
</button>
</div>
</div>
)}
{isEmpty ? (
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
<div className="flex max-w-md flex-col items-center text-center">
{isMemberSession ? (
<>
<span className="material-symbols-outlined text-[48px] mb-4 text-[var(--color-text-tertiary)]">smart_toy</span>
<p className="text-[var(--color-text-secondary)]">
{memberInfo?.status === 'running'
? `${memberInfo.role} ${t('teams.working')}`
: t('teams.noMessages')}
</p>
</>
) : (
<>
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px]" style={{ boxShadow: 'var(--shadow-dropdown)' }} />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
{t('empty.title')}
</h1>
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: 'var(--font-body)' }}>
{t('empty.subtitle')}
</p>
</>
)}
</div>
</div>
) : (
<>
{!isMemberSession && (
<div className="mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3">
<div className="flex-1">
<h1 className="text-lg font-bold font-headline text-on-surface leading-tight">
{session?.title || t('session.untitled')}
</h1>
<div className="flex items-center gap-2 text-[10px] text-outline font-medium mt-1">
{isActive && (
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
{t('session.active')}
</span>
)}
{totalTokens > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{totalTokens.toLocaleString()} t</span>
</>
)}
{lastUpdated && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.lastUpdated', { time: lastUpdated })}</span>
</>
)}
{session?.messageCount !== undefined && session.messageCount > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.messages', { count: session.messageCount })}</span>
</>
)}
</div>
{session?.workDirExists === false && (
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
<span className="material-symbols-outlined text-[14px]">warning</span>
<span className="truncate">
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
</span>
</div>
)}
</div>
</div>
)}
<MessageList />
</>
)}
{!isMemberSession && <SessionTaskBar />}
<TeamStatusBar />
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
</div>
)
}