mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
211 lines
9.1 KiB
TypeScript
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>
|
|
)
|
|
}
|