Stabilize the desktop transcript so long agent sessions stay readable

The desktop app now keeps the composer stable while turns are active,
reduces low-signal tool noise in the transcript, restores project context
under the composer after session creation, and relies on the CLI's own
permission requests instead of injecting broader desktop-side Bash asks.

This also brings in the supporting desktop app source tree and the server
routes/session metadata needed for git info, filesystem browsing, session
resume, slash commands, and SDK-backed permission bridging so the UI can
operate as a coherent feature instead of a partial patch.

Constraint: Desktop transcript needs to stay usable during long multi-tool sessions without hiding file-change diffs
Constraint: Permission prompts must mirror CLI behavior closely enough that read-only commands do not get desktop-only prompts
Rejected: Keep rendering Read/Bash bodies inline | too noisy and unlike the intended transcript model
Rejected: Commit only the touched desktop files | would leave the newly introduced desktop app incomplete in git history
Confidence: medium
Scope-risk: broad
Reversibility: messy
Directive: Treat non-writing tools as summary-first transcript events; do not re-expand them by default without validating the UX against long sessions
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run test -- --run
Tested: bun test src/server/__tests__/conversations.test.ts
Not-tested: Manual visual regression against the exact screenshots in a live desktop session
Not-tested: Full root TypeScript check (repository still has unrelated extracted-native parse failures)
This commit is contained in:
程序员阿江(Relakkes)
2026-04-06 20:37:44 +08:00
parent 52f00a95d6
commit 993b96cd39
108 changed files with 15431 additions and 288 deletions
+1
View File
@@ -0,0 +1 @@
# Override parent bunfig to avoid preload interference
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Claude Code Companion</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600&family=JetBrains+Mono&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
<style>
/* Prevent white flash on load */
html { background: #FAF9F5; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4295
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "claude-code-desktop",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest",
"test:ui": "vitest --ui",
"lint": "tsc --noEmit"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"diff": "^7.0.0",
"highlight.js": "^11.11.1",
"lucide-react": "^0.469.0",
"marked": "^15.0.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/diff": "^7.0.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vitest": "^3.0.4"
},
"optionalDependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/cli": "^2.3.1",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1"
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "claude-code-desktop"
version = "0.1.0"
edition = "2021"
[lib]
name = "claude_code_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,15 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-capabilities-schema-v2.json",
"identifier": "default",
"description": "Default capabilities for Claude Code Desktop",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"shell:allow-execute",
"dialog:allow-open",
"dialog:allow-save",
"process:allow-exit",
"process:allow-restart"
]
}
+24
View File
@@ -0,0 +1,24 @@
use tauri::Manager;
#[tauri::command]
fn get_server_url() -> String {
let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "3456".to_string());
let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
format!("http://{}:{}", host, port)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.invoke_handler(tauri::generate_handler![get_server_url])
.setup(|app| {
// Window is configured in tauri.conf.json
let _window = app.get_webview_window("main").unwrap();
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
claude_code_desktop_lib::run()
}
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
"productName": "Claude Code",
"version": "0.1.0",
"identifier": "com.claude-code.desktop",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "bun run dev",
"beforeBuildCommand": "bun run build"
},
"app": {
"windows": [
{
"title": "Claude Code",
"width": 1280,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"decorations": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"transparent": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+5
View File
@@ -0,0 +1,5 @@
import { AppShell } from './components/layout/AppShell'
export function App() {
return <AppShell />
}
+153
View File
@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
// Import all pages
import { EmptySession } from '../pages/EmptySession'
import { ActiveSession } from '../pages/ActiveSession'
import { AgentTeams } from '../pages/AgentTeams'
import { AgentTranscript } from '../pages/AgentTranscript'
import { ScheduledTasks } from '../pages/ScheduledTasks'
import { ToolInspection } from '../pages/ToolInspection'
// Layout components (chrome is now here, not in pages)
import { AppShell } from '../components/layout/AppShell'
import { UserMessage } from '../components/chat/UserMessage'
import { useChatStore } from '../stores/chatStore'
/**
* Core rendering tests: content-only pages must render without crashing
* and contain key structural elements from the prototype.
*/
describe('Content-only pages render without errors', () => {
it('EmptySession renders mascot and composer', () => {
const { container } = render(<EmptySession />)
expect(container.querySelector('textarea')).toBeInTheDocument()
expect(container.innerHTML).toContain('New session')
expect(container.innerHTML).toContain('Ask anything')
})
it('EmptySession plus menu exposes uploads and slash commands before chat starts', () => {
render(<EmptySession />)
fireEvent.click(screen.getByRole('button', { name: 'Open composer tools' }))
expect(screen.getByText('Add files or photos')).toBeInTheDocument()
expect(screen.getByText('Slash commands')).toBeInTheDocument()
})
it('ActiveSession renders with chat components', () => {
const { container } = render(<ActiveSession />)
// Should have the title area and chat input
expect(container.innerHTML).toContain('Untitled Session')
// ChatInput has a textarea
expect(container.querySelector('textarea')).toBeInTheDocument()
expect(container.innerHTML).not.toContain('Preview')
})
it('ActiveSession shows a single primary action button while a turn is active', () => {
useChatStore.setState({ chatState: 'thinking' })
render(<ActiveSession />)
expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^run$/i })).not.toBeInTheDocument()
useChatStore.setState({ chatState: 'idle' })
})
it('AgentTeams renders team strip and members', () => {
const { container } = render(<AgentTeams />)
expect(container.innerHTML).toContain('Architect')
expect(container.innerHTML).toContain('session-dev')
expect(container.innerHTML).toContain('groups')
})
it('AgentTranscript renders transcript content', () => {
const { container } = render(<AgentTranscript />)
expect(container.innerHTML).toContain('Frontend Dev')
expect(container.innerHTML).toContain('Back to Leader')
expect(container.innerHTML).toContain('Navigation.tsx')
})
it('ScheduledTasks renders (store-connected)', async () => {
const { container } = render(<ScheduledTasks />)
await screen.findByText('Scheduled tasks')
expect(container.innerHTML).toContain('Scheduled tasks')
})
it('ToolInspection renders diff viewer', () => {
const { container } = render(<ToolInspection />)
expect(container.innerHTML).toContain('edit_file')
expect(container.innerHTML).toContain('Split')
expect(container.innerHTML).toContain('Unified')
})
})
describe('Chat attachments', () => {
it('UserMessage opens image gallery when an attachment is clicked', () => {
render(
<UserMessage
content=""
attachments={[
{
type: 'image',
name: 'diagram.png',
data: 'data:image/png;base64,abc123',
},
]}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText('diagram.png')).toBeInTheDocument()
})
})
describe('AppShell layout renders chrome', () => {
it('AppShell renders sidebar and session shell', () => {
const { container } = render(<AppShell />)
expect(container.querySelector('aside')).toBeInTheDocument()
expect(container.innerHTML).toContain('New session')
expect(container.innerHTML).toContain('Scheduled')
expect(container.innerHTML).toContain('Select a project')
})
})
describe('Design system compliance', () => {
it('Pages use Material Symbols Outlined icons', () => {
const pages = [EmptySession, AgentTeams, AgentTranscript, ToolInspection]
for (const Page of pages) {
const { container, unmount } = render(<Page />)
const icons = container.querySelectorAll('.material-symbols-outlined')
expect(icons.length).toBeGreaterThan(0)
unmount()
}
})
it('Current brand color is used in content pages', () => {
const pages = [EmptySession]
for (const Page of pages) {
const { container, unmount } = render(<Page />)
const html = container.innerHTML
expect(
html.includes('C47A5A') ||
html.includes('8F482F') ||
html.includes('var(--color-brand)') ||
html.includes('bg-[var(--color-brand)]'),
).toBe(true)
unmount()
}
})
})
describe('Mock data integration', () => {
it('AgentTeams shows team members from mock data', () => {
const { container } = render(<AgentTeams />)
expect(container.innerHTML).toContain('Architect')
expect(container.innerHTML).toContain('Frontend Dev')
expect(container.innerHTML).toContain('Backend Dev')
expect(container.innerHTML).toContain('Tester')
})
it('AgentTranscript shows transcript content', () => {
const { container } = render(<AgentTranscript />)
expect(container.innerHTML).toContain('Navigation.tsx')
})
})
+50
View File
@@ -0,0 +1,50 @@
const DEFAULT_BASE_URL = 'http://127.0.0.1:3456'
let baseUrl = DEFAULT_BASE_URL
export function setBaseUrl(url: string) {
baseUrl = url.replace(/\/$/, '')
}
export function getBaseUrl() {
return baseUrl
}
export class ApiError extends Error {
constructor(
public status: number,
public body: unknown,
) {
super(`API error ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`)
this.name = 'ApiError'
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const url = `${baseUrl}${path}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
const res = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const errorBody = await res.json().catch(() => res.text())
throw new ApiError(res.status, errorBody)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
}
+18
View File
@@ -0,0 +1,18 @@
import { api } from './client'
type BrowseResult = {
currentPath: string
parentPath: string
entries: Array<{
name: string
path: string
isDirectory: boolean
}>
}
export const filesystemApi = {
browse(path?: string) {
const params = path ? `?path=${encodeURIComponent(path)}` : ''
return api.get<BrowseResult>(`/api/filesystem/browse${params}`)
},
}
+28
View File
@@ -0,0 +1,28 @@
import { api } from './client'
import type { ModelInfo, EffortLevel } from '../types/settings'
type ModelsResponse = { models: ModelInfo[] }
type CurrentModelResponse = { model: ModelInfo }
type EffortResponse = { level: EffortLevel; available: EffortLevel[] }
export const modelsApi = {
list() {
return api.get<ModelsResponse>('/api/models')
},
getCurrent() {
return api.get<CurrentModelResponse>('/api/models/current')
},
setCurrent(modelId: string) {
return api.put<{ ok: true; model: string }>('/api/models/current', { modelId })
},
getEffort() {
return api.get<EffortResponse>('/api/effort')
},
setEffort(level: EffortLevel) {
return api.put<{ ok: true; level: EffortLevel }>('/api/effort', { level })
},
}
+29
View File
@@ -0,0 +1,29 @@
import { api } from './client'
type SearchResult = {
file: string
line: number
text: string
context?: string[]
}
type SearchResponse = { results: SearchResult[]; total: number }
type SessionSearchResult = {
sessionId: string
title: string
matchCount: number
matches: Array<{ line: number; text: string }>
}
type SessionSearchResponse = { results: SessionSearchResult[] }
export const searchApi = {
search(params: { query: string; cwd?: string; maxResults?: number; glob?: string }) {
return api.post<SearchResponse>('/api/search', params)
},
searchSessions(query: string) {
return api.post<SessionSearchResponse>('/api/search/sessions', { query })
},
}
+56
View File
@@ -0,0 +1,56 @@
import { api } from './client'
import type { SessionListItem, MessageEntry } from '../types/session'
type SessionsResponse = { sessions: SessionListItem[]; total: number }
type MessagesResponse = { messages: MessageEntry[] }
type CreateSessionResponse = { sessionId: string }
export type RecentProject = {
projectPath: string
realPath: string
projectName: string
isGit: boolean
repoName: string | null
branch: string | null
modifiedAt: string
sessionCount: number
}
export const sessionsApi = {
list(params?: { project?: string; limit?: number; offset?: number }) {
const query = new URLSearchParams()
if (params?.project) query.set('project', params.project)
if (params?.limit) query.set('limit', String(params.limit))
if (params?.offset) query.set('offset', String(params.offset))
const qs = query.toString()
return api.get<SessionsResponse>(`/api/sessions${qs ? `?${qs}` : ''}`)
},
getMessages(sessionId: string) {
return api.get<MessagesResponse>(`/api/sessions/${sessionId}/messages`)
},
create(workDir: string) {
return api.post<CreateSessionResponse>('/api/sessions', { workDir })
},
delete(sessionId: string) {
return api.delete<{ ok: true }>(`/api/sessions/${sessionId}`)
},
rename(sessionId: string, title: string) {
return api.patch<{ ok: true }>(`/api/sessions/${sessionId}`, { title })
},
getRecentProjects() {
return api.get<{ projects: RecentProject[] }>('/api/sessions/recent-projects')
},
getGitInfo(sessionId: string) {
return api.get<{ branch: string | null; repoName: string | null; workDir: string; changedFiles: number }>(`/api/sessions/${sessionId}/git-info`)
},
getSlashCommands(sessionId: string) {
return api.get<{ commands: Array<{ name: string; description: string }> }>(`/api/sessions/${sessionId}/slash-commands`)
},
}
+20
View File
@@ -0,0 +1,20 @@
import { api } from './client'
import type { PermissionMode, UserSettings } from '../types/settings'
export const settingsApi = {
getUser() {
return api.get<UserSettings>('/api/settings/user')
},
updateUser(settings: Partial<UserSettings>) {
return api.put<{ ok: true }>('/api/settings/user', settings)
},
getPermissionMode() {
return api.get<{ mode: PermissionMode }>('/api/permissions/mode')
},
setPermissionMode(mode: PermissionMode) {
return api.put<{ ok: true; mode: PermissionMode }>('/api/permissions/mode', { mode })
},
}
+32
View File
@@ -0,0 +1,32 @@
import { api } from './client'
import type { CronTask, CreateTaskInput, TaskRun } from '../types/task'
type TasksResponse = { tasks: CronTask[] }
type TaskResponse = { task: CronTask }
type RunsResponse = { runs: TaskRun[] }
export const tasksApi = {
list() {
return api.get<TasksResponse>('/api/scheduled-tasks')
},
create(input: CreateTaskInput) {
return api.post<TaskResponse>('/api/scheduled-tasks', input)
},
update(id: string, updates: Partial<CronTask>) {
return api.put<TaskResponse>(`/api/scheduled-tasks/${id}`, updates)
},
delete(id: string) {
return api.delete<{ ok: true }>(`/api/scheduled-tasks/${id}`)
},
getRecentRuns(limit = 50) {
return api.get<RunsResponse>(`/api/scheduled-tasks/runs?limit=${limit}`)
},
getTaskRuns(taskId: string) {
return api.get<RunsResponse>(`/api/scheduled-tasks/${taskId}/runs`)
},
}
+25
View File
@@ -0,0 +1,25 @@
import { api } from './client'
import type { TeamSummary, TeamDetail, TranscriptMessage } from '../types/team'
type TeamsResponse = { teams: TeamSummary[] }
type TranscriptResponse = { messages: TranscriptMessage[] }
export const teamsApi = {
list() {
return api.get<TeamsResponse>('/api/teams')
},
get(name: string) {
return api.get<TeamDetail>(`/api/teams/${encodeURIComponent(name)}`)
},
getMemberTranscript(teamName: string, agentId: string) {
return api.get<TranscriptResponse>(
`/api/teams/${encodeURIComponent(teamName)}/members/${encodeURIComponent(agentId)}/transcript`,
)
},
delete(name: string) {
return api.delete<{ ok: true }>(`/api/teams/${encodeURIComponent(name)}`)
},
}
+137
View File
@@ -0,0 +1,137 @@
import type { ClientMessage, ServerMessage } from '../types/chat'
import { getBaseUrl } from './client'
type MessageHandler = (msg: ServerMessage) => void
class WebSocketManager {
private ws: WebSocket | null = null
private sessionId: string | null = null
private handlers = new Set<MessageHandler>()
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempt = 0
private pingInterval: ReturnType<typeof setInterval> | null = null
private intentionalClose = false
private pendingMessages: ClientMessage[] = []
get connected() {
return this.ws?.readyState === WebSocket.OPEN
}
get currentSessionId() {
return this.sessionId
}
connect(sessionId: string) {
// Disconnect existing connection if switching sessions
if (this.sessionId && this.sessionId !== sessionId) {
this.disconnect()
}
this.sessionId = sessionId
this.intentionalClose = false
this.reconnectAttempt = 0
const wsUrl = getBaseUrl().replace(/^http/, 'ws')
this.ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
this.ws.onopen = () => {
this.reconnectAttempt = 0
this.startPingLoop()
// Flush any pending messages that were queued before connection opened
while (this.pendingMessages.length > 0) {
const msg = this.pendingMessages.shift()!
this.ws!.send(JSON.stringify(msg))
}
}
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string) as ServerMessage
for (const handler of this.handlers) {
handler(msg)
}
} catch {
// Ignore malformed messages
}
}
this.ws.onclose = () => {
this.stopPingLoop()
if (!this.intentionalClose && this.sessionId) {
this.scheduleReconnect()
}
}
this.ws.onerror = () => {
// onclose will fire after onerror
}
}
disconnect() {
this.intentionalClose = true
this.sessionId = null
this.stopPingLoop()
this.clearReconnect()
this.pendingMessages = []
if (this.ws) {
this.ws.close()
this.ws = null
}
}
send(message: ClientMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message))
} else if (this.ws?.readyState === WebSocket.CONNECTING) {
// Queue message to be sent when connection opens
this.pendingMessages.push(message)
}
}
onMessage(handler: MessageHandler) {
this.handlers.add(handler)
return () => { this.handlers.delete(handler) }
}
/** Remove all message handlers — call before registering new ones */
clearHandlers() {
this.handlers.clear()
}
private startPingLoop() {
this.stopPingLoop()
this.pingInterval = setInterval(() => {
this.send({ type: 'ping' })
}, 30_000)
}
private stopPingLoop() {
if (this.pingInterval) {
clearInterval(this.pingInterval)
this.pingInterval = null
}
}
private scheduleReconnect() {
this.clearReconnect()
const delay = Math.min(1000 * 2 ** this.reconnectAttempt, 30_000)
this.reconnectAttempt++
this.reconnectTimer = setTimeout(() => {
if (this.sessionId) {
this.connect(this.sessionId)
}
}, delay)
}
private clearReconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
}
export const wsManager = new WebSocketManager()
@@ -0,0 +1,229 @@
import { useState } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { Button } from '../shared/Button'
type QuestionOption = {
label: string
description?: string
}
type Question = {
question: string
options?: QuestionOption[]
}
type AskUserInput = {
questions?: Question[]
question?: string
options?: QuestionOption[]
}
type Props = {
toolUseId: string
input: unknown
}
/**
* Parse the AskUserQuestion input which may come in different shapes.
*/
function parseInput(input: unknown): Question[] {
if (!input || typeof input !== 'object') return []
const obj = input as AskUserInput
// Shape 1: { questions: [...] }
if (Array.isArray(obj.questions)) {
return obj.questions
}
// Shape 2: { question: "...", options: [...] }
if (typeof obj.question === 'string') {
return [{ question: obj.question, options: obj.options }]
}
return []
}
export function AskUserQuestion({ toolUseId: _toolUseId, input }: Props) {
const { sendMessage } = useChatStore()
const questions = parseInput(input)
const [selections, setSelections] = useState<Record<number, string>>({})
const [freeText, setFreeText] = useState('')
const [submitted, setSubmitted] = useState(false)
if (questions.length === 0) return null
const handleSelect = (qIndex: number, label: string) => {
if (submitted) return
setSelections((prev) => ({ ...prev, [qIndex]: label }))
// Clear free text when an option is selected
setFreeText('')
}
const handleSubmit = () => {
if (submitted) return
// Build the response text
const parts: string[] = []
for (let i = 0; i < questions.length; i++) {
const selected = selections[i]
if (selected) {
parts.push(selected)
}
}
// Free text overrides if provided
const response = freeText.trim() || parts.join('; ') || ''
if (!response) return
setSubmitted(true)
// Send the response as a user message -- the server routes it as a tool_result
sendMessage(response)
}
const hasSelection = Object.keys(selections).length > 0 || freeText.trim().length > 0
return (
<div className={`mb-4 rounded-[var(--radius-lg)] border overflow-hidden ${
submitted
? 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
: 'border-[var(--color-secondary)] bg-[var(--color-surface-container-lowest)]'
}`}>
{/* Header */}
<div className={`flex items-center gap-3 px-4 py-3 ${
submitted
? 'bg-[var(--color-surface-container-low)]'
: 'bg-[var(--color-surface-container)]'
}`}>
<div className="flex items-center justify-center w-8 h-8 rounded-[var(--radius-md)] bg-[var(--color-secondary)]/10">
<span className="material-symbols-outlined text-[18px] text-[var(--color-secondary)]">
help
</span>
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
Claude needs your input
</span>
{submitted && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
Answered
</span>
)}
</div>
</div>
{/* Questions */}
<div className="px-4 py-3 space-y-4">
{questions.map((q, qIndex) => (
<div key={qIndex}>
<p className="text-sm font-medium text-[var(--color-text-primary)] mb-3">
{q.question}
</p>
{/* Option cards */}
{q.options && q.options.length > 0 && (
<div className="space-y-2 mb-3">
{q.options.map((opt, optIndex) => {
const isSelected = selections[qIndex] === opt.label
return (
<button
key={optIndex}
onClick={() => handleSelect(qIndex, opt.label)}
disabled={submitted}
className={`w-full text-left px-4 py-3 rounded-[var(--radius-md)] border transition-all duration-150 cursor-pointer ${
isSelected
? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]/8 ring-1 ring-[var(--color-secondary)]/30'
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface)] hover:border-[var(--color-outline-variant)] hover:bg-[var(--color-surface-container-low)]'
} ${submitted ? 'cursor-default' : ''}`}
>
<div className="flex items-start gap-3">
{/* Radio indicator */}
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
isSelected
? 'border-[var(--color-secondary)]'
: 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-[var(--color-secondary)]" />
)}
</div>
<div className="flex-1 min-w-0">
<span className={`text-sm font-medium ${
isSelected
? 'text-[var(--color-secondary)]'
: 'text-[var(--color-text-primary)]'
}`}>
{opt.label}
</span>
{opt.description && (
<p className="text-xs text-[var(--color-text-secondary)] mt-0.5">
{opt.description}
</p>
)}
</div>
</div>
</button>
)
})}
</div>
)}
</div>
))}
{/* Free text "Other" input */}
{!submitted && (
<div>
<label className="text-xs text-[var(--color-text-tertiary)] mb-1.5 block">
Or type a custom response:
</label>
<div className="flex gap-2">
<input
type="text"
value={freeText}
onChange={(e) => {
setFreeText(e.target.value)
// Clear radio selections when typing
if (e.target.value.trim()) {
setSelections({})
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && hasSelection) {
handleSubmit()
}
}}
placeholder="Type your answer..."
className="flex-1 px-3 py-2 text-sm bg-[var(--color-surface)] border border-[var(--color-outline-variant)]/40 rounded-[var(--radius-md)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-secondary)] focus:ring-1 focus:ring-[var(--color-secondary)]/30"
/>
</div>
</div>
)}
{/* Submitted answer display */}
{submitted && (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
<span>
Answered: <strong>{freeText.trim() || Object.values(selections).join(', ')}</strong>
</span>
</div>
)}
</div>
{/* Submit button */}
{!submitted && (
<div className="flex items-center gap-2 px-4 py-3 border-t border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)]">
<Button
variant="primary"
size="sm"
disabled={!hasSelection}
onClick={handleSubmit}
icon={
<span className="material-symbols-outlined text-[14px]">send</span>
}
>
Submit
</Button>
</div>
)}
</div>
)
}
@@ -0,0 +1,51 @@
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { useState } from 'react'
type Props = {
content: string
isStreaming?: boolean
}
export function AssistantMessage({ content, isStreaming }: Props) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
setCopied(false)
}
}
return (
<div className="group mb-5 flex gap-3">
{/* Avatar */}
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-[var(--color-brand)] flex items-center justify-center mt-0.5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7l10 5 10-5-10-5z" fill="white" opacity="0.9"/>
<path d="M2 17l10 5 10-5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" opacity="0.7"/>
<path d="M2 12l10 5 10-5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" opacity="0.85"/>
</svg>
</div>
{/* Content */}
<div className="min-w-0 flex-1 text-sm text-[var(--color-text-primary)]">
{!isStreaming && content.trim() && (
<div className="mb-2 flex justify-end">
<button
onClick={handleCopy}
className="rounded-lg border border-[var(--color-border)] px-2.5 py-1 text-[11px] text-[var(--color-text-tertiary)] opacity-0 transition-opacity hover:text-[var(--color-text-primary)] group-hover:opacity-100"
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
)}
<MarkdownRenderer content={content} />
{isStreaming && (
<span className="inline-block w-0.5 h-4 bg-[var(--color-brand)] animate-shimmer ml-0.5 align-text-bottom" />
)}
</div>
</div>
)
}
@@ -0,0 +1,113 @@
import { useMemo, useState } from 'react'
import { ImageGalleryModal } from './ImageGalleryModal'
export type AttachmentPreview = {
id?: string
type: 'image' | 'file'
name: string
data?: string
previewUrl?: string
}
type Props = {
attachments: AttachmentPreview[]
variant?: 'composer' | 'message'
onRemove?: (id: string) => void
}
export function AttachmentGallery({ attachments, variant = 'message', onRemove }: Props) {
const [activeImageIndex, setActiveImageIndex] = useState<number | null>(null)
const images = useMemo(
() =>
attachments
.filter((attachment) => attachment.type === 'image' && (attachment.previewUrl || attachment.data))
.map((attachment) => ({
src: attachment.previewUrl || attachment.data || '',
name: attachment.name,
})),
[attachments],
)
if (attachments.length === 0) return null
const isComposer = variant === 'composer'
return (
<>
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'grid grid-cols-1 gap-2 sm:grid-cols-2'}>
{attachments.map((attachment, index) => {
if (attachment.type === 'image' && (attachment.previewUrl || attachment.data)) {
const src = attachment.previewUrl || attachment.data || ''
return (
<div
key={attachment.id || `${attachment.name}-${index}`}
className={isComposer ? 'group relative' : ''}
>
<button
type="button"
onClick={() => setActiveImageIndex(images.findIndex((image) => image.src === src))}
className={
isComposer
? 'overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)]'
: 'overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left shadow-sm transition-transform hover:scale-[1.01]'
}
>
<img
src={src}
alt={attachment.name}
className={
isComposer
? 'h-16 w-16 object-cover'
: 'max-h-[340px] w-full max-w-[360px] object-cover'
}
/>
</button>
{onRemove && attachment.id && (
<button
type="button"
onClick={() => onRemove(attachment.id!)}
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--color-error)] text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove ${attachment.name}`}
>
×
</button>
)}
</div>
)
}
return (
<div
key={attachment.id || `${attachment.name}-${index}`}
className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs text-[var(--color-text-secondary)]"
>
<span className="material-symbols-outlined text-[14px]">attach_file</span>
<span className="max-w-[220px] truncate">{attachment.name}</span>
{onRemove && attachment.id && (
<button
type="button"
onClick={() => onRemove(attachment.id!)}
className="ml-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-error)]"
aria-label={`Remove ${attachment.name}`}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
)
})}
</div>
{activeImageIndex !== null && activeImageIndex >= 0 && (
<ImageGalleryModal
open={activeImageIndex !== null}
images={images}
activeIndex={activeImageIndex}
onClose={() => setActiveImageIndex(null)}
onSelect={setActiveImageIndex}
/>
)}
</>
)
}
+420
View File
@@ -0,0 +1,420 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { sessionsApi } from '../../api/sessions'
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
import { ModelSelector } from '../controls/ModelSelector'
import type { AttachmentRef } from '../../types/chat'
import { AttachmentGallery } from './AttachmentGallery'
import { ProjectContextChip } from '../shared/ProjectContextChip'
import {
FALLBACK_SLASH_COMMANDS,
findSlashTrigger,
replaceSlashToken,
} from './composerUtils'
type GitInfo = { branch: string | null; repoName: string | null; workDir: string; changedFiles: number }
type Attachment = {
id: string
name: string
type: 'image' | 'file'
mimeType?: string
previewUrl?: string
data?: string
}
export function ChatInput() {
const [input, setInput] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
const [slashFilter, setSlashFilter] = useState('')
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null)
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
const { sendMessage, chatState, stopGeneration, slashCommands } = useChatStore()
const activeSessionId = useSessionStore((state) => state.activeSessionId)
const activeSession = useSessionStore((state) => state.sessions.find((session) => session.id === state.activeSessionId) ?? null)
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
const isActive = chatState !== 'idle'
const isWorkspaceMissing = activeSession?.workDirExists === false
const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || attachments.length > 0)
useEffect(() => {
textareaRef.current?.focus()
}, [isActive])
useEffect(() => {
if (!activeSessionId) {
setGitInfo(null)
return
}
sessionsApi.getGitInfo(activeSessionId).then(setGitInfo).catch(() => setGitInfo(null))
}, [activeSessionId])
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 200)}px`
}, [input])
useEffect(() => {
if (!plusMenuOpen) return
const handleClick = (event: MouseEvent) => {
if (plusMenuRef.current && !plusMenuRef.current.contains(event.target as Node)) {
setPlusMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [plusMenuOpen])
useEffect(() => {
if (!slashMenuOpen) return
const handleClick = (event: MouseEvent) => {
if (
slashMenuRef.current &&
!slashMenuRef.current.contains(event.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(event.target as Node)
) {
setSlashMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [slashMenuOpen])
const filteredCommands = useMemo(() => {
const source = slashCommands.length > 0 ? slashCommands : FALLBACK_SLASH_COMMANDS
if (!slashFilter) return source
const lower = slashFilter.toLowerCase()
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' })
}
}, [slashMenuOpen, slashSelectedIndex])
const detectSlashTrigger = useCallback((value: string, cursorPos: number) => {
const token = findSlashTrigger(value, cursorPos)
if (!token) {
setSlashMenuOpen(false)
return
}
setSlashFilter(token.filter)
setSlashMenuOpen(true)
}, [])
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value
setInput(value)
detectSlashTrigger(value, event.target.selectionStart ?? value.length)
}
const selectSlashCommand = useCallback((command: string) => {
const el = textareaRef.current
if (!el) return
const cursorPos = el.selectionStart ?? input.length
const replacement = replaceSlashToken(input, cursorPos, command)
setInput(replacement.value)
setSlashMenuOpen(false)
requestAnimationFrame(() => {
el.focus()
el.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
})
}, [input])
const handleSubmit = () => {
const text = input.trim()
if ((!text && attachments.length === 0) || isWorkspaceMissing) return
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name,
data: attachment.data,
mimeType: attachment.mimeType,
}))
sendMessage(text, attachmentPayload)
setInput('')
setAttachments([])
setSlashMenuOpen(false)
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (slashMenuOpen && filteredCommands.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSlashSelectedIndex((prev) => (prev + 1) % filteredCommands.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length)
return
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
const selected = filteredCommands[slashSelectedIndex]
if (selected) selectSlashCommand(selected.name)
return
}
if (event.key === 'Escape') {
event.preventDefault()
setSlashMenuOpen(false)
return
}
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSubmit()
}
}
const handlePaste = (event: React.ClipboardEvent) => {
const items = event.clipboardData?.items
if (!items) return
let hasImage = false
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
if (!item || !item.type.startsWith('image/')) continue
hasImage = true
event.preventDefault()
const file = item.getAsFile()
if (!file) continue
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
const reader = new FileReader()
reader.onload = () => {
setAttachments((prev) => [
...prev,
{
id,
name: `pasted-image-${Date.now()}.png`,
type: 'image',
mimeType: file.type || 'image/png',
previewUrl: reader.result as string,
data: reader.result as string,
},
])
}
reader.readAsDataURL(file)
}
if (!hasImage) return
}
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (!files) return
Array.from(files).forEach((file) => {
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
const isImage = file.type.startsWith('image/')
const reader = new FileReader()
reader.onload = () => {
setAttachments((prev) => [
...prev,
{
id,
name: file.name,
type: isImage ? 'image' : 'file',
mimeType: file.type || undefined,
previewUrl: isImage ? (reader.result as string) : undefined,
data: reader.result as string,
},
])
}
reader.readAsDataURL(file)
})
event.target.value = ''
}
const handleDrop = (event: React.DragEvent) => {
event.preventDefault()
const files = event.dataTransfer.files
if (files.length > 0) {
const fakeEvent = { target: { files } } as React.ChangeEvent<HTMLInputElement>
handleFileSelect(fakeEvent)
}
}
const removeAttachment = (id: string) => {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
}
const insertSlashCommand = () => {
const el = textareaRef.current
const cursorPos = el?.selectionStart ?? input.length
const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false })
setInput(replacement.value)
setPlusMenuOpen(false)
setSlashFilter('')
setSlashMenuOpen(true)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
})
}
return (
<div className="bg-[#FAF9F5] px-4 py-4">
<div className="mx-auto max-w-[860px]">
<div
className="relative rounded-xl border border-[#dac1ba]/15 bg-white p-4 transition-colors focus-within:border-[var(--color-border-focus)]"
style={{ boxShadow: '0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08)' }}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
{slashMenuOpen && filteredCommands.length > 0 && (
<div
ref={slashMenuRef}
className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
>
<div className="max-h-[300px] overflow-y-auto py-1">
{filteredCommands.map((command, index) => (
<button
key={command.name}
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 ${
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)]">
{command.description}
</span>
</button>
))}
</div>
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Up/Down</kbd>
<span>navigate</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Enter</kbd>
<span>select</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd>
<span>dismiss</span>
</div>
</div>
)}
{attachments.length > 0 && (
<div className="px-3 pt-3">
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
</div>
)}
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={
isWorkspaceMissing
? 'This session points to a missing workspace. Create a new session or pick another project.'
: 'Ask Claude to edit, debug or explain...'
}
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-[#dac1ba]/10 px-3 py-3">
<div className="flex items-center gap-2">
<div ref={plusMenuRef} className="relative">
<button
onClick={() => setPlusMenuOpen((value) => !value)}
aria-label="Open composer tools"
className="rounded-[var(--radius-md)] p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[#F4F4F0]"
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
{plusMenuOpen && (
<div className="absolute bottom-full left-0 z-50 mb-2 w-[240px] rounded-xl border border-[#dac1ba]/20 bg-white py-1 shadow-[0_18px_48px_rgba(27,28,26,0.12)]">
<button
onClick={() => {
fileInputRef.current?.click()
setPlusMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
<span className="text-sm text-[var(--color-text-primary)]">Add files or photos</span>
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
>
<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)]">Slash commands</span>
</button>
</div>
)}
</div>
<PermissionModeSelector />
</div>
<div className="flex items-center gap-2">
<ModelSelector />
<button
onClick={isActive ? stopGeneration : handleSubmit}
disabled={!isActive && !canSubmit}
title={isActive ? 'Stop generation (Cmd+.)' : undefined}
className={`flex w-[112px] items-center justify-center gap-1 rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-all hover:opacity-90 disabled:opacity-30 ${
isActive ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
}`}
>
<span className="material-symbols-outlined text-[14px]">
{isActive ? 'stop' : 'arrow_forward'}
</span>
{isActive ? 'Stop' : 'Run'}
</button>
</div>
</div>
</div>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
<div className="mt-3 px-1">
<ProjectContextChip
workDir={gitInfo?.workDir || activeSession?.workDir}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,99 @@
import { useMemo, useState } from 'react'
import hljs from 'highlight.js'
import { CopyButton } from '../shared/CopyButton'
type Props = {
code: string
language?: string
maxLines?: number
showLineNumbers?: boolean
}
export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = true }: Props) {
const [expanded, setExpanded] = useState(false)
const lines = code.split('\n')
const isTruncated = !expanded && lines.length > maxLines
const visibleCode = isTruncated ? lines.slice(0, maxLines).join('\n') : code
const highlightedLines = useMemo(() => {
return visibleCode.split('\n').map((line) => {
try {
if (language && hljs.getLanguage(language)) {
return hljs.highlight(line || ' ', { language, ignoreIllegals: true }).value
}
return hljs.highlightAuto(line || ' ').value
} catch {
return escapeHtml(line)
}
})
}, [language, visibleCode])
const startLine = 1
const languageLabel = language || 'code'
const visibleLines = visibleCode.split('\n')
const lineCountLabel = `${lines.length} ${lines.length === 1 ? 'line' : 'lines'}`
const showExpandToggle = lines.length > maxLines
const displayLines = visibleLines.length
return (
<div className="overflow-hidden rounded-2xl border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f]">
<div className="flex items-center justify-between border-b border-[#d0d7de] bg-white px-3 py-2 text-[11px] text-[#57606a]">
<div className="flex items-center gap-3">
<span className="font-semibold uppercase tracking-[0.14em] text-[#57606a]">{languageLabel}</span>
<span>{lineCountLabel}</span>
</div>
<CopyButton
text={code}
className="rounded-md border border-[#d0d7de] bg-white px-2 py-1 text-[11px] text-[#57606a] transition-colors hover:bg-[#f3f4f6] hover:text-[#24292f]"
/>
</div>
<div className="max-h-[420px] overflow-auto">
<div className="min-w-full font-[var(--font-mono)] text-[12px] leading-[1.45]">
{visibleLines.map((line, index) => (
<div
key={`${startLine + index}-${line}`}
className={`grid grid-cols-[3rem,minmax(0,1fr)] gap-0 last:border-b-0 ${
index === displayLines - 1 ? '' : 'border-b border-[#d8dee4]'
}`}
>
{showLineNumbers ? (
<span className="select-none border-r border-[#d8dee4] bg-[#f6f8fa] px-3 py-1 text-right text-[11px] text-[#57606a]">
{startLine + index}
</span>
) : (
<span className="w-0" />
)}
<span
className="overflow-hidden bg-white px-4 py-1 whitespace-pre-wrap break-words text-[#24292f]"
dangerouslySetInnerHTML={{ __html: highlightedLines[index] ?? escapeHtml(line) }}
/>
</div>
))}
</div>
</div>
{showExpandToggle && (
<button
onClick={() => setExpanded((value) => !value)}
className="w-full border-t border-white/8 py-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-[#999] transition-colors hover:text-white"
>
{expanded ? 'Collapse' : `Show ${lines.length - maxLines} more lines`}
</button>
)}
</div>
)
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+134
View File
@@ -0,0 +1,134 @@
import { useMemo } from 'react'
import { createPatch } from 'diff'
import { CopyButton } from '../shared/CopyButton'
type Props = {
filePath: string
oldString: string
newString: string
}
type DiffLine = {
oldLineNo: number | null
newLineNo: number | null
type: 'added' | 'removed' | 'context'
content: string
}
export function DiffViewer({ filePath, oldString, newString }: Props) {
const patch = useMemo(
() => createPatch(filePath, oldString, newString, 'before', 'after', { context: 3 }),
[filePath, newString, oldString],
)
const lines = useMemo(() => parsePatchLines(patch), [patch])
const additions = lines.filter((line) => line.type === 'added').length
const deletions = lines.filter((line) => line.type === 'removed').length
return (
<div className="overflow-hidden rounded-2xl border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f]">
<div className="flex items-center justify-between border-b border-[#d0d7de] bg-white px-3 py-2">
<div className="min-w-0">
<div className="truncate font-[var(--font-mono)] text-[11px] text-[#57606a]">
{filePath}
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] uppercase tracking-[0.14em]">
<span className="rounded-full bg-[#dafbe1] px-2 py-0.5 text-[#1a7f37]">+{additions}</span>
<span className="rounded-full bg-[#ffebe9] px-2 py-0.5 text-[#cf222e]">-{deletions}</span>
</div>
</div>
<CopyButton
text={patch}
label="Copy patch"
className="rounded-md border border-[#d0d7de] bg-white px-2 py-1 text-[11px] text-[#57606a] transition-colors hover:bg-[#f3f4f6] hover:text-[#24292f]"
/>
</div>
<div className="max-h-[360px] overflow-auto">
<div className="min-w-full">
{lines.map((line, index) => {
const rowClass =
line.type === 'added'
? 'bg-[#f0fff4]'
: line.type === 'removed'
? 'bg-[#fff8f8]'
: 'bg-white'
const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '
const prefixColor =
line.type === 'added'
? 'text-[#1a7f37]'
: line.type === 'removed'
? 'text-[#cf222e]'
: 'text-[#57606a]'
return (
<div
key={`${line.oldLineNo}-${line.newLineNo}-${index}`}
className={`grid grid-cols-[3rem,3rem,1.25rem,minmax(0,1fr)] gap-0 font-[var(--font-mono)] text-[12px] leading-[1.45] ${rowClass}`}
>
<span className="select-none border-r border-[#d8dee4] px-2 py-1 text-right text-[11px] text-[#57606a]">
{line.oldLineNo ?? ''}
</span>
<span className="select-none border-r border-[#d8dee4] px-2 py-1 text-right text-[11px] text-[#57606a]">
{line.newLineNo ?? ''}
</span>
<span className={`border-r border-[#d8dee4] px-1 py-1 text-center ${prefixColor}`}>{prefix}</span>
<span className="whitespace-pre-wrap break-words px-3 py-1 text-[#24292f]">{line.content}</span>
</div>
)
})}
</div>
</div>
</div>
)
}
function parsePatchLines(patch: string): DiffLine[] {
const output: DiffLine[] = []
const patchLines = patch.split('\n')
let oldLineNo = 0
let newLineNo = 0
for (const line of patchLines) {
if (
line.startsWith('Index:') ||
line.startsWith('===') ||
line.startsWith('---') ||
line.startsWith('+++')
) {
continue
}
if (line.startsWith('@@')) {
const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)/)
if (match?.[1]) oldLineNo = parseInt(match[1], 10) - 1
if (match?.[2]) newLineNo = parseInt(match[2], 10) - 1
continue
}
if (line.startsWith('\\ No newline')) continue
if (line.startsWith('+')) {
newLineNo += 1
output.push({ oldLineNo: null, newLineNo, type: 'added', content: line.slice(1) })
continue
}
if (line.startsWith('-')) {
oldLineNo += 1
output.push({ oldLineNo, newLineNo: null, type: 'removed', content: line.slice(1) })
continue
}
oldLineNo += 1
newLineNo += 1
output.push({
oldLineNo,
newLineNo,
type: 'context',
content: line.startsWith(' ') ? line.slice(1) : line,
})
}
return output
}
@@ -0,0 +1,91 @@
import { useEffect } from 'react'
import { Modal } from '../shared/Modal'
type GalleryImage = {
src: string
name: string
}
type Props = {
open: boolean
images: GalleryImage[]
activeIndex: number
onClose: () => void
onSelect: (index: number) => void
}
export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect }: Props) {
const activeImage = images[activeIndex]
useEffect(() => {
if (!open || images.length <= 1) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
onSelect((activeIndex - 1 + images.length) % images.length)
} else if (event.key === 'ArrowRight') {
event.preventDefault()
onSelect((activeIndex + 1) % images.length)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [activeIndex, images.length, onSelect, open])
if (!activeImage) return null
return (
<Modal open={open} onClose={onClose} width={960}>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{activeImage.name}</div>
<div className="text-xs text-[var(--color-text-tertiary)]">
{activeIndex + 1} / {images.length}
</div>
</div>
{images.length > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => onSelect((activeIndex - 1 + images.length) % images.length)}
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
aria-label="Previous image"
>
<span className="material-symbols-outlined text-[18px]">chevron_left</span>
</button>
<button
onClick={() => onSelect((activeIndex + 1) % images.length)}
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--color-border)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
aria-label="Next image"
>
<span className="material-symbols-outlined text-[18px]">chevron_right</span>
</button>
</div>
)}
</div>
<div className="flex max-h-[70vh] items-center justify-center overflow-hidden rounded-2xl bg-[#111]">
<img src={activeImage.src} alt={activeImage.name} className="max-h-[70vh] w-full object-contain" />
</div>
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{images.map((image, index) => (
<button
key={`${image.name}-${index}`}
onClick={() => onSelect(index)}
className={`overflow-hidden rounded-xl border transition-all ${
index === activeIndex
? 'border-[var(--color-brand)] shadow-[0_0_0_1px_var(--color-brand)]'
: 'border-[var(--color-border)]'
}`}
>
<img src={image.src} alt={image.name} className="h-16 w-16 object-cover" />
</button>
))}
</div>
)}
</div>
</Modal>
)
}
+142
View File
@@ -0,0 +1,142 @@
import { useRef, useEffect } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { UserMessage } from './UserMessage'
import { AssistantMessage } from './AssistantMessage'
import { ThinkingBlock } from './ThinkingBlock'
import { ToolCallBlock } from './ToolCallBlock'
import { ToolResultBlock } from './ToolResultBlock'
import { PermissionDialog } from './PermissionDialog'
import { AskUserQuestion } from './AskUserQuestion'
import { StreamingIndicator } from './StreamingIndicator'
import type { UIMessage } from '../../types/chat'
export function MessageList() {
const { messages, chatState, streamingText, activeThinkingId } = useChatStore()
const bottomRef = useRef<HTMLDivElement>(null)
// Auto-scroll to bottom on new messages
useEffect(() => {
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
}, [messages.length, streamingText])
// Build a map of toolUseId → tool_use message for linking results
const toolUseMap = new Map<string, Extract<UIMessage, { type: 'tool_use' }>>()
for (const msg of messages) {
if (msg.type === 'tool_use') {
toolUseMap.set(msg.toolUseId, msg)
}
}
// For each tool_use, find its matching tool_result
const toolResultMap = new Map<string, Extract<UIMessage, { type: 'tool_result' }>>()
for (const msg of messages) {
if (msg.type === 'tool_result' && msg.toolUseId) {
toolResultMap.set(msg.toolUseId, msg)
}
}
return (
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="mx-auto max-w-[860px]">
{messages.map((msg) => {
// Skip tool_results that are rendered inline within their ToolCallBlock
if (msg.type === 'tool_result' && toolUseMap.has(msg.toolUseId)) {
return null
}
return (
<MessageBlock
key={msg.id}
message={msg}
activeThinkingId={activeThinkingId}
toolResult={
msg.type === 'tool_use'
? toolResultMap.get(msg.toolUseId) ?? null
: null
}
/>
)
})}
{/* Streaming text (not yet committed as a message) */}
{streamingText && chatState === 'streaming' && (
<AssistantMessage content={streamingText} isStreaming />
)}
{/* Loading indicator */}
{chatState !== 'idle' && chatState !== 'streaming' && chatState !== 'permission_pending' && (
<StreamingIndicator />
)}
<div ref={bottomRef} />
</div>
</div>
)
}
function MessageBlock({
message,
activeThinkingId,
toolResult,
}: {
message: UIMessage
activeThinkingId: string | null
toolResult?: { content: unknown; isError: boolean } | null
}) {
switch (message.type) {
case 'user_text':
return <UserMessage content={message.content} attachments={message.attachments} />
case 'assistant_text':
return <AssistantMessage content={message.content} />
case 'thinking':
return <ThinkingBlock content={message.content} isActive={message.id === activeThinkingId} />
case 'tool_use':
// Special handling for AskUserQuestion tool calls
if (message.toolName === 'AskUserQuestion') {
return (
<AskUserQuestion
toolUseId={message.toolUseId}
input={message.input}
/>
)
}
return (
<ToolCallBlock
toolName={message.toolName}
input={message.input}
result={toolResult}
/>
)
case 'tool_result':
// Standalone tool_result (no matching tool_use found)
return (
<ToolResultBlock
content={message.content}
isError={message.isError}
standalone
/>
)
case 'permission_request':
return (
<PermissionDialog
requestId={message.requestId}
toolName={message.toolName}
input={message.input}
description={message.description}
/>
)
case 'error':
return (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-red-50 border border-red-200 text-sm text-[var(--color-error)]">
<strong>Error:</strong> {message.message}
</div>
)
case 'system':
return (
<div className="mb-4 text-center text-xs text-[var(--color-text-tertiary)]">
{message.content}
</div>
)
}
}
@@ -0,0 +1,256 @@
import { useState } from 'react'
import { useChatStore } from '../../stores/chatStore'
import { Button } from '../shared/Button'
import { DiffViewer } from './DiffViewer'
type Props = {
requestId: string
toolName: string
input: unknown
description?: string
}
/**
* Icons for known tool types.
* Uses Material Symbols Outlined names.
*/
const TOOL_META: Record<string, { icon: string; label: string; color: string }> = {
Bash: { icon: 'terminal', label: 'Bash', color: '#CA8A04' },
Edit: { icon: 'edit_note', label: 'Edit File', color: '#8F482F' },
Write: { icon: 'edit_document', label: 'Write File', color: '#16A34A' },
Read: { icon: 'description', label: 'Read File', color: '#2D628F' },
Glob: { icon: 'search', label: 'Glob Search', color: '#2D628F' },
Grep: { icon: 'find_in_page', label: 'Grep Search', color: '#2D628F' },
Agent: { icon: 'smart_toy', label: 'Agent', color: '#7C3AED' },
WebSearch: { icon: 'travel_explore', label: 'Web Search', color: '#2D628F' },
WebFetch: { icon: 'cloud_download', label: 'Web Fetch', color: '#2D628F' },
NotebookEdit: { icon: 'note', label: 'Notebook Edit', color: '#8F482F' },
Skill: { icon: 'auto_awesome', label: 'Skill', color: '#7C3AED' },
}
/**
* Extract human-readable detail lines from tool input.
*/
function extractToolDetails(toolName: string, input: unknown): { primary: string; secondary?: string } {
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
switch (toolName) {
case 'Bash': {
const cmd = typeof obj.command === 'string' ? obj.command : ''
const desc = typeof obj.description === 'string' ? obj.description : undefined
return { primary: cmd, secondary: desc }
}
case 'Edit': {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
return { primary: filePath, secondary: obj.old_string ? 'Replacing content in file' : undefined }
}
case 'Write': {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
return { primary: filePath }
}
case 'Read': {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
return { primary: filePath }
}
case 'Glob':
return { primary: typeof obj.pattern === 'string' ? obj.pattern : '' }
case 'Grep':
return { primary: typeof obj.pattern === 'string' ? obj.pattern : '' }
case 'Agent':
return { primary: typeof obj.description === 'string' ? obj.description : '' }
case 'WebSearch':
return { primary: typeof obj.query === 'string' ? obj.query : '' }
case 'WebFetch':
return { primary: typeof obj.url === 'string' ? obj.url : '' }
default:
return { primary: typeof input === 'string' ? input : JSON.stringify(input, null, 2) }
}
}
function getPermissionTitle(toolName: string, input: unknown) {
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
const fileName = filePath ? filePath.split('/').pop() || filePath : ''
switch (toolName) {
case 'Edit':
case 'Write':
return fileName ? `Allow Claude to ${toolName} ${fileName}?` : `Allow Claude to ${toolName.toLowerCase()} this file?`
case 'Bash':
return 'Allow Claude to run this command?'
default:
return `Allow Claude to use ${toolName}?`
}
}
function renderPermissionPreview(toolName: string, input: unknown) {
const obj = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file'
if (toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string') {
return <DiffViewer filePath={filePath} oldString={obj.old_string} newString={obj.new_string} />
}
if (toolName === 'Write' && typeof obj.content === 'string') {
return <DiffViewer filePath={filePath} oldString="" newString={obj.content} />
}
if (toolName === 'Bash' && typeof obj.command === 'string') {
return (
<div className="overflow-x-auto rounded-[var(--radius-md)] bg-[#1e1e1e] px-3 py-2.5">
<pre className="font-[var(--font-mono)] text-[11px] leading-[1.45] text-[#d4d4d4] whitespace-pre-wrap break-words">
<span className="text-[#28c840] select-none">$ </span>{obj.command}
</pre>
</div>
)
}
return null
}
export function PermissionDialog({ requestId, toolName, input, description }: Props) {
const { respondToPermission, pendingPermission } = useChatStore()
const isPending = pendingPermission?.requestId === requestId
const [showRaw, setShowRaw] = useState(false)
const meta = TOOL_META[toolName] || { icon: 'shield', label: toolName, color: '#87736D' }
const details = extractToolDetails(toolName, input)
const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
const preview = renderPermissionPreview(toolName, input)
const title = getPermissionTitle(toolName, input)
const allowRawToggle = !preview
return (
<div className={`mb-4 ml-10 overflow-hidden rounded-[var(--radius-lg)] border ${
isPending
? 'border-[var(--color-warning)] bg-[var(--color-surface-container-lowest)]'
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
}`}>
{/* Header */}
<div className={`flex items-center gap-3 px-4 py-3 ${
isPending
? 'bg-[var(--color-surface-container)]'
: 'bg-[var(--color-surface-container-low)]'
}`}>
<div
className="flex items-center justify-center w-8 h-8 rounded-[var(--radius-md)]"
style={{ backgroundColor: `${meta.color}18` }}
>
<span
className="material-symbols-outlined text-[18px]"
style={{ color: meta.color }}
>
{meta.icon}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{title}
</span>
{isPending && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-warning)]/15 text-[var(--color-warning)]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
Awaiting approval
</span>
)}
{!isPending && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
Responded
</span>
)}
</div>
{description && (
<p className="mt-0.5 text-xs text-[var(--color-text-secondary)] truncate">{description}</p>
)}
</div>
</div>
{/* Tool details */}
<div className="border-t border-[var(--color-outline-variant)]/20 px-4 py-3">
{preview ? (
<div className="space-y-2">
{details.primary && toolName !== 'Bash' ? (
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs font-[var(--font-mono)] text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)] flex-shrink-0">
folder_open
</span>
<span className="truncate">{details.primary}</span>
</div>
) : null}
{preview}
</div>
) : details.primary ? (
<div className="mb-2">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs font-[var(--font-mono)] text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)] flex-shrink-0">
{toolName === 'Glob' || toolName === 'Grep' ? 'search' : 'folder_open'}
</span>
<span className="truncate">{details.primary}</span>
</div>
</div>
) : null}
{/* Secondary detail */}
{details.secondary && (
<p className="mt-2 text-xs text-[var(--color-text-tertiary)]">{details.secondary}</p>
)}
{allowRawToggle && (
<button
onClick={() => setShowRaw(!showRaw)}
className="mt-2 flex cursor-pointer items-center gap-1 text-[11px] text-[var(--color-text-accent)] hover:underline"
>
<span className="material-symbols-outlined text-[14px]">
{showRaw ? 'expand_less' : 'expand_more'}
</span>
{showRaw ? 'Hide details' : 'Show full input'}
</button>
)}
{allowRawToggle && showRaw && (
<pre className="mt-2 max-h-[220px] overflow-y-auto overflow-x-auto rounded-[var(--radius-md)] bg-[#1e1e1e] px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.45] text-[#d4d4d4] whitespace-pre-wrap break-words">
{rawInput}
</pre>
)}
</div>
{/* Action buttons */}
{isPending && (
<div className="flex items-center gap-2 border-t border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] px-4 py-3">
<Button
variant="primary"
size="sm"
onClick={() => respondToPermission(requestId, true)}
icon={
<span className="material-symbols-outlined text-[14px]">check</span>
}
>
Allow
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => respondToPermission(requestId, true, 'always')}
icon={
<span className="material-symbols-outlined text-[14px]">verified</span>
}
>
Allow for session
</Button>
<div className="flex-1" />
<Button
variant="danger"
size="sm"
onClick={() => respondToPermission(requestId, false)}
icon={
<span className="material-symbols-outlined text-[14px]">close</span>
}
>
Deny
</Button>
</div>
)}
</div>
)
}
@@ -0,0 +1,26 @@
import { useChatStore } from '../../stores/chatStore'
export function StreamingIndicator() {
const { chatState, elapsedSeconds, tokenUsage } = useChatStore()
const verb = chatState === 'thinking'
? 'Thinking'
: chatState === 'tool_executing'
? 'Executing'
: 'Crafting'
return (
<div className="mb-4 ml-10 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/60 bg-[var(--color-surface-container-low)] px-3 py-1.5">
<span className="text-[var(--color-brand)] animate-shimmer text-sm">✦</span>
<span className="text-sm font-medium text-[var(--color-brand)]">{verb}...</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
{elapsedSeconds}s
</span>
{tokenUsage.output_tokens > 0 && (
<span className="text-xs text-[var(--color-text-tertiary)]">
· ↓ {tokenUsage.output_tokens} tokens
</span>
)}
</div>
)
}
@@ -0,0 +1,35 @@
import type { ReactNode } from 'react'
type Props = {
title?: string
children: ReactNode
className?: string
}
/**
* macOS-style terminal window decoration with traffic light buttons.
* Reusable wrapper for Bash commands, tool results, and code viewers.
*/
export function TerminalChrome({ title, children, className = '' }: Props) {
return (
<div className={`overflow-hidden rounded-2xl border border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-dim)] ${className}`}>
{/* Title bar with traffic lights */}
<div className="flex items-center gap-2 border-b border-[#1a1a1a] bg-[#2d2d2d] px-3 py-2">
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-[#ff5f57]" />
<div className="w-2.5 h-2.5 rounded-full bg-[#febc2e]" />
<div className="w-2.5 h-2.5 rounded-full bg-[#28c840]" />
</div>
{title && (
<span className="ml-2 truncate font-[var(--font-mono)] text-[10px] text-[#999]">
{title}
</span>
)}
</div>
{/* Content */}
<div className="bg-[#1e1e1e] text-[#d4d4d4]">
{children}
</div>
</div>
)
}
@@ -0,0 +1,90 @@
import { useState, useEffect, useRef } from 'react'
export function ThinkingBlock({ content, isActive = false }: { content: string; isActive?: boolean }) {
const [expanded, setExpanded] = useState(false)
// Auto-scroll to bottom of content area when expanded and content is still growing
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (expanded && isActive && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [content, expanded, isActive])
const showCursor = isActive && expanded
const inlinePreview = content.replace(/\s+/g, ' ').trim()
const collapsedText = inlinePreview.slice(0, 140)
const hasOverflow = inlinePreview.length > collapsedText.length
return (
<div className="mb-2 ml-10">
<style>{thinkingStyles}</style>
<button
onClick={() => setExpanded((value) => !value)}
className="flex w-full items-center gap-2 rounded-lg px-1 py-1 text-left text-[12px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-secondary)]"
>
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--color-surface-container-high)] text-[8px] text-[var(--color-outline)]">
{expanded ? '\u25BE' : '\u25B8'}
</span>
<span className="shrink-0 font-medium italic">
Thinking
{isActive && <span className="thinking-dots" />}
</span>
{!expanded && (
<span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-text-tertiary)]">
{collapsedText}
{hasOverflow ? '…' : ''}
{isActive && !expanded ? <span className="thinking-inline-cursor" /> : null}
</span>
)}
</button>
{expanded && (
<div className="mt-1 rounded-2xl border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] px-3 py-3">
<div
ref={contentRef}
className="max-h-[220px] overflow-y-auto whitespace-pre-wrap rounded-xl bg-[var(--color-surface)] font-[var(--font-mono)] text-[11px] leading-[1.45] text-[var(--color-text-secondary)]"
>
{content}
{showCursor && <span className="thinking-cursor" />}
</div>
</div>
)}
</div>
)
}
/** Shared keyframe styles injected once per ThinkingBlock mount */
const thinkingStyles = `
@keyframes thinking-cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes thinking-dots {
0%, 20% { content: ''; }
40% { content: '.'; }
60% { content: '..'; }
80%, 100% { content: '...'; }
}
.thinking-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--color-text-tertiary);
vertical-align: middle;
margin-left: 1px;
animation: thinking-cursor-blink 1s step-end infinite;
}
.thinking-inline-cursor {
display: inline-block;
width: 1px;
height: 0.95em;
margin-left: 3px;
vertical-align: text-bottom;
background: var(--color-text-tertiary);
animation: thinking-cursor-blink 1s step-end infinite;
}
.thinking-dots::after {
content: '';
animation: thinking-dots 1.4s steps(1, end) infinite;
}
`
@@ -0,0 +1,267 @@
import { useMemo, useState } from 'react'
import { CodeViewer } from './CodeViewer'
import { DiffViewer } from './DiffViewer'
import { TerminalChrome } from './TerminalChrome'
import { CopyButton } from '../shared/CopyButton'
type Props = {
toolName: string
input: unknown
result?: { content: unknown; isError: boolean } | null
}
const TOOL_ICONS: Record<string, string> = {
Bash: 'terminal',
Read: 'description',
Write: 'edit_document',
Edit: 'edit_note',
Glob: 'search',
Grep: 'find_in_page',
Agent: 'smart_toy',
WebSearch: 'travel_explore',
WebFetch: 'cloud_download',
NotebookEdit: 'note',
Skill: 'auto_awesome',
}
const STATUS_BADGE: Record<string, { label: string; className: string }> = {
Edit: { label: 'MODIFIED', className: 'bg-[var(--color-primary-fixed)] text-[#75331C]' },
Write: { label: 'CREATED', className: 'bg-[#D4EAB4] text-[#3B4C24]' },
Read: { label: 'READ', className: 'bg-[var(--color-surface-container-high)] text-[var(--color-outline)]' },
Bash: { label: 'EXECUTED', className: 'bg-[var(--color-surface-container-high)] text-[var(--color-outline)]' },
}
export function ToolCallBlock({ toolName, input, result }: Props) {
const [expanded, setExpanded] = useState(false)
const obj = input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
const icon = TOOL_ICONS[toolName] || 'build'
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
const summary = getToolSummary(toolName, obj)
const outputSummary = getToolResultSummary(toolName, result?.content)
const inlineError = getInlineErrorSummary(result)
const badge = result
? result.isError
? { label: 'ERROR', className: 'bg-[var(--color-error-container)] text-[var(--color-error)]' }
: { label: 'SUCCESS', className: 'bg-[#D4EAB4] text-[#3B4C24]' }
: STATUS_BADGE[toolName]
const preview = useMemo(() => renderPreview(toolName, obj, result), [obj, result, toolName])
const details = useMemo(() => renderDetails(toolName, obj), [obj, toolName])
const expandable = toolName === 'Edit' || toolName === 'Write'
return (
<div className="mb-2 ml-10 overflow-hidden rounded-xl border border-[var(--color-border)]/65 bg-[var(--color-surface-container-lowest)]">
<button
type="button"
onClick={() => {
if (expandable) {
setExpanded((value) => !value)
}
}}
className="flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
>
<div className="flex min-w-0 flex-1 items-start gap-3">
<span className="material-symbols-outlined mt-0.5 text-[16px] text-[var(--color-outline)]">{icon}</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--color-outline)]">
{toolName}
</span>
{badge && (
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.14em] ${badge.className}`}>
{badge.label}
</span>
)}
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-2 text-[11px] leading-[1.35] text-[var(--color-text-tertiary)]">
{filePath ? (
<span className="max-w-full truncate rounded-md bg-[var(--color-surface-container-low)] px-2 py-1 font-[var(--font-mono)] text-[10px] text-[var(--color-text-secondary)]">
{filePath}
</span>
) : null}
{summary ? (
<span className="min-w-0 truncate font-[var(--font-mono)] text-[10px] text-[var(--color-text-tertiary)]">
{summary}
</span>
) : null}
{result && outputSummary ? (
<span className="min-w-0 truncate text-[10px] text-[var(--color-outline)]">
{outputSummary}
</span>
) : null}
</div>
{inlineError ? (
<div className="mt-1 truncate text-[10px] text-[var(--color-error)]">
{inlineError}
</div>
) : null}
</div>
</div>
{expandable ? (
<span className="material-symbols-outlined text-[16px] text-[var(--color-outline)]">
{expanded ? 'expand_less' : 'expand_more'}
</span>
) : null}
</button>
{expandable && expanded && (
<div className="space-y-2.5 border-t border-[var(--color-border)]/60 px-3 py-3">
{preview}
{details}
</div>
)}
</div>
)
}
function renderPreview(
toolName: string,
obj: Record<string, unknown>,
result?: { content: unknown; isError: boolean } | null,
) {
const filePath = typeof obj.file_path === 'string' ? obj.file_path : 'file'
if (toolName === 'Edit' && typeof obj.old_string === 'string' && typeof obj.new_string === 'string') {
return <DiffViewer filePath={filePath} oldString={obj.old_string} newString={obj.new_string} />
}
if (toolName === 'Write' && typeof obj.content === 'string') {
return <DiffViewer filePath={filePath} oldString="" newString={obj.content} />
}
if (toolName === 'Bash' && typeof obj.command === 'string') {
return (
<TerminalChrome title={typeof obj.description === 'string' ? obj.description : filePath}>
<div className="px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.45] text-[#d8d8d8]">
<span className="text-[#28c840]">$</span> {obj.command}
</div>
</TerminalChrome>
)
}
if (toolName === 'Read') {
return null
}
if (result) {
const text = extractTextContent(result.content)
if (text) {
return (
<div className={`overflow-hidden rounded-2xl border ${
result.isError
? 'border-[var(--color-error)]/20 bg-[var(--color-error-container)]/60'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}>
<div className="flex items-center justify-between border-b border-[var(--color-border)]/60 px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{result.isError ? 'Error Output' : 'Tool Output'}</span>
<CopyButton
text={text}
className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[10px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
</div>
<CodeViewer code={text} language="plaintext" maxLines={18} />
</div>
)
}
}
return null
}
function renderDetails(toolName: string, obj: Record<string, unknown>) {
if (toolName === 'Edit' || toolName === 'Write') {
return null
}
const text = JSON.stringify(obj, null, 2)
return (
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>Tool Input</span>
<CopyButton
text={text}
className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[10px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
</div>
<CodeViewer code={text} language="json" maxLines={18} />
</div>
)
}
function getToolResultSummary(toolName: string, content: unknown): string {
if (toolName === 'Bash') return ''
const text = extractTextContent(content)
if (!text) return ''
const lineCount = text.split('\n').length
if (lineCount > 1) {
return `${lineCount} lines output`
}
const compact = text.replace(/\s+/g, ' ').trim()
if (!compact) return ''
if (compact.length <= 36) return compact
return `${compact.slice(0, 36)}…`
}
function getInlineErrorSummary(result?: { content: unknown; isError: boolean } | null) {
if (!result?.isError) return ''
const text = extractTextContent(result.content)?.replace(/\s+/g, ' ').trim() || ''
if (!text) return 'Tool failed'
if (text.length <= 120) return text
return `${text.slice(0, 120)}…`
}
function getToolSummary(toolName: string, obj: Record<string, unknown>): string {
switch (toolName) {
case 'Bash':
return typeof obj.command === 'string' ? obj.command : ''
case 'Read':
return typeof obj.limit === 'number' ? `Read file contents` : 'Read file contents'
case 'Write':
return typeof obj.content === 'string' ? `${obj.content.split('\n').length} lines created` : 'Create file'
case 'Edit':
return typeof obj.old_string === 'string' && typeof obj.new_string === 'string'
? changedLineSummary(obj.old_string, obj.new_string)
: 'Update file contents'
case 'Glob':
return typeof obj.pattern === 'string' ? obj.pattern : ''
case 'Grep':
return typeof obj.pattern === 'string' ? obj.pattern : ''
case 'Agent':
return typeof obj.description === 'string' ? obj.description : ''
default:
return ''
}
}
function extractTextContent(content: unknown): string | null {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((chunk: any) => (typeof chunk === 'string' ? chunk : chunk?.text || ''))
.filter(Boolean)
.join('\n')
}
if (content && typeof content === 'object') {
return JSON.stringify(content, null, 2)
}
return null
}
function changedLineSummary(oldString: string, newString: string): string {
const oldLines = oldString.split('\n')
const newLines = newString.split('\n')
let changed = 0
const max = Math.max(oldLines.length, newLines.length)
for (let index = 0; index < max; index += 1) {
if ((oldLines[index] ?? '') !== (newLines[index] ?? '')) {
changed += 1
}
}
return `${changed} lines changed`
}
@@ -0,0 +1,101 @@
import { CodeViewer } from './CodeViewer'
import { useState } from 'react'
type Props = {
content: unknown
isError: boolean
toolName?: string
standalone?: boolean
}
/**
* Standalone tool result block — only shown when not already rendered
* inline within ToolCallBlock (i.e., when the tool_use and tool_result
* are NOT grouped together by MessageList).
*/
export function ToolResultBlock({ content, isError, toolName, standalone = true }: Props) {
const [expanded, setExpanded] = useState(false)
// Don't render standalone if this result is already rendered inline
if (!standalone) return null
const text = extractText(content)
const preview = text.slice(0, 200)
const hasMore = text.length > 200
return (
<div className={`mb-2 ml-10 overflow-hidden rounded-xl border ${
isError
? 'border-[var(--color-error)]/20'
: 'border-[var(--color-outline-variant)]/20'
}`}>
{/* Status header */}
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className={`flex w-full items-center justify-between px-3 py-2 text-left text-[10px] font-bold uppercase tracking-wider ${
isError
? 'bg-[var(--color-error-container)] text-[var(--color-error)]'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-outline)]'
}`}
>
<span className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[12px]">
{isError ? 'error' : 'check_circle'}
</span>
{toolName ? `${toolName} result` : 'Tool result'}
</span>
<span className={`px-2 py-0.5 rounded-full text-[9px] ${
isError
? 'bg-[var(--color-error)]/10'
: 'bg-[#D4EAB4] text-[#3B4C24]'
}`}>
{isError ? 'ERROR' : 'SUCCESS'}
</span>
</button>
{/* Content */}
{expanded ? (
isError ? (
<div className="bg-[var(--color-error-container)]/50 px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.5] whitespace-pre-wrap break-words text-[var(--color-error)]">
{text}
</div>
) : (
<CodeViewer
code={text}
language="plaintext"
maxLines={12}
/>
)
) : (
<div className="bg-[var(--color-surface-container-lowest)] px-3 py-2 font-[var(--font-mono)] text-[10px] leading-[1.35] text-[var(--color-text-tertiary)]">
{preview}
{hasMore ? '…' : ''}
</div>
)}
{hasMore && (
<button
onClick={() => setExpanded((value) => !value)}
className="w-full py-1 text-[10px] font-medium text-[var(--color-text-accent)] hover:underline bg-[var(--color-surface-container-low)] border-t border-[var(--color-outline-variant)]/10"
>
{expanded ? 'Show less' : `Show ${text.length - 200} more characters`}
</button>
)}
</div>
)
}
function extractText(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map((c: any) => (typeof c === 'string' ? c : c?.text || ''))
.filter(Boolean)
.join('\n')
}
if (content && typeof content === 'object') {
return JSON.stringify(content, null, 2)
}
return String(content ?? '')
}
@@ -0,0 +1,30 @@
import type { UIAttachment } from '../../types/chat'
import { AttachmentGallery } from './AttachmentGallery'
type Props = {
content: string
attachments?: UIAttachment[]
}
export function UserMessage({ content, attachments }: Props) {
const hasText = content.trim().length > 0
return (
<div className="mb-5 flex justify-end">
<div className="max-w-[82%] space-y-2">
{attachments && attachments.length > 0 && (
<AttachmentGallery attachments={attachments} variant="message" />
)}
{hasText && (
<div
className="bg-[var(--color-surface-user-msg)] px-4 py-3 text-sm leading-relaxed text-[var(--color-text-primary)] whitespace-pre-wrap break-words"
style={{ borderRadius: '18px 4px 18px 18px' }}
>
{content}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { ThinkingBlock } from './ThinkingBlock'
import { ToolCallBlock } from './ToolCallBlock'
import { PermissionDialog } from './PermissionDialog'
import { useChatStore } from '../../stores/chatStore'
describe('chat blocks', () => {
beforeEach(() => {
useChatStore.setState({
chatState: 'idle',
messages: [],
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
elapsedSeconds: 0,
})
})
it('keeps thinking collapsed by default', () => {
const { container } = render(<ThinkingBlock content="this is a long internal reasoning trace" isActive />)
expect(screen.getByText(/Thinking/)).toBeTruthy()
expect(container.textContent).toContain('this is a long internal reasoning trace')
expect(container.querySelector('.thinking-cursor')).toBeNull()
})
it('does not animate inactive historical thinking blocks', () => {
const { container } = render(<ThinkingBlock content="old reasoning" isActive={false} />)
expect(container.querySelector('.thinking-inline-cursor')).toBeNull()
})
it('shows tool previews only after expanding the tool block', () => {
const { container } = render(
<ToolCallBlock
toolName="Read"
input={{ file_path: '/tmp/example.ts', limit: 20 }}
result={{ content: 'const answer = 42\nconsole.log(answer)', isError: false }}
/>,
)
expect(container.textContent).toContain('Read')
expect(container.textContent).not.toContain('const answer = 42')
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).not.toContain('Tool Input')
expect(container.textContent).not.toContain('const answer = 42')
})
it('does not surface bash stdout in the transcript preview', () => {
const { container } = render(
<ToolCallBlock
toolName="Bash"
input={{ command: 'ls -la', description: 'List files' }}
result={{ content: 'file-a\nfile-b\nfile-c', isError: false }}
/>,
)
expect(container.textContent).toContain('Bash')
expect(container.textContent).not.toContain('file-a')
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).toContain('ls -la')
expect(container.textContent).not.toContain('file-a')
})
it('shows a diff preview for edit permission requests', () => {
useChatStore.setState({
pendingPermission: {
requestId: 'perm-1',
toolName: 'Edit',
input: {
file_path: '/tmp/example.ts',
old_string: 'const count = 1',
new_string: 'const count = 2',
},
},
})
const { container } = render(
<PermissionDialog
requestId="perm-1"
toolName="Edit"
input={{
file_path: '/tmp/example.ts',
old_string: 'const count = 1',
new_string: 'const count = 2',
}}
/>,
)
expect(container.textContent).toContain('/tmp/example.ts')
expect(container.textContent).toContain('const count = 1')
expect(container.textContent).toContain('const count = 2')
})
})
+25
View File
@@ -0,0 +1,25 @@
export async function copyTextToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return true
}
} catch {
// Fall through to legacy copy path.
}
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const copied = document.execCommand('copy')
document.body.removeChild(textarea)
return copied
} catch {
return false
}
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { findSlashToken, insertSlashTrigger, replaceSlashCommand } from './composerUtils'
describe('composerUtils', () => {
it('finds slash token without trailing space', () => {
expect(findSlashToken('/rev', 4)).toEqual({ start: 0, filter: 'rev' })
expect(findSlashToken('hello /rev', 10)).toEqual({ start: 6, filter: 'rev' })
})
it('does not treat slash followed by a space as an active token', () => {
expect(findSlashToken('/ review', 8)).toBeNull()
})
it('inserts a slash trigger without appending a trailing space', () => {
expect(insertSlashTrigger('', 0)).toEqual({ value: '/', cursorPos: 1 })
expect(insertSlashTrigger('hello', 5)).toEqual({ value: 'hello /', cursorPos: 7 })
})
it('replaces the current slash token with a command and one trailing separator', () => {
expect(replaceSlashCommand('/rev', 4, 'review')).toEqual({
value: '/review ',
cursorPos: 8,
})
})
})
@@ -0,0 +1,112 @@
export const FALLBACK_SLASH_COMMANDS = [
{ name: 'compact', description: 'Compact conversation context' },
{ name: 'clear', description: 'Clear conversation history' },
{ name: 'help', description: 'Show available commands' },
{ name: 'review', description: 'Review code changes' },
{ name: 'commit', description: 'Create a git commit' },
{ name: 'pr', description: 'Create a pull request' },
{ name: 'init', description: 'Initialize project CLAUDE.md' },
{ name: 'bug', description: 'Report a bug' },
{ name: 'config', description: 'Open configuration' },
{ name: 'cost', description: 'Show token usage and costs' },
{ name: 'doctor', description: 'Diagnose installation issues' },
{ name: 'login', description: 'Switch Anthropic accounts' },
{ name: 'logout', description: 'Sign out of current account' },
{ name: 'memory', description: 'Edit CLAUDE.md memory files' },
{ name: 'model', description: 'Switch AI model' },
{ name: 'permissions', description: 'View or manage tool permissions' },
{ name: 'status', description: 'Show project and session status' },
{ name: 'terminal-setup', description: 'Set up terminal integration' },
{ name: 'vim', description: 'Toggle vim editing mode' },
]
export type SlashTrigger = {
slashPos: number
filter: string
}
export function findSlashTrigger(value: string, cursorPos: number): SlashTrigger | null {
const textBeforeCursor = value.slice(0, cursorPos)
let slashPos = -1
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
const ch = textBeforeCursor[i]!
if (ch === '/') {
if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) {
slashPos = i
break
}
break
}
if (/\s/.test(ch)) {
break
}
}
if (slashPos < 0) return null
const filter = textBeforeCursor.slice(slashPos + 1)
if (/\s/.test(filter)) return null
return { slashPos, filter }
}
export function replaceSlashToken(
input: string,
cursorPos: number,
command: string,
options?: { trailingSpace?: boolean },
): { value: string; cursorPos: number } {
const trigger = findSlashTrigger(input, cursorPos)
if (!trigger) {
const prefix = input && !/\s$/.test(input) ? `${input} ` : input
const token = `/${command}`
const suffix = options?.trailingSpace !== false ? ' ' : ''
const value = `${prefix}${token}${suffix}`
return { value, cursorPos: value.length }
}
const before = input.slice(0, trigger.slashPos)
const after = input.slice(cursorPos)
const token = `/${command}`
const suffix = options?.trailingSpace !== false ? ' ' : ''
const value = `${before}${token}${suffix}${after}`
const nextCursorPos = before.length + token.length + suffix.length
return { value, cursorPos: nextCursorPos }
}
export type SlashToken = {
start: number
filter: string
}
export function findSlashToken(value: string, cursorPos: number): SlashToken | null {
const trigger = findSlashTrigger(value, cursorPos)
if (!trigger) return null
return { start: trigger.slashPos, filter: trigger.filter }
}
export function replaceSlashCommand(
value: string,
cursorPos: number,
command: string,
): { value: string; cursorPos: number } | null {
const trigger = findSlashTrigger(value, cursorPos)
if (!trigger) return null
return replaceSlashToken(value, cursorPos, command, { trailingSpace: true })
}
export function insertSlashTrigger(
value: string,
cursorPos: number,
): { value: string; cursorPos: number } {
const before = value.slice(0, cursorPos)
const after = value.slice(cursorPos)
const needsLeadingSpace = before.length > 0 && !/\s$/.test(before)
const token = `${needsLeadingSpace ? ' ' : ''}/`
return {
value: `${before}${token}${after}`,
cursorPos: before.length + token.length,
}
}
@@ -0,0 +1,26 @@
import hljs from 'highlight.js'
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export function highlightCodeLines(code: string, language?: string): string[] {
return code.split('\n').map((line) => highlightCodeLine(line, language))
}
function highlightCodeLine(line: string, language?: string): string {
const safeLine = line.length > 0 ? line : ' '
try {
if (language && hljs.getLanguage(language)) {
return hljs.highlight(safeLine, { language }).value
}
return hljs.highlightAuto(safeLine).value
} catch {
return escapeHtml(safeLine)
}
}
@@ -0,0 +1,137 @@
import { useState, useRef, useEffect } from 'react'
import { useSettingsStore } from '../../stores/settingsStore'
import type { EffortLevel } from '../../types/settings'
const MODEL_ICONS = {
opus: 'diamond',
sonnet: 'auto_awesome',
haiku: 'bolt',
} as const
const EFFORT_OPTIONS: { value: EffortLevel; label: string }[] = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'max', label: 'Max' },
]
export function ModelSelector() {
const { currentModel, availableModels, effortLevel, setModel, setEffort } = useSettingsStore()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
}
}, [open])
const getModelIcon = (id: string): string => {
const lower = id.toLowerCase()
if (lower.includes('opus')) return MODEL_ICONS.opus
if (lower.includes('sonnet')) return MODEL_ICONS.sonnet
if (lower.includes('haiku')) return MODEL_ICONS.haiku
return 'smart_toy'
}
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs font-medium text-[var(--color-text-secondary)] transition-colors"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">auto_awesome</span>
<span>{currentModel?.name ?? 'Select model'}</span>
<span className="material-symbols-outlined text-[12px]">expand_more</span>
</button>
{open && (
<div className="absolute right-0 bottom-full mb-2 w-[340px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50">
{/* Models */}
<div className="p-3">
<div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] mb-2 px-1">
Model Configuration
</div>
<div className="space-y-1">
{availableModels.map((model) => {
const isSelected = model.id === currentModel?.id
return (
<button
key={model.id}
onClick={() => { setModel(model.id); setOpen(false) }}
className={`
w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors
${isSelected
? 'bg-[var(--color-primary-fixed)] border border-[var(--color-brand)]/20'
: 'hover:bg-[var(--color-surface-hover)]'
}
`}
>
{/* Radio button */}
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
isSelected
? 'border-[var(--color-brand)]'
: 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">
{getModelIcon(model.id)}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
{model.description && (
<div className="text-[10px] text-[var(--color-text-tertiary)] mt-0.5 truncate">{model.description}</div>
)}
</div>
</button>
)
})}
</div>
</div>
{/* Effort */}
<div className="border-t border-[var(--color-border)] p-3">
<div className="text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] mb-2 px-1">
Effort
</div>
<div className="grid grid-cols-4 gap-1.5">
{EFFORT_OPTIONS.map((opt) => {
const isSelected = opt.value === effortLevel
return (
<button
key={opt.value}
onClick={() => { setEffort(opt.value); setOpen(false) }}
className={`
py-2 rounded-lg text-xs font-semibold transition-colors text-center
${isSelected
? 'bg-[var(--color-brand)] text-white'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
{opt.label}
</button>
)
})}
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,127 @@
import { useState, useRef, useEffect } from 'react'
import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
import type { PermissionMode } from '../../types/settings'
const PERMISSION_ITEMS: Array<{
value: PermissionMode
label: string
description: string
icon: string
color?: string
}> = [
{
value: 'default',
label: 'Ask permissions',
description: 'Confirm file edits and higher-risk commands when CLI asks',
icon: 'verified_user',
},
{
value: 'acceptEdits',
label: 'Auto accept edits',
description: 'Claude writes to disk without asking',
icon: 'bolt',
},
{
value: 'plan',
label: 'Plan mode',
description: 'Architecture & reasoning only, no files',
icon: 'architecture',
color: 'text-[var(--color-text-tertiary)]',
},
{
value: 'bypassPermissions',
label: 'Bypass permissions',
description: 'Full tool access for shell and file system',
icon: 'gavel',
color: 'text-[var(--color-error)]',
},
]
const MODE_ICONS: Record<PermissionMode, string> = {
default: 'verified_user',
acceptEdits: 'bolt',
plan: 'architecture',
bypassPermissions: 'gavel',
dontAsk: 'gavel',
}
const MODE_LABELS: Record<PermissionMode, string> = {
default: 'Ask permissions',
acceptEdits: 'Auto accept',
plan: 'Plan mode',
bypassPermissions: 'Bypass',
dontAsk: 'Don\'t ask',
}
export function PermissionModeSelector() {
const { permissionMode, setPermissionMode } = useSettingsStore()
const setSessionPermissionMode = useChatStore((s) => s.setSessionPermissionMode)
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
}
}, [open])
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs font-medium text-[var(--color-text-secondary)] transition-colors"
>
<span className="material-symbols-outlined text-[14px]">{MODE_ICONS[permissionMode]}</span>
<span>{MODE_LABELS[permissionMode]}</span>
<span className="material-symbols-outlined text-[12px]">expand_more</span>
</button>
{open && (
<div className="absolute left-0 bottom-full mb-2 w-[320px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50 py-2">
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
Execution Permissions
</div>
{PERMISSION_ITEMS.map((item) => (
<button
key={item.value}
onClick={() => {
void setPermissionMode(item.value)
setSessionPermissionMode(item.value)
setOpen(false)
}}
className={`
w-full flex items-start gap-3 px-4 py-3 text-left transition-colors
hover:bg-[var(--color-surface-hover)]
${item.value === permissionMode ? 'bg-[var(--color-surface-selected)]' : ''}
`}
>
<span className={`material-symbols-outlined text-[20px] mt-0.5 ${item.color || 'text-[var(--color-text-secondary)]'}`}>
{item.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{item.label}</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{item.description}</div>
</div>
{item.value === permissionMode && (
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)] mt-0.5" style={{ fontVariationSettings: "'FILL' 1" }}>
check_circle
</span>
)}
</button>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,26 @@
import { useEffect } from 'react'
import { Sidebar } from './Sidebar'
import { ContentRouter } from './ContentRouter'
import { ToastContainer } from '../shared/Toast'
import { useSettingsStore } from '../../stores/settingsStore'
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
export function AppShell() {
const fetchSettings = useSettingsStore((s) => s.fetchAll)
useEffect(() => {
fetchSettings()
}, [fetchSettings])
useKeyboardShortcuts()
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className="flex-1 flex flex-col overflow-hidden">
<ContentRouter />
</main>
<ToastContainer />
</div>
)
}
@@ -0,0 +1,69 @@
import { useUIStore } from '../../stores/uiStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTeamStore } from '../../stores/teamStore'
import { EmptySession } from '../../pages/EmptySession'
import { ActiveSession } from '../../pages/ActiveSession'
import { ScheduledTasks } from '../../pages/ScheduledTasks'
import { Settings } from '../../pages/Settings'
import { AgentTranscript } from '../../pages/AgentTranscript'
export function ContentRouter() {
const activeView = useUIStore((s) => s.activeView)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const viewingAgentId = useTeamStore((s) => s.viewingAgentId)
if (activeView === 'settings') {
return <Settings />
}
if (activeView === 'scheduled') {
return <ScheduledTasks />
}
if (activeView === 'terminal') {
return <TerminalPlaceholder />
}
if (activeView === 'history') {
// If viewing an agent transcript, show that
if (viewingAgentId) {
return <AgentTranscript />
}
return <HistoryPlaceholder />
}
// Code view
if (!activeSessionId) {
return <EmptySession />
}
if (viewingAgentId) {
return <AgentTranscript />
}
return <ActiveSession />
}
function TerminalPlaceholder() {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-[#1e1e1e] text-[#d4d4d4]">
<span className="material-symbols-outlined text-[48px] text-[#555] mb-4">terminal</span>
<h2 className="text-lg font-semibold text-[#999] mb-2">Terminal</h2>
<p className="text-sm text-[#666] max-w-sm text-center">
Integrated terminal coming soon. Use the Code tab to interact with Claude.
</p>
</div>
)
}
function HistoryPlaceholder() {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-[var(--color-surface)]">
<span className="material-symbols-outlined text-[48px] text-[var(--color-outline)] mb-4">history</span>
<h2 className="text-lg font-semibold text-[var(--color-text-secondary)] mb-2">History</h2>
<p className="text-sm text-[var(--color-text-tertiary)] max-w-sm text-center">
View session history and agent transcripts. Select a session from the sidebar to view its history.
</p>
</div>
)
}
+275
View File
@@ -0,0 +1,275 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore'
import type { SessionListItem } from '../../types/session'
export function Sidebar() {
const { sessions, activeSessionId, setActiveSession, fetchSessions, deleteSession, renameSession } = useSessionStore()
const { activeView, setActiveView } = useUIStore()
const [searchQuery, setSearchQuery] = useState('')
const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
useEffect(() => {
fetchSessions()
}, [fetchSessions])
// Close context menu on click outside
useEffect(() => {
if (!contextMenu) return
const close = () => setContextMenu(null)
document.addEventListener('click', close)
return () => document.removeEventListener('click', close)
}, [contextMenu])
// Filter sessions by search query — already sorted by modifiedAt from API
const filteredSessions = searchQuery
? sessions.filter((s) => s.title.toLowerCase().includes(searchQuery.toLowerCase()))
: sessions
// Group filtered sessions by projectPath, sorted by most recent session per group
const groupedSessions = useMemo(() => {
const groups = new Map<string, SessionListItem[]>()
for (const session of filteredSessions) {
const key = session.projectPath || '_unknown'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(session)
}
// Sort sessions within each group by most recent first
for (const list of groups.values()) {
list.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime())
}
// Sort groups by their most recent session
const sorted = [...groups.entries()].sort((a, b) => {
const aLatest = new Date(a[1][0]?.modifiedAt ?? 0).getTime()
const bLatest = new Date(b[1][0]?.modifiedAt ?? 0).getTime()
return bLatest - aLatest
})
return sorted.map(([path, items]) => ({
projectPath: path,
displayName: getProjectDisplayName(path),
sessions: items,
}))
}, [filteredSessions])
const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
e.preventDefault()
setContextMenu({ id, x: e.clientX, y: e.clientY })
}, [])
const handleDelete = useCallback(async (id: string) => {
setContextMenu(null)
await deleteSession(id)
}, [deleteSession])
const handleStartRename = useCallback((id: string, currentTitle: string) => {
setContextMenu(null)
setRenamingId(id)
setRenameValue(currentTitle)
}, [])
const handleFinishRename = useCallback(async () => {
if (renamingId && renameValue.trim()) {
await renameSession(renamingId, renameValue.trim())
}
setRenamingId(null)
setRenameValue('')
}, [renamingId, renameValue, renameSession])
return (
<aside className="w-[var(--sidebar-width)] h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none">
{/* Navigation */}
<div className="p-3 flex flex-col gap-0.5">
<NavItem
active={activeView === 'code' && !activeSessionId}
onClick={() => { setActiveView('code'); setActiveSession(null) }}
icon={<PlusIcon />}
>
New session
</NavItem>
<NavItem
active={activeView === 'scheduled'}
onClick={() => setActiveView('scheduled')}
icon={<ClockIcon />}
>
Scheduled
</NavItem>
</div>
{/* Search */}
<div className="px-3 pb-2">
<input
id="sidebar-search"
type="text"
placeholder="Search sessions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-7 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
/>
</div>
{/* Session list — grouped by project directory */}
<div className="flex-1 overflow-y-auto px-3">
{filteredSessions.length === 0 && (
<div className="px-3 py-4 text-xs text-[var(--color-text-tertiary)] text-center">
{searchQuery ? 'No matching sessions' : 'No sessions yet'}
</div>
)}
{groupedSessions.map((group) => (
<div key={group.projectPath} className="mb-1">
{/* Project group header */}
<div className="px-2 pt-3 pb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)] tracking-wide truncate">
{group.displayName}
</div>
{/* Sessions in this group */}
{group.sessions.map((session) => (
<div key={session.id} className="relative">
{renamingId === session.id ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishRename()
if (e.key === 'Escape') { setRenamingId(null); setRenameValue('') }
}}
className="w-full px-3 py-2 text-sm rounded-[var(--radius-md)] border border-[var(--color-border-focus)] bg-[var(--color-surface)] text-[var(--color-text-primary)] outline-none ml-1"
/>
) : (
<button
onClick={() => { setActiveView('code'); setActiveSession(session.id) }}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
w-full flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm text-left rounded-[var(--radius-md)] transition-colors duration-200 group
${session.id === activeSessionId
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
<span className="w-1 h-1 rounded-full flex-shrink-0" style={{
backgroundColor: session.id === activeSessionId ? 'var(--color-brand)' : 'var(--color-text-tertiary)',
opacity: session.id === activeSessionId ? 1 : 0.5,
}} />
<span className="truncate flex-1">{session.title || 'Untitled'}</span>
{!session.workDirExists && (
<span
className="text-[10px] text-[var(--color-warning)] flex-shrink-0"
title={session.workDir ?? 'Missing workspace'}
>
missing dir
</span>
)}
<span className="text-[10px] text-[var(--color-text-tertiary)] flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{formatRelativeTime(session.modifiedAt)}
</span>
</button>
)}
</div>
))}
</div>
))}
</div>
{/* Settings button at bottom */}
<div className="p-3 border-t border-[var(--color-border)]">
<NavItem
active={activeView === 'settings'}
onClick={() => setActiveView('settings')}
icon={<span className="material-symbols-outlined text-[18px]">settings</span>}
>
Settings
</NavItem>
</div>
{/* Context menu */}
{contextMenu && (
<div
className="fixed z-50 bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-md)] py-1 min-w-[140px]"
style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-dropdown)' }}
>
<button
onClick={() => {
const session = sessions.find(s => s.id === contextMenu.id)
handleStartRename(contextMenu.id, session?.title || '')
}}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
Rename
</button>
<button
onClick={() => handleDelete(contextMenu.id)}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-error)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
Delete
</button>
</div>
)}
</aside>
)
}
/**
* Extract a human-readable project name from a sanitized project path.
* Paths look like: "-Users-nanmi-workspace-myself-code-claude-code-haha"
* We desanitize by treating leading `-` as `/`, then splitting on `-` to
* get path segments. The last non-empty segment is the project name.
* This is intentionally simple and lossy (original hyphens vs separators
* are indistinguishable), but good enough for display.
*/
function getProjectDisplayName(sanitizedPath: string): string {
if (!sanitizedPath || sanitizedPath === '_unknown') return 'Other'
// Take the last `-` delimited segment as the display name
const segments = sanitizedPath.split('-').filter(Boolean)
return segments[segments.length - 1] || 'Other'
}
function NavItem({ active, onClick, icon, children }: { active: boolean; onClick: () => void; icon: React.ReactNode; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className={`
w-full flex items-center gap-2.5 px-3 py-2 text-sm rounded-[var(--radius-md)] transition-colors duration-200
${active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}
`}
>
{icon}
{children}
</button>
)
}
function formatRelativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const min = Math.floor(diff / 60000)
if (min < 1) return 'now'
if (min < 60) return `${min}m`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h`
const day = Math.floor(hr / 24)
if (day < 30) return `${day}d`
return `${Math.floor(day / 30)}mo`
}
function PlusIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
)
}
function ClockIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
)
}
@@ -0,0 +1,58 @@
import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
export function StatusBar() {
const { currentModel } = useSettingsStore()
const { connectionState } = useChatStore()
const { sessions, activeSessionId } = useSessionStore()
const activeSession = sessions.find((s) => s.id === activeSessionId)
const statusColor =
connectionState === 'connected'
? 'bg-[var(--color-success)]'
: connectionState === 'connecting' || connectionState === 'reconnecting'
? 'bg-[var(--color-warning)] animate-pulse-dot'
: 'bg-[var(--color-error)]'
const statusText =
connectionState === 'connected'
? 'Connected'
: connectionState === 'connecting'
? 'Connecting...'
: connectionState === 'reconnecting'
? 'Reconnecting...'
: 'Disconnected'
const projectName = activeSession?.projectPath
? activeSession.projectPath.split('-').filter(Boolean).pop() || ''
: ''
return (
<div className="h-[var(--statusbar-height)] flex items-center justify-between px-4 border-t border-[var(--color-border)] bg-[var(--color-surface-sidebar)] select-none text-[11px]">
{/* Left: User info + plan */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${statusColor}`} />
<span className="text-[var(--color-text-tertiary)]">{statusText}</span>
</div>
{projectName && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span className="text-[var(--color-text-secondary)] font-[var(--font-mono)]">{projectName}</span>
</>
)}
</div>
<div className="flex items-center gap-4">
{currentModel && (
<span className="text-[var(--color-text-tertiary)] font-[var(--font-mono)]">
{currentModel.name}
</span>
)}
</div>
</div>
)
}
@@ -0,0 +1,94 @@
import { useUIStore } from '../../stores/uiStore'
export function TitleBar() {
const { activeView, setActiveView } = useUIStore()
return (
<div
className="h-[var(--titlebar-height)] flex items-center border-b border-[var(--color-border)] bg-[var(--color-surface)] select-none"
data-tauri-drag-region
>
{/* macOS traffic light spacer */}
<div className="w-[78px] flex-shrink-0" data-tauri-drag-region />
{/* Logo */}
<div className="flex items-center gap-2 mr-4" data-tauri-drag-region>
<span className="text-xs font-bold tracking-wider text-[var(--color-brand)] uppercase">Claude Code Companion</span>
</div>
{/* Navigation arrows */}
<div className="flex items-center gap-1 mr-4">
<button className="p-1 rounded-[var(--radius-md)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<button className="p-1 rounded-[var(--radius-md)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
{/* Center tabs */}
<div className="flex-1 flex items-center justify-center gap-1" data-tauri-drag-region>
<TabButton
active={activeView === 'code'}
onClick={() => setActiveView('code')}
icon="code"
>
Code
</TabButton>
<TabButton
active={activeView === 'terminal'}
onClick={() => setActiveView('terminal')}
icon="terminal"
>
Terminal
</TabButton>
<TabButton
active={activeView === 'history'}
onClick={() => setActiveView('history')}
icon="history"
>
History
</TabButton>
</div>
{/* Right: Settings */}
<div className="flex items-center gap-2 mr-4">
<button className="p-1.5 rounded-[var(--radius-md)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors">
<span className="material-symbols-outlined text-[18px]">settings</span>
</button>
</div>
</div>
)
}
function TabButton({
active,
onClick,
icon,
children,
}: {
active: boolean
onClick: () => void
icon: string
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
className={`
flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-[var(--radius-md)] transition-colors duration-200
${active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}
`}
>
<span className="material-symbols-outlined text-[16px]">{icon}</span>
{children}
</button>
)
}
@@ -0,0 +1,96 @@
import { useMemo } from 'react'
import { marked, type Tokens } from 'marked'
import { escapeHtml, highlightCodeLines } from '../chat/highlightCode'
type Props = {
content: string
}
const renderer = new marked.Renderer()
renderer.code = function ({ text, lang }: Tokens.Code) {
const languageLabel = escapeHtml(lang || 'code')
const lines = text.split('\n')
const highlightedLines = highlightCodeLines(text, lang)
const body = highlightedLines
.map((line, index) => `
<div class="grid grid-cols-[3rem,minmax(0,1fr)] gap-0 ${index === lines.length - 1 ? '' : 'border-b border-[#d8dee4]'}">
<span class="select-none border-r border-[#d8dee4] bg-[#f6f8fa] px-3 py-1 text-right text-[11px] text-[#57606a]">${index + 1}</span>
<span class="overflow-hidden bg-white px-4 py-1 whitespace-pre-wrap break-words text-[#24292f]">${line || '&nbsp;'}</span>
</div>
`)
.join('')
return `
<div class="my-4 overflow-hidden rounded-2xl border border-[#d0d7de] bg-[#f6f8fa] text-[#24292f]">
<div class="flex items-center justify-between border-b border-[#d0d7de] bg-white px-3 py-2 text-[11px] text-[#57606a]">
<div class="flex items-center gap-3">
<span class="font-semibold uppercase tracking-[0.14em] text-[#57606a]">${languageLabel}</span>
<span>${lines.length} ${lines.length === 1 ? 'line' : 'lines'}</span>
</div>
<button class="rounded-md border border-[#d0d7de] bg-white px-2 py-1 text-[11px] text-[#57606a] transition-colors hover:bg-[#f3f4f6] hover:text-[#24292f]" data-copy-code="${escapeHtml(text)}">
Copy
</button>
</div>
<div class="max-h-[420px] overflow-auto">
<div class="min-w-full font-[var(--font-mono)] text-[12px] leading-[1.45]">${body}</div>
</div>
</div>
`
}
marked.setOptions({
breaks: true,
gfm: true,
})
marked.use({ renderer })
export function MarkdownRenderer({ content }: Props) {
const html = useMemo(() => {
try {
return marked.parse(content) as string
} catch {
return content
}
}, [content])
const handleClick = async (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement | null
const button = target?.closest<HTMLButtonElement>('[data-copy-code]')
if (!button) return
const text = button.getAttribute('data-copy-code')
if (!text) return
try {
await navigator.clipboard.writeText(text)
const original = button.textContent
button.textContent = 'Copied'
window.setTimeout(() => {
button.textContent = original
}, 1500)
} catch {
// Ignore clipboard errors and keep the original label.
}
}
return (
<div
className="prose prose-sm max-w-none text-[var(--color-text-primary)]
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
prose-p:my-2 prose-p:leading-relaxed
prose-code:text-[13px] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-info)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none
prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline
prose-strong:text-[var(--color-text-primary)]
prose-ul:my-2 prose-ol:my-2
prose-li:my-0.5
prose-table:text-sm
prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2
prose-td:px-3 prose-td:py-2 prose-td:border-[var(--color-border)]"
dangerouslySetInnerHTML={{ __html: html }}
onClick={handleClick}
/>
)
}
+63
View File
@@ -0,0 +1,63 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant
size?: 'sm' | 'md' | 'lg'
loading?: boolean
icon?: ReactNode
}
const variantStyles: Record<ButtonVariant, string> = {
primary:
'bg-[var(--color-btn-primary-bg)] text-[var(--color-btn-primary-fg)] hover:bg-[var(--color-btn-primary-bg-hover)] active:bg-[var(--color-btn-primary-bg-active)]',
secondary:
'bg-[var(--color-surface)] text-[var(--color-text-primary)] border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)]',
danger:
'bg-[var(--color-error)] text-white hover:opacity-90',
ghost:
'bg-transparent text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]',
}
const sizeStyles = {
sm: 'px-2 py-1 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-sm',
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
icon,
disabled,
children,
className = '',
...props
}: ButtonProps) {
return (
<button
disabled={disabled || loading}
className={`
inline-flex items-center justify-center gap-1.5 rounded-[var(--radius-md)]
font-medium transition-colors duration-150 cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed
${variantStyles[variant]} ${sizeStyles[size]} ${className}
`}
{...props}
>
{loading ? <Spinner /> : icon}
{children}
</button>
)
}
function Spinner() {
return (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)
}
@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react'
type Props = {
text: string
label?: string
className?: string
}
export function CopyButton({ text, label = 'Copy', className = '' }: Props) {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!copied) return
const timer = window.setTimeout(() => setCopied(false), 1500)
return () => window.clearTimeout(timer)
}, [copied])
const handleCopy = async () => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
setCopied(true)
} catch {
setCopied(false)
}
}
return (
<button
type="button"
onClick={handleCopy}
className={className}
aria-label={copied ? 'Copied' : label}
title={copied ? 'Copied' : label}
>
{copied ? 'Copied' : label}
</button>
)
}
@@ -0,0 +1,236 @@
import { useState, useEffect, useRef } from 'react'
import { sessionsApi, type RecentProject } from '../../api/sessions'
import { filesystemApi } from '../../api/filesystem'
type Props = {
value: string
onChange: (path: string) => void
}
type DirEntry = { name: string; path: string; isDirectory: boolean }
export function DirectoryPicker({ value, onChange }: Props) {
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<'recent' | 'browse'>('recent')
const [projects, setProjects] = useState<RecentProject[]>([])
const [browseEntries, setBrowseEntries] = useState<DirEntry[]>([])
const [browsePath, setBrowsePath] = useState('')
const [browseParent, setBrowseParent] = useState('')
const [loading, setLoading] = useState(false)
const ref = useRef<HTMLDivElement>(null)
// Close on outside click
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
// Load recent projects when opened
useEffect(() => {
if (!open || mode !== 'recent') return
setLoading(true)
sessionsApi.getRecentProjects()
.then(({ projects }) => setProjects(projects))
.catch(() => setProjects([]))
.finally(() => setLoading(false))
}, [open, mode])
const loadBrowseDir = async (path?: string) => {
setLoading(true)
try {
const result = await filesystemApi.browse(path)
setBrowsePath(result.currentPath)
setBrowseParent(result.parentPath)
setBrowseEntries(result.entries)
} catch { /* API not available */ }
setLoading(false)
}
const handleSelect = (path: string) => {
onChange(path)
setOpen(false)
setMode('recent')
}
const switchToBrowse = () => {
setMode('browse')
loadBrowseDir(value || undefined)
}
// Find selected project info
const selectedProject = projects.find((p) => p.realPath === value)
return (
<div ref={ref} className="relative">
{/* Trigger — shows selected project chip or placeholder */}
{value ? (
<button
onClick={() => { setOpen(!open); setMode('recent') }}
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs transition-colors border border-[var(--color-border)]"
>
{/* GitHub icon for git repos, folder for others */}
{selectedProject?.isGit ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="text-[var(--color-text-secondary)]">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
) : (
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-secondary)]">folder</span>
)}
<span className="font-medium text-[var(--color-text-primary)]">
{selectedProject?.repoName || selectedProject?.projectName || value.split('/').pop()}
</span>
{selectedProject?.branch && (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--color-text-tertiary)]">
<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" /><line x1="6" y1="9" x2="6" y2="21" />
</svg>
<span className="text-[var(--color-text-tertiary)]">{selectedProject.branch}</span>
</>
)}
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">expand_more</span>
</button>
) : (
<button
onClick={() => { setOpen(!open); setMode('recent') }}
className="flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<span className="material-symbols-outlined text-[14px]">folder_open</span>
Select a project...
</button>
)}
{/* Dropdown */}
{open && (
<div className="absolute left-0 bottom-full mb-2 w-[400px] z-50 bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] rounded-xl shadow-[var(--shadow-dropdown)] overflow-hidden">
{mode === 'recent' ? (
/* Recent projects mode */
<>
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
Recent
</div>
<div className="max-h-[300px] overflow-y-auto">
{loading ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">Loading...</div>
) : projects.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">No recent projects</div>
) : (
projects.map((project) => {
const isSelected = project.realPath === value
return (
<button
key={project.projectPath}
onClick={() => handleSelect(project.realPath)}
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
isSelected ? 'bg-[var(--color-surface-selected)]' : ''
}`}
>
{/* Icon */}
{project.isGit ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" /><line x1="6" y1="9" x2="6" y2="21" />
</svg>
) : (
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-secondary)] flex-shrink-0">folder</span>
)}
{/* Info */}
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)] truncate">
{project.repoName || project.projectName}
</div>
<div className="text-[11px] text-[var(--color-text-tertiary)] truncate font-[var(--font-mono)]">
{project.realPath}
</div>
</div>
{/* Check mark */}
{isSelected && (
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)] flex-shrink-0" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
)}
</button>
)
})
)}
</div>
{/* Divider + Choose different folder */}
<div className="border-t border-[var(--color-border)]">
<button
onClick={switchToBrowse}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-tertiary)]">create_new_folder</span>
<span className="text-sm text-[var(--color-text-secondary)]">Choose a different folder</span>
</button>
</div>
</>
) : (
/* File browser mode */
<>
<div className="px-3 py-2 border-b border-[var(--color-border)] flex items-center gap-1 flex-wrap">
<button onClick={() => setMode('recent')} className="text-xs text-[var(--color-text-accent)] hover:underline mr-2">
← Recent
</button>
<button onClick={() => loadBrowseDir('/')} className="text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]">/</button>
{browsePath.split('/').filter(Boolean).map((seg, i, arr) => (
<span key={i} className="flex items-center gap-1">
<span className="text-[10px] text-[var(--color-text-tertiary)]">/</span>
<button
onClick={() => loadBrowseDir('/' + arr.slice(0, i + 1).join('/'))}
className="text-[10px] text-[var(--color-text-accent)] hover:underline"
>{seg}</button>
</span>
))}
</div>
<div className="max-h-[240px] overflow-y-auto">
{loading ? (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">Loading...</div>
) : (
<>
{browseParent && browseParent !== browsePath && (
<button onClick={() => loadBrowseDir(browseParent)} className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--color-surface-hover)]">
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">arrow_upward</span>
<span className="text-xs text-[var(--color-text-secondary)]">..</span>
</button>
)}
{browseEntries.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">No subdirectories</div>
) : browseEntries.map((entry) => (
<button
key={entry.path}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]" onClick={() => loadBrowseDir(entry.path)}>folder</span>
<span className="text-xs text-[var(--color-text-primary)] flex-1" onClick={() => loadBrowseDir(entry.path)}>{entry.name}</span>
<button onClick={() => handleSelect(entry.path)} className="px-2 py-0.5 text-[10px] font-semibold text-[var(--color-brand)] hover:bg-[var(--color-primary-fixed)] rounded transition-colors">
Select
</button>
</button>
))}
</>
)}
</div>
{/* Use current folder */}
<div className="px-3 py-2 border-t border-[var(--color-border)] flex justify-between items-center">
<span className="text-[10px] text-[var(--color-text-tertiary)] font-[var(--font-mono)] truncate">{browsePath}</span>
<button onClick={() => handleSelect(browsePath)} className="px-3 py-1.5 bg-[var(--color-brand)] text-white text-xs font-semibold rounded-lg hover:opacity-90">
Use this folder
</button>
</div>
</>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,91 @@
import { useState, useRef, useEffect, type ReactNode } from 'react'
type DropdownItem<T extends string> = {
value: T
label: string
description?: string
icon?: ReactNode
}
type DropdownProps<T extends string> = {
items: DropdownItem<T>[]
value: T
onChange: (value: T) => void
trigger: ReactNode
width?: number
align?: 'left' | 'right'
}
export function Dropdown<T extends string>({
items,
value,
onChange,
trigger,
width = 320,
align = 'left',
}: DropdownProps<T>) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
}
}, [open])
return (
<div ref={ref} className="relative inline-block">
<div onClick={() => setOpen(!open)} className="cursor-pointer">
{trigger}
</div>
{open && (
<div
className={`
absolute z-50 mt-1 py-1 rounded-[var(--radius-lg)]
bg-[var(--color-surface)] border border-[var(--color-border)]
shadow-[var(--shadow-dropdown)]
animate-in fade-in slide-in-from-top-1
${align === 'right' ? 'right-0' : 'left-0'}
`}
style={{ width }}
>
{items.map((item, i) => (
<button
key={item.value}
onClick={() => { onChange(item.value); setOpen(false) }}
className={`
w-full flex items-center gap-3 px-4 py-3 text-left transition-colors
hover:bg-[var(--color-surface-hover)]
${i > 0 ? 'border-t border-[var(--color-border-separator)]' : ''}
`}
>
{item.icon && <span className="text-lg flex-shrink-0">{item.icon}</span>}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">{item.label}</div>
{item.description && (
<div className="text-xs text-[var(--color-text-secondary)] mt-0.5">{item.description}</div>
)}
</div>
{item.value === value && (
<span className="text-[var(--color-text-primary)] text-sm flex-shrink-0">✓</span>
)}
</button>
))}
</div>
)}
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import type { InputHTMLAttributes } from 'react'
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
label?: string
error?: string
required?: boolean
}
export function Input({ label, error, required, className = '', id, ...props }: InputProps) {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="flex flex-col gap-1">
{label && (
<label htmlFor={inputId} className="text-sm font-medium text-[var(--color-text-primary)]">
{label}
{required && <span className="text-[var(--color-error)] ml-0.5">*</span>}
</label>
)}
<input
id={inputId}
className={`
h-10 px-3 rounded-[var(--radius-md)] border text-sm
bg-[var(--color-surface)] text-[var(--color-text-primary)]
placeholder:text-[var(--color-text-tertiary)]
transition-colors duration-150
${error
? 'border-[var(--color-error)] focus:shadow-[0_0_0_3px_rgba(171,43,63,0.1)]'
: 'border-[var(--color-border)] focus:border-[var(--color-border-focus)] focus:shadow-[0_0_0_3px_rgba(153,153,153,0.1)]'
}
outline-none
${className}
`}
{...props}
/>
{error && <p className="text-xs text-[var(--color-error)]">{error}</p>}
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, type ReactNode } from 'react'
type ModalProps = {
open: boolean
onClose: () => void
title?: string
children: ReactNode
width?: number
footer?: ReactNode
}
export function Modal({ open, onClose, title, children, width = 560, footer }: ModalProps) {
useEffect(() => {
if (!open) return
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [open, onClose])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 transition-opacity duration-200"
onClick={onClose}
/>
{/* Modal content */}
<div
className="relative bg-[var(--color-surface)] rounded-[var(--radius-xl)] shadow-[var(--shadow-modal)] max-h-[85vh] flex flex-col"
style={{ width, maxWidth: 'calc(100vw - 48px)' }}
>
{title && (
<div className="px-6 pt-6 pb-0">
<h2 className="text-xl font-bold text-[var(--color-text-primary)]">{title}</h2>
</div>
)}
<div className="px-6 py-4 overflow-y-auto flex-1">
{children}
</div>
{footer && (
<div className="px-6 pb-6 pt-0 flex justify-end gap-2">
{footer}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,30 @@
type Props = {
workDir?: string | null
repoName?: string | null
branch?: string | null
}
export function ProjectContextChip({ workDir, repoName, branch }: Props) {
const label = branch ? (repoName || workDir?.split('/').pop() || '') : (workDir?.split('/').pop() || repoName || '')
if (!label) return null
return (
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#dac1ba] px-4 py-2 text-sm text-[#87736D]">
{branch ? (
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" className="shrink-0 text-[#6b5a54]">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
) : (
<span className="material-symbols-outlined text-[18px] text-[#6b5a54]">folder</span>
)}
<span className="truncate font-medium text-[#4f423d]">{label}</span>
{branch ? (
<>
<span className="text-[#b49d95]">|</span>
<span className="truncate">{branch}</span>
</>
) : null}
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
type SpinnerProps = {
size?: number
className?: string
}
export function Spinner({ size = 20, className = '' }: SpinnerProps) {
return (
<svg
className={`animate-spin ${className}`}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)
}
@@ -0,0 +1,38 @@
import type { TextareaHTMLAttributes } from 'react'
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
label?: string
error?: string
required?: boolean
}
export function Textarea({ label, error, required, className = '', id, ...props }: TextareaProps) {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="flex flex-col gap-1">
{label && (
<label htmlFor={inputId} className="text-sm font-medium text-[var(--color-text-primary)]">
{label}
{required && <span className="text-[var(--color-error)] ml-0.5">*</span>}
</label>
)}
<textarea
id={inputId}
className={`
min-h-[120px] px-3 py-2 rounded-[var(--radius-lg)] border text-sm resize-y
bg-[var(--color-surface)] text-[var(--color-text-primary)]
placeholder:text-[var(--color-text-tertiary)]
transition-colors duration-150
${error
? 'border-[var(--color-error)]'
: 'border-[var(--color-border)] focus:border-[var(--color-border-focus)] focus:shadow-[0_0_0_3px_rgba(153,153,153,0.1)]'
}
outline-none
${className}
`}
{...props}
/>
{error && <p className="text-xs text-[var(--color-error)]">{error}</p>}
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { useUIStore, type Toast as ToastType } from '../../stores/uiStore'
const typeStyles: Record<ToastType['type'], string> = {
success: 'border-l-4 border-l-[var(--color-success)]',
error: 'border-l-4 border-l-[var(--color-error)]',
warning: 'border-l-4 border-l-[var(--color-warning)]',
info: 'border-l-4 border-l-[var(--color-text-accent)]',
}
function ToastItem({ toast }: { toast: ToastType }) {
const removeToast = useUIStore((s) => s.removeToast)
return (
<div
className={`
bg-[var(--color-surface)] rounded-[var(--radius-md)] shadow-[var(--shadow-dropdown)]
px-4 py-3 text-sm text-[var(--color-text-primary)]
${typeStyles[toast.type]}
animate-in slide-in-from-right fade-in duration-200
`}
>
<div className="flex items-center justify-between gap-2">
<span>{toast.message}</span>
<button
onClick={() => removeToast(toast.id)}
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] text-lg leading-none"
>
×
</button>
</div>
</div>
)
}
export function ToastContainer() {
const toasts = useUIStore((s) => s.toasts)
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} />
))}
</div>
)
}
@@ -0,0 +1,119 @@
import { useState } from 'react'
import { useTaskStore } from '../../stores/taskStore'
import { Modal } from '../shared/Modal'
import { Input } from '../shared/Input'
import { Textarea } from '../shared/Textarea'
import { Button } from '../shared/Button'
type Props = {
open: boolean
onClose: () => void
}
const FREQUENCY_OPTIONS = [
{ value: '0 * * * *', label: 'Hourly' },
{ value: '0 9 * * *', label: 'Daily' },
{ value: '0 9 * * 1', label: 'Weekly' },
{ value: '0 9 1 * *', label: 'Monthly' },
]
export function NewTaskModal({ open, onClose }: Props) {
const { createTask } = useTaskStore()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [prompt, setPrompt] = useState('')
const [cron, setCron] = useState('0 9 * * *')
const [isSubmitting, setIsSubmitting] = useState(false)
const canSubmit = name.trim() && prompt.trim()
const handleSubmit = async () => {
if (!canSubmit) return
setIsSubmitting(true)
try {
await createTask({
name: name.trim(),
description: description.trim() || undefined,
cron,
prompt: prompt.trim(),
enabled: true,
recurring: true,
})
// Reset form
setName('')
setDescription('')
setPrompt('')
setCron('0 9 * * *')
onClose()
} catch (err) {
console.error('Failed to create task:', err)
} finally {
setIsSubmitting(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title="New scheduled task"
footer={
<>
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>Create task</Button>
</>
}
>
{/* Info banner */}
<div className="flex items-start gap-2 px-3 py-2.5 rounded-[var(--radius-md)] bg-[var(--color-surface-info)] mb-4">
<span className="text-[var(--color-text-secondary)] text-sm">ℹ</span>
<span className="text-xs text-[var(--color-text-secondary)]">
Local tasks only run while your computer is awake.
</span>
</div>
<div className="flex flex-col gap-4">
<Input
label="Name"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="daily-code-review"
/>
<Input
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Review yesterday's commits..."
/>
<Textarea
label="Prompt"
required
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Look at the commits from the last 24 hours. Summarize what changed, call out any risky patterns..."
/>
{/* Frequency */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">Frequency</label>
<select
value={cron}
onChange={(e) => setCron(e.target.value)}
className="h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
>
{FREQUENCY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<p className="text-xs text-[var(--color-text-tertiary)]">
Scheduled tasks use a randomized delay of up to 5 minutes to avoid rate limits.
</p>
</div>
</Modal>
)
}
@@ -0,0 +1,28 @@
import { Button } from '../shared/Button'
type Props = {
onCreateTask: () => void
}
export function TaskEmptyState({ onCreateTask }: Props) {
return (
<div className="flex flex-col items-center justify-center py-20">
{/* Clock icon */}
<div className="w-16 h-16 rounded-full bg-[var(--color-surface-info)] flex items-center justify-center mb-4">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-tertiary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
<h3 className="text-sm font-medium text-[var(--color-text-primary)] mb-1">
No scheduled tasks yet.
</h3>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4 text-center max-w-sm">
Create a recurring task to automate your engineering workflows.
</p>
<Button onClick={onCreateTask}>+ New task</Button>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import type { CronTask } from '../../types/task'
import { TaskRow } from './TaskRow'
type Props = {
tasks: CronTask[]
}
export function TaskList({ tasks }: Props) {
const enabledCount = tasks.filter((t) => t.enabled).length
return (
<div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
<StatCard label="Total Tasks" value={String(tasks.length)} />
<StatCard label="Active" value={String(enabledCount)} />
<StatCard label="Disabled" value={String(tasks.length - enabledCount)} />
</div>
{/* Task rows */}
<div className="flex flex-col">
{tasks.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
</div>
)
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div className="px-4 py-3 rounded-[var(--radius-lg)] bg-[var(--color-surface-info)]">
<div className="text-2xl font-bold text-[var(--color-text-primary)]">{value}</div>
<div className="text-xs text-[var(--color-text-secondary)]">{label}</div>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import type { CronTask } from '../../types/task'
import { useTaskStore } from '../../stores/taskStore'
type Props = {
task: CronTask
}
export function TaskRow({ task }: Props) {
const { deleteTask, updateTask } = useTaskStore()
const toggleEnabled = () => {
updateTask(task.id, { enabled: !task.enabled })
}
return (
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-border-separator)] hover:bg-[var(--color-surface-hover)] transition-colors group">
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Status indicator */}
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${task.enabled ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">{task.name}</div>
{task.description && (
<div className="text-xs text-[var(--color-text-secondary)] truncate">{task.description}</div>
)}
</div>
</div>
<div className="flex items-center gap-4 flex-shrink-0">
{/* Cron expression */}
<span className="text-xs text-[var(--color-text-tertiary)] font-[var(--font-mono)]">{task.cron}</span>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={toggleEnabled}
className="px-2 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-selected)] transition-colors"
>
{task.enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => deleteTask(task.id)}
className="px-2 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-error)] hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,14 @@
import { useTeamStore } from '../../stores/teamStore'
export function BackToLeader() {
const setViewingAgent = useTeamStore((s) => s.setViewingAgent)
return (
<button
onClick={() => setViewingAgent(null)}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-[var(--color-text-accent)] hover:underline transition-colors"
>
← Back to Leader
</button>
)
}
@@ -0,0 +1,46 @@
import { useTeamStore } from '../../stores/teamStore'
import type { TeamMember, AgentColor } from '../../types/team'
const COLOR_MAP: Record<AgentColor, string> = {
red: 'var(--color-agent-red)',
blue: 'var(--color-agent-blue)',
green: 'var(--color-agent-green)',
yellow: 'var(--color-agent-yellow)',
purple: 'var(--color-agent-purple)',
orange: 'var(--color-agent-orange)',
pink: 'var(--color-agent-pink)',
cyan: 'var(--color-agent-cyan)',
}
const STATUS_STYLES: Record<string, { dotClass: string; color: string }> = {
running: { dotClass: 'animate-pulse-dot', color: 'var(--color-warning)' },
completed: { dotClass: '', color: 'var(--color-success)' },
error: { dotClass: '', color: 'var(--color-error)' },
idle: { dotClass: '', color: 'var(--color-text-tertiary)' },
}
export function MemberTag({ member }: { member: TeamMember }) {
const { setViewingAgent, viewingAgentId, memberColors } = useTeamStore()
const isViewing = viewingAgentId === member.agentId
const color = member.color || memberColors.get(member.agentId) || 'blue'
const agentColor = COLOR_MAP[color]
const statusInfo = STATUS_STYLES[member.status] || STATUS_STYLES.idle!
return (
<button
onClick={() => setViewingAgent(isViewing ? null : member.agentId)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-[var(--radius-md)] text-xs font-medium transition-colors"
style={{
backgroundColor: isViewing ? `color-mix(in srgb, ${agentColor} 15%, transparent)` : 'var(--color-surface-selected)',
border: isViewing ? `2px solid ${agentColor}` : '2px solid transparent',
}}
>
{/* Status dot */}
<span
className={`w-1.5 h-1.5 rounded-full ${statusInfo.dotClass}`}
style={{ backgroundColor: statusInfo.color }}
/>
<span className="text-[var(--color-text-primary)]">{member.role || member.agentId}</span>
</button>
)
}
@@ -0,0 +1,29 @@
import { useTeamStore } from '../../stores/teamStore'
import { MemberTag } from './MemberTag'
export function TeamStatusBar() {
const { activeTeam } = useTeamStore()
if (!activeTeam) return null
return (
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface-info)]">
{/* Team header */}
<div className="px-4 py-2 flex items-center gap-2">
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
Team: {activeTeam.name}
</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
({activeTeam.members.length} members)
</span>
</div>
{/* Member tags */}
<div className="px-4 pb-2 flex flex-wrap gap-2">
{activeTeam.members.map((member) => (
<MemberTag key={member.agentId} member={member} />
))}
</div>
</div>
)
}
@@ -0,0 +1,36 @@
import { useTeamStore } from '../../stores/teamStore'
export function TranscriptView() {
const { agentTranscript, viewingAgentId, activeTeam } = useTeamStore()
const member = activeTeam?.members.find((m) => m.agentId === viewingAgentId)
return (
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="max-w-[720px] mx-auto">
{/* Viewing divider */}
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-[var(--color-border)]" />
<span className="text-xs text-[var(--color-text-tertiary)]">
Viewing: {member?.role || viewingAgentId} transcript
</span>
<div className="flex-1 h-px bg-[var(--color-border)]" />
</div>
{/* Transcript messages */}
{agentTranscript.length === 0 ? (
<div className="text-center text-sm text-[var(--color-text-tertiary)] py-8">
No messages yet
</div>
) : (
agentTranscript.map((msg) => (
<div key={msg.id} className="mb-3 text-sm text-[var(--color-text-primary)]">
<pre className="whitespace-pre-wrap break-words font-[var(--font-mono)] text-xs bg-[var(--color-surface-info)] rounded-[var(--radius-md)] px-3 py-2">
{typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2)}
</pre>
</div>
))
)}
</div>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useUIStore } from '../stores/uiStore'
export function useKeyboardShortcuts() {
const setActiveSession = useSessionStore((s) => s.setActiveSession)
const setActiveView = useUIStore((s) => s.setActiveView)
const closeModal = useUIStore((s) => s.closeModal)
const activeModal = useUIStore((s) => s.activeModal)
const stopGeneration = useChatStore((s) => s.stopGeneration)
const chatState = useChatStore((s) => s.chatState)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const meta = e.metaKey || e.ctrlKey
// Cmd+N — New session
if (meta && e.key === 'n') {
e.preventDefault()
setActiveSession(null)
setActiveView('code')
}
// Cmd+K — Focus search (sidebar search input)
if (meta && e.key === 'k') {
e.preventDefault()
const searchInput = document.querySelector('aside input[type="text"]') as HTMLInputElement
searchInput?.focus()
}
// Escape — Close modal or clear state
if (e.key === 'Escape') {
if (activeModal) {
closeModal()
}
}
// Cmd+. — Stop generation
if (meta && e.key === '.') {
if (chatState !== 'idle') {
e.preventDefault()
stopGeneration()
}
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [activeModal, chatState, closeModal, setActiveSession, setActiveView, stopGeneration])
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App'
import './theme/globals.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+202
View File
@@ -0,0 +1,202 @@
/**
* Mock data for all pages — matches the UI prototypes exactly.
* Replace with real API calls once server integration is done.
*/
// ─── Sessions ─────────────────────────────────────────────────────
export const mockSessions = {
today: [
{ id: 's1', title: 'Refactor login flow', modifiedAt: new Date().toISOString() },
{ id: 's2', title: 'Fix CSS responsive layout', modifiedAt: new Date().toISOString() },
],
previous7Days: [
{ id: 's3', title: 'Add user authentication', modifiedAt: new Date(Date.now() - 3 * 86400000).toISOString() },
{ id: 's4', title: 'Database migration script', modifiedAt: new Date(Date.now() - 5 * 86400000).toISOString() },
],
older: [
{ id: 's5', title: 'Initial project setup', modifiedAt: new Date(Date.now() - 30 * 86400000).toISOString() },
],
}
// ─── Active Session Messages ──────────────────────────────────────
export const mockActiveMessages = [
{
id: 'm1',
role: 'user' as const,
content: "I want to refactor the login flow in `auth.ts`. Let's move the JWT signing logic to a separate helper and add validation for the user payload.",
},
{
id: 'm2',
role: 'assistant' as const,
content: "Understood. I will begin by analyzing the current implementation of `auth.ts`. I'll extract the JWT logic into a new utility function and implement the validation layer as requested.",
thinking: "Looking at the auth.ts file, the JWT signing is currently inline in the login handler. I need to:\n1. Extract signToken() helper\n2. Add Zod validation for LoginPayload\n3. Update the handler to use both",
},
{
id: 'm3',
role: 'tool' as const,
toolName: 'edit_file',
toolStatus: 'success',
filePath: 'src/lib/auth.ts',
content: `export const validatePayload = (user) => { ... }
export const signToken = (payload) => jwt.sign(payload, SECRET);
// 8 OLD LINES REMOVED`,
},
]
// ─── Agent Teams ──────────────────────────────────────────────────
export const mockTeam = {
name: 'session-dev',
memberCount: 4,
members: [
{ id: 'a1', role: 'Architect', status: 'completed' as const, color: '#16a34a' },
{ id: 'a2', role: 'Frontend Dev', status: 'running' as const, color: '#dc2626' },
{ id: 'a3', role: 'Backend Dev', status: 'running' as const, color: '#2563eb' },
{ id: 'a4', role: 'Tester', status: 'idle' as const, color: '#9333ea' },
],
}
export const mockTeamMessages = {
userMessage: "Refactor the authentication middleware to support JWT and OAuth2 simultaneously. Ensure we have proper test coverage for the edge cases.",
assistantMessage: "I've initiated the agent team for this task. The architect is designing the interface, while the developers are preparing the boilerplate for the new strategies.",
systemInfo: `Info: spawning child_processes for parallel development
active: session-dev cluster initiated
ready: 4 agents assigned`,
}
// ─── Agent Transcript ─────────────────────────────────────────────
export const mockTranscript = {
agentName: 'Frontend Dev',
messages: [
{
id: 't1',
role: 'agent' as const,
timestamp: 'P1:42:11',
content: "I've analyzed the component structure. I need to update the `Navigation.tsx` to include the new responsive breakpoints. Initiating local file system audit.",
},
{
id: 't2',
role: 'tool' as const,
toolName: 'BASH',
status: 'SUCCESS' as const,
command: '$ grep -r "breakpoint" .\n./Navigation.tsx: const [isMobile, setIsMobile] = useState(false);\n./Navigation.tsx: // TODO: Add mobile breakpoint check\n./Header.tsx: @media (max-width: 768px) {',
},
{
id: 't3',
role: 'progress' as const,
label: 'Patching Navigation.tsx',
progress: 67,
},
{
id: 't4',
role: 'agent' as const,
timestamp: 'P1:44:35',
content: "Breakpoint logic implemented. I'm now verifying the CSS-in-JS injection to ensure no style collisions with the existing theme.",
images: ['/placeholder-code-1.jpg', '/placeholder-code-2.jpg'],
},
],
teamBar: [
{ id: 'lead', role: 'Lead Claude', active: false, color: '#87736D' },
{ id: 'a2', role: 'Frontend Dev', active: true, color: '#dc2626' },
{ id: 'a3', role: 'Backend Architect', active: false, color: '#2563eb' },
],
}
// ─── Scheduled Tasks ──────────────────────────────────────────────
export const mockScheduledTasks = {
stats: {
totalTasks: 12,
activeHealthy: 9,
nextRun: { name: 'Nightly linting', time: 'Today, 11:30 PM' },
systemHealth: 99.8,
healthPeriod: 'Last 30 days execution rate',
},
tasks: [
{
id: 'task1',
name: 'Nightly linting',
frequency: 'Daily',
lastResult: 'Success' as const,
nextExecution: 'Today, 11:30 PM',
},
{
id: 'task2',
name: 'Clean up temp files',
description: 'Clean TempOutput/**',
frequency: 'Weekly',
lastResult: 'Success' as const,
nextExecution: 'Sun, 2:00 AM',
},
{
id: 'task3',
name: 'Database Vacuum',
description: 'Postgres maintenance',
frequency: 'Monthly',
lastResult: 'Failed (Disk Full)' as const,
nextExecution: 'Dec 01, 9:01 AM',
},
],
}
// ─── Session Controls ─────────────────────────────────────────────
export const mockPermissionModes = [
{ id: 'ask', label: 'Ask permissions', description: 'Confirm every file edit or terminal command.', icon: 'lock' },
{ id: 'auto', label: 'Auto accept edits', description: 'Claude writes to disk without asking.', icon: 'edit_note' },
{ id: 'plan', label: 'Plan mode', description: 'Architecture & reasoning only. No writes.', icon: 'architecture' },
{ id: 'bypass', label: 'Bypass permissions', description: 'Full root access for shell and file system.', icon: 'warning' },
]
export const mockModels = [
{ id: 'opus', name: 'Opus 4.6', active: false },
{ id: 'sonnet', name: 'Sonnet 4.6', active: true },
{ id: 'haiku', name: 'Haiku 4.5', active: false },
]
export const mockEffortLevels = ['Low', 'Medium', 'High', 'Max']
// ─── Tool Inspection (edit_file diff) ─────────────────────────────
export const mockToolInspection = {
toolType: 'TOOL CALL',
toolName: 'edit_file',
description: 'Updating login logic to use new SDK',
filePath: 'src/lib/auth.ts',
dryRunStatus: 'Dry-run Success',
linesChanged: { added: 12, removed: 8 },
diffLines: [
{ type: 'context' as const, lineNo: 1, content: 'export async function loginCredentials: LoginCredentials): Promise<LoginResponse> {' },
{ type: 'context' as const, lineNo: 2, content: ' try {' },
{ type: 'removed' as const, lineNo: 3, content: ' const response = await legacyHttpClient.authenticate()' },
{ type: 'added' as const, lineNo: 3, content: ' const response = await httpClient.authenticate({' },
{ type: 'added' as const, lineNo: 4, content: ' user: credentials.username,' },
{ type: 'added' as const, lineNo: 5, content: ' pass: credentials.password,' },
{ type: 'context' as const, lineNo: 6, content: ' })' },
{ type: 'context' as const, lineNo: 7, content: '' },
{ type: 'removed' as const, lineNo: 8, content: ' const client = await createClient();' },
{ type: 'added' as const, lineNo: 8, content: ' const client = await newSdkClient.create({' },
{ type: 'added' as const, lineNo: 9, content: ' identifier: credentials.username,' },
{ type: 'added' as const, lineNo: 10, content: ' secret: credentials.password,' },
{ type: 'context' as const, lineNo: 11, content: '' },
{ type: 'added' as const, lineNo: 12, content: ' options: { persistent: true }' },
{ type: 'context' as const, lineNo: 13, content: ' })' },
{ type: 'context' as const, lineNo: 14, content: '' },
{ type: 'context' as const, lineNo: 15, content: ' if (response.status === 200) {' },
{ type: 'context' as const, lineNo: 16, content: ' return response.data;' },
],
}
// ─── New Task Modal ───────────────────────────────────────────────
export const mockNewTaskDefaults = {
permissionModes: ['Restricted', 'Standard', 'Full Access'],
models: ['Claude 3.5 Sonnet', 'Claude 3.5 Haiku', 'Claude 3.5 Opus'],
frequencies: ['Hourly', 'Daily at 9:00 AM', 'Weekly', 'Monthly', 'Custom cron'],
}
// ─── Footer / Status Bar ──────────────────────────────────────────
export const mockStatusBar = {
user: 'User Avatar',
username: 'username',
plan: 'Pro Plan',
branch: 'main-branch',
worktreeToggle: 'worktree-toggle',
localSwitch: 'local-switch',
status: 'Ready',
}
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useMemo } from 'react'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
export function ActiveSession() {
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const sessions = useSessionStore((s) => s.sessions)
const connectToSession = useChatStore((s) => s.connectToSession)
const { chatState, tokenUsage } = useChatStore()
const session = sessions.find((s) => s.id === activeSessionId)
useEffect(() => {
if (activeSessionId) {
connectToSession(activeSessionId)
}
}, [activeSessionId, connectToSession])
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 'just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
return `${Math.floor(diff / 86400000)}d ago`
}, [session?.modifiedAt])
return (
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface">
{/* Session info header */}
<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 || 'Untitled Session'}
</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" />
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>last updated {lastUpdated}</span>
</>
)}
{session?.messageCount !== undefined && session.messageCount > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{session.messageCount} messages</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">
Workspace unavailable: {session.workDir || 'directory no longer exists'}
</span>
</div>
)}
</div>
</div>
{/* Message stream */}
<MessageList />
{/* Team status bar */}
<TeamStatusBar />
{/* Chat input */}
<ChatInput />
</div>
)
}
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react'
import { mockTeam, mockTeamMessages } from '../mocks/data'
// ─── Inline keyframes for pulse-subtle animation ─────────────────
const pulseSubtleStyle = `
@keyframes pulse-subtle {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; transform: scale(0.98); }
}
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
`
export function AgentTeams() {
const [inputValue, setInputValue] = useState('')
return (
<>
<style>{pulseSubtleStyle}</style>
<div className="flex-1 flex flex-col relative overflow-hidden bg-[#FAF9F5] text-[#1B1C1A] font-['Inter'] selection:bg-[#ffdbd0]">
{/* Code Content Area */}
<div className="flex-1 overflow-y-auto p-6 md:p-10 max-w-5xl mx-auto w-full">
<div className="space-y-8">
{/* ─── Message Thread ─── */}
<div className="space-y-6">
{/* USER message */}
<div className="flex gap-4 group">
<div className="w-8 h-8 rounded-full bg-[#ffdbd0] flex-shrink-0 flex items-center justify-center text-[#390c00] font-bold text-xs">
U
</div>
<div className="space-y-2">
<p className="text-xs font-semibold text-[#87736D] uppercase tracking-widest">
User
</p>
<p className="text-[#1B1C1A] leading-relaxed">
{mockTeamMessages.userMessage}
</p>
</div>
</div>
{/* CLAUDE COMPANION response */}
<div className="flex gap-4 group">
<div className="w-8 h-8 rounded-full bg-[#677b4e] flex-shrink-0 flex items-center justify-center text-[#faffea]">
<span
className="material-symbols-outlined text-sm"
style={{ fontVariationSettings: "'FILL' 1" }}
>
smart_toy
</span>
</div>
<div className="space-y-4 flex-1">
<p className="text-xs font-semibold text-[#87736D] uppercase tracking-widest">
Claude Companion
</p>
<div className="bg-[#f4f4f0] p-5 rounded-xl border border-[#dac1ba]/10 shadow-sm">
<p className="text-[#1B1C1A] mb-4">
{mockTeamMessages.assistantMessage}
</p>
<div className="bg-[#dbdad6] p-4 rounded-lg font-['JetBrains_Mono'] text-[13px] text-[#54433e] overflow-x-auto">
<span className="text-[#ad5f45]">info:</span> spawning child_processes for parallel development
<br />
<span className="text-[#2d628f]">active:</span> session-dev cluster initiated
<br />
<span className="text-[#4f6237]">ready:</span> 4 agents assigned
</div>
</div>
</div>
</div>
</div>
{/* ─── TEAM STRIP ─── */}
<div className="relative py-8">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-[#dac1ba]/20" />
<div className="relative bg-[#FAF9F5] p-4 rounded-2xl flex flex-col md:flex-row md:items-center gap-4 border border-[#dac1ba]/15 shadow-sm overflow-hidden">
{/* Team label */}
<div className="flex items-center gap-3 pr-4 md:border-r border-[#dac1ba]/30">
<div className="p-2 bg-[#ffb59d]/20 rounded-lg">
<span className="material-symbols-outlined text-[#8F482F] text-xl">
groups
</span>
</div>
<div>
<h3 className="text-sm font-bold font-['Manrope'] text-[#1B1C1A]">
Team: {mockTeam.name}
</h3>
<p className="text-[11px] font-medium text-[#87736D] uppercase tracking-tighter">
{mockTeam.memberCount} members
</p>
</div>
</div>
{/* Agent Chips */}
<div className="flex flex-wrap gap-2 items-center flex-1">
{mockTeam.members.map((member) => {
if (member.status === 'completed') {
return (
<div
key={member.id}
className="flex items-center gap-2 px-3 py-1.5 bg-[#e3e2df] rounded-full border border-[#677b4e]/20 group hover:border-[#677b4e]/50 transition-all cursor-pointer"
>
<div className="w-2 h-2 rounded-full bg-[#677b4e] shadow-[0_0_8px_rgba(103,123,78,0.4)]" />
<span className="text-xs font-semibold font-['Inter'] text-[#1B1C1A]">
{member.role}
</span>
<span
className="material-symbols-outlined text-[14px] text-[#4f6237]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
check_circle
</span>
</div>
)
}
if (member.status === 'running') {
return (
<div
key={member.id}
className="flex items-center gap-2 px-3 py-1.5 bg-[#e3e2df] rounded-full border border-[#8F482F]/20 animate-pulse-subtle group hover:border-[#8F482F]/50 transition-all cursor-pointer"
>
<div className="w-2 h-2 rounded-full bg-[#f59e0b] shadow-[0_0_8px_rgba(245,158,11,0.4)]" />
<span className="text-xs font-semibold font-['Inter'] text-[#1B1C1A]">
{member.role}
</span>
<span
className="material-symbols-outlined text-[14px] text-[#f59e0b]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
sync
</span>
</div>
)
}
return (
<div
key={member.id}
className="flex items-center gap-2 px-3 py-1.5 bg-[#f4f4f0] rounded-full border border-[#dac1ba]/20 grayscale group hover:grayscale-0 hover:border-[#2d628f]/50 transition-all cursor-pointer"
>
<div className="w-2 h-2 rounded-full bg-[#87736D] shadow-[0_0_8px_rgba(135,115,109,0.2)]" />
<span className="text-xs font-semibold font-['Inter'] text-[#87736D] group-hover:text-[#1B1C1A]">
{member.role}
</span>
<span className="material-symbols-outlined text-[14px] text-[#87736D]">
{member.role === 'Tester' ? 'schedule' : 'pause_circle'}
</span>
</div>
)
})}
</div>
{/* Expand button */}
<button className="ml-auto p-2 hover:bg-[#e9e8e4] rounded-full transition-colors text-[#87736D]">
<span className="material-symbols-outlined text-sm">expand_more</span>
</button>
</div>
</div>
{/* ─── Chat Composer ─── */}
<div className="max-w-3xl mx-auto w-full mt-auto">
<div className="relative bg-white rounded-xl border border-[#dac1ba]/15 p-1.5 shadow-md flex items-center gap-2 focus-within:border-[#dac1ba]/40 transition-all">
<div className="p-2 text-[#87736D]">
<span className="material-symbols-outlined">attach_file</span>
</div>
<input
className="flex-1 bg-transparent border-none focus:ring-0 focus:outline-none text-sm font-['Inter'] text-[#1B1C1A] py-2"
placeholder="Type a command or ask Claude..."
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button className="bg-[#8F482F] text-white w-9 h-9 rounded-lg flex items-center justify-center hover:bg-[#ad5f45] transition-all active:scale-95">
<span
className="material-symbols-outlined text-lg"
style={{ fontVariationSettings: "'FILL' 1" }}
>
send
</span>
</button>
</div>
<div className="mt-3 flex justify-center gap-4">
<div className="flex items-center gap-1.5 text-[10px] text-[#87736D] font-semibold uppercase tracking-widest">
<span className="w-1.5 h-1.5 rounded-full bg-[#4f6237]" />
Auto-run enabled
</div>
<div className="flex items-center gap-1.5 text-[10px] text-[#87736D] font-semibold uppercase tracking-widest">
<span className="w-1.5 h-1.5 rounded-full bg-[#2d628f]" />
Local LLM
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { useEffect } from 'react'
import { useTeamStore } from '../stores/teamStore'
import { mockTranscript } from '../mocks/data'
function Icon({ name, className = '', filled = false }: { name: string; className?: string; filled?: boolean }) {
return (
<span
className={`material-symbols-outlined ${className}`}
style={filled ? { fontVariationSettings: "'FILL' 1" } : undefined}
>
{name}
</span>
)
}
export function AgentTranscript() {
const { viewingAgentId, activeTeam, agentTranscript, setViewingAgent, fetchMemberTranscript } = useTeamStore()
const viewingMember = activeTeam?.members.find((m) => m.agentId === viewingAgentId)
const agentName = viewingMember?.role ?? mockTranscript.agentName
useEffect(() => {
if (viewingAgentId && activeTeam) {
fetchMemberTranscript(activeTeam.name, viewingAgentId)
}
}, [viewingAgentId, activeTeam, fetchMemberTranscript])
// Use real transcript if available, fall back to mock
const messages = agentTranscript.length > 0
? agentTranscript.map((msg, i) => ({
id: msg.id || `t-${i}`,
role: 'agent' as const,
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
timestamp: msg.timestamp,
}))
: mockTranscript.messages
const teamMembers = activeTeam
? activeTeam.members.map((m) => ({
id: m.agentId,
role: m.role,
active: m.agentId === viewingAgentId,
}))
: mockTranscript.teamBar
return (
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface selection:bg-primary-fixed">
{/* Context Banner */}
<div className="sticky top-0 z-30 bg-primary-container/10 backdrop-blur-md px-6 py-2 border-b border-primary/10 flex justify-between items-center">
<div className="flex items-center gap-3">
<span className="flex h-2 w-2 rounded-full bg-primary animate-pulse" />
<span className="text-sm font-medium text-primary">
Viewing: {agentName} transcript
</span>
</div>
<button
onClick={() => setViewingAgent(null)}
className="text-xs font-bold text-primary flex items-center gap-1 hover:underline cursor-pointer"
>
<Icon name="arrow_back" className="text-xs" />
Back to Leader
</button>
</div>
{/* Teammate Transcript Stream */}
<div className="flex-1 overflow-y-auto max-w-4xl mx-auto px-6 py-8 space-y-8 pb-24">
{messages.map((msg) => {
if (msg.role === 'agent') {
return (
<div key={msg.id} className="flex gap-4 items-start">
<div className="w-8 h-8 rounded-lg bg-surface-container-high shrink-0 flex items-center justify-center">
<Icon name="smart_toy" className="text-secondary" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-on-surface">{agentName}</span>
<span className="text-[10px] text-outline font-mono">{msg.timestamp}</span>
</div>
<div className="p-5 bg-surface-container-lowest rounded-xl border border-outline-variant/15 shadow-sm">
<p className="text-on-surface leading-relaxed text-sm">{msg.content}</p>
</div>
</div>
</div>
)
}
if (msg.role === 'tool') {
return (
<div key={msg.id} className="flex gap-4 items-start">
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
<Icon name="terminal" className="text-outline" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between text-[10px] uppercase tracking-widest text-outline font-bold">
<span>Tool Call: {msg.toolName?.toLowerCase()}</span>
<span className="bg-tertiary-container text-on-tertiary-container px-2 py-0.5 rounded-full">
{msg.status}
</span>
</div>
<div className="rounded-lg overflow-hidden bg-surface-dim border border-outline-variant/20">
<div className="flex items-center gap-2 px-4 py-2 bg-on-surface/5 border-b border-outline-variant/10">
<div className="w-2 h-2 rounded-full bg-error/40" />
<div className="w-2 h-2 rounded-full bg-primary-container/40" />
<div className="w-2 h-2 rounded-full bg-tertiary-container/40" />
<span className="ml-2 text-[10px] font-mono text-outline">
~/projects/companion/src/components
</span>
</div>
<pre className="p-4 text-xs font-mono text-on-surface leading-5 overflow-x-auto">
<code>{msg.command}</code>
</pre>
</div>
</div>
</div>
)
}
if (msg.role === 'progress') {
return (
<div key={msg.id} className="flex gap-4 items-start">
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
<Icon name="insights" className="text-outline" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3 p-4 bg-surface-container-low rounded-lg border border-outline-variant/5">
<Icon name="sync" className="text-primary text-lg" filled />
<div className="flex-1">
<p className="text-xs font-medium text-on-surface">{msg.label}</p>
<div className="w-full bg-surface-variant h-1 rounded-full mt-2 overflow-hidden">
<div
className="bg-primary h-full rounded-full"
style={{ width: `${msg.progress}%` }}
/>
</div>
</div>
<span className="text-[10px] font-mono text-outline">{msg.progress}%</span>
</div>
</div>
</div>
)
}
return null
})}
</div>
{/* ── Team Strip (Persistent Navigation) ── */}
<div className="absolute bottom-4 left-4 right-4 bg-surface-container-lowest/80 backdrop-blur-xl border border-outline-variant/20 rounded-2xl p-2 px-4 flex items-center gap-4 shadow-2xl z-10">
<div className="text-[10px] font-bold text-outline uppercase tracking-tighter pr-4 border-r border-outline-variant/20">
The Team
</div>
<div className="flex items-center gap-2">
{teamMembers.map((member) => (
<button
key={member.id}
onClick={() => setViewingAgent(member.id)}
className={`flex items-center gap-2 p-1.5 px-3 rounded-xl transition-colors ${
member.active
? 'bg-primary-fixed text-on-primary-fixed'
: 'hover:bg-surface-container-high'
}`}
>
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${
member.active ? 'bg-on-primary-fixed/10' : 'bg-surface-container-high'
}`}>
<Icon name="smart_toy" className="text-xs" />
</div>
<span className={`text-xs ${member.active ? 'font-bold' : 'font-semibold'}`}>
{member.role}
</span>
{member.active && (
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
)}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-4">
<span className="text-[10px] text-outline italic">Teammate is thinking...</span>
<button
onClick={() => setViewingAgent(null)}
className="w-8 h-8 rounded-full bg-on-surface text-surface flex items-center justify-center hover:scale-105 transition-transform"
>
<Icon name="arrow_back" className="text-sm" />
</button>
</div>
</div>
</div>
)
}
+418
View File
@@ -0,0 +1,418 @@
import { useEffect, useRef, useState } from 'react'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useUIStore } from '../stores/uiStore'
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import { ModelSelector } from '../components/controls/ModelSelector'
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
import {
FALLBACK_SLASH_COMMANDS,
findSlashToken,
insertSlashTrigger,
replaceSlashCommand,
} from '../components/chat/composerUtils'
import type { AttachmentRef } from '../types/chat'
type Attachment = {
id: string
name: string
type: 'image' | 'file'
mimeType?: string
previewUrl?: string
data?: string
}
export function EmptySession() {
const [input, setInput] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [workDir, setWorkDir] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
const [slashFilter, setSlashFilter] = useState('')
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null)
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
const createSession = useSessionStore((state) => state.createSession)
const sendMessage = useChatStore((state) => state.sendMessage)
const connectToSession = useChatStore((state) => state.connectToSession)
const setActiveView = useUIStore((state) => state.setActiveView)
const addToast = useUIStore((state) => state.addToast)
useEffect(() => {
textareaRef.current?.focus()
}, [])
useEffect(() => {
if (!plusMenuOpen) return
const handleClick = (event: MouseEvent) => {
if (plusMenuRef.current && !plusMenuRef.current.contains(event.target as Node)) {
setPlusMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [plusMenuOpen])
useEffect(() => {
if (!slashMenuOpen) return
const handleClick = (event: MouseEvent) => {
if (
slashMenuRef.current &&
!slashMenuRef.current.contains(event.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(event.target as Node)
) {
setSlashMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [slashMenuOpen])
const filteredCommands = FALLBACK_SLASH_COMMANDS.filter((command) => {
if (!slashFilter) return true
const lower = slashFilter.toLowerCase()
return command.name.toLowerCase().includes(lower) || command.description.toLowerCase().includes(lower)
})
useEffect(() => {
setSlashSelectedIndex(0)
}, [slashFilter])
useEffect(() => {
if (slashMenuOpen && slashItemRefs.current[slashSelectedIndex]) {
slashItemRefs.current[slashSelectedIndex]?.scrollIntoView({ block: 'nearest' })
}
}, [slashMenuOpen, slashSelectedIndex])
const handleSubmit = async () => {
const text = input.trim()
if ((!text && attachments.length === 0) || isSubmitting) return
setIsSubmitting(true)
try {
const sessionId = await createSession(workDir || undefined)
setActiveView('code')
connectToSession(sessionId)
const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name,
data: attachment.data,
mimeType: attachment.mimeType,
}))
sendMessage(text, attachmentPayload)
setInput('')
setAttachments([])
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : 'Failed to create session',
})
} finally {
setIsSubmitting(false)
}
}
const handleInputChange = (value: string, cursorPos: number) => {
setInput(value)
const token = findSlashToken(value, cursorPos)
if (!token) {
setSlashMenuOpen(false)
return
}
setSlashFilter(token.filter)
setSlashMenuOpen(true)
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (slashMenuOpen && filteredCommands.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSlashSelectedIndex((prev) => (prev + 1) % filteredCommands.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length)
return
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
const selected = filteredCommands[slashSelectedIndex]
if (selected) selectSlashCommand(selected.name)
return
}
if (event.key === 'Escape') {
event.preventDefault()
setSlashMenuOpen(false)
return
}
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSubmit()
}
}
const handlePaste = (event: React.ClipboardEvent) => {
const items = event.clipboardData?.items
if (!items) return
let hasImage = false
for (let i = 0; i < items.length; i += 1) {
const item = items[i]
if (!item || !item.type.startsWith('image/')) continue
hasImage = true
event.preventDefault()
const file = item.getAsFile()
if (!file) continue
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
const reader = new FileReader()
reader.onload = () => {
setAttachments((prev) => [
...prev,
{
id,
name: `pasted-image-${Date.now()}.png`,
type: 'image',
mimeType: file.type || undefined,
previewUrl: reader.result as string,
data: reader.result as string,
},
])
}
reader.readAsDataURL(file)
}
if (!hasImage) return
}
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (!files) return
Array.from(files).forEach((file) => {
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
const isImage = file.type.startsWith('image/')
const reader = new FileReader()
reader.onload = () => {
setAttachments((prev) => [
...prev,
{
id,
name: file.name,
type: isImage ? 'image' : 'file',
mimeType: file.type || undefined,
previewUrl: isImage ? (reader.result as string) : undefined,
data: reader.result as string,
},
])
}
reader.readAsDataURL(file)
})
event.target.value = ''
}
const handleDrop = (event: React.DragEvent) => {
event.preventDefault()
const files = event.dataTransfer.files
if (files.length > 0) {
const fakeEvent = { target: { files } } as React.ChangeEvent<HTMLInputElement>
handleFileSelect(fakeEvent)
}
}
const removeAttachment = (id: string) => {
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
}
const selectSlashCommand = (command: string) => {
const el = textareaRef.current
if (!el) return
const cursorPos = el.selectionStart ?? input.length
const replacement = replaceSlashCommand(input, cursorPos, command)
if (!replacement) return
setInput(replacement.value)
setSlashMenuOpen(false)
requestAnimationFrame(() => {
el.focus()
el.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
})
}
const insertSlashCommand = () => {
const el = textareaRef.current
const cursorPos = el?.selectionStart ?? input.length
const replacement = insertSlashTrigger(input, cursorPos)
setInput(replacement.value)
setPlusMenuOpen(false)
setSlashFilter('')
setSlashMenuOpen(true)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(replacement.cursorPos, replacement.cursorPos)
})
}
return (
<div className="relative flex flex-1 flex-col overflow-hidden bg-[#FAF9F5]">
<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">
<div className="mb-6 h-24 w-24 opacity-40 grayscale contrast-125">
<MascotIllustration />
</div>
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[#1B1C1A]" style={{ fontFamily: "'Manrope', sans-serif" }}>
New session
</h1>
<p className="mx-auto max-w-xs text-[#87736D]" style={{ fontFamily: "'Inter', sans-serif" }}>
Start a fresh coding session. Claude is ready to help you build, debug, and architect your project.
</p>
</div>
</div>
<div className="absolute bottom-4 left-0 right-0 flex justify-center px-8">
<div className="flex w-full max-w-3xl flex-col gap-2">
<div
className="flex flex-col gap-3 rounded-xl border border-[#dac1ba]/15 bg-white p-4"
style={{ boxShadow: '0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08)' }}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
{slashMenuOpen && filteredCommands.length > 0 && (
<div
ref={slashMenuRef}
className="rounded-xl border border-[#dac1ba]/20 bg-white shadow-[0_18px_48px_rgba(27,28,26,0.12)]"
>
<div className="max-h-[260px] overflow-y-auto py-1">
{filteredCommands.map((command, index) => (
<button
key={command.name}
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 ${
index === slashSelectedIndex ? 'bg-[#F4F4F0]' : 'hover:bg-[#F8F7F4]'
}`}
>
<span className="text-sm font-semibold text-[#1B1C1A]">/{command.name}</span>
<span className="truncate text-xs text-[#87736D]">{command.description}</span>
</button>
))}
</div>
</div>
)}
{attachments.length > 0 && (
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
)}
<div className="flex items-start gap-3">
<textarea
ref={textareaRef}
value={input}
onChange={(event) => handleInputChange(event.target.value, event.target.selectionStart ?? event.target.value.length)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className="flex-1 resize-none border-none bg-transparent py-2 leading-relaxed text-[#1B1C1A] outline-none placeholder:text-[#87736D]/50"
style={{ fontFamily: "'Inter', sans-serif" }}
placeholder="Ask anything..."
rows={2}
/>
</div>
<div className="flex items-center justify-between border-t border-[#dac1ba]/10 pt-3">
<div className="flex items-center gap-2">
<div ref={plusMenuRef} className="relative">
<button
onClick={() => setPlusMenuOpen((prev) => !prev)}
aria-label="Open composer tools"
className="rounded-lg p-1.5 text-[#87736D] transition-colors hover:bg-[#F4F4F0]"
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
{plusMenuOpen && (
<div className="absolute bottom-full left-0 mb-2 w-[240px] rounded-xl border border-[#dac1ba]/20 bg-white py-1 shadow-[0_18px_48px_rgba(27,28,26,0.12)]">
<button
onClick={() => {
fileInputRef.current?.click()
setPlusMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[#1B1C1A] transition-colors hover:bg-[#F8F7F4]"
>
<span className="material-symbols-outlined text-[18px] text-[#87736D]">attach_file</span>
Add files or photos
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-[#1B1C1A] transition-colors hover:bg-[#F8F7F4]"
>
<span className="w-5 text-center text-[18px] font-bold text-[#87736D]">/</span>
Slash commands
</button>
</div>
)}
</div>
<PermissionModeSelector />
</div>
<div className="flex items-center gap-3">
<ModelSelector />
<button
onClick={handleSubmit}
disabled={(!input.trim() && attachments.length === 0) || isSubmitting}
className="flex w-[112px] items-center justify-center gap-1 rounded-lg bg-[var(--color-brand)] px-3 py-1.5 text-xs font-semibold text-white transition-all hover:opacity-90 disabled:opacity-30"
>
Run
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
</button>
</div>
</div>
</div>
<div>
<DirectoryPicker value={workDir} onChange={setWorkDir} />
</div>
</div>
</div>
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
</div>
)
}
function MascotIllustration() {
return (
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="24" y="30" width="48" height="36" rx="8" fill="#C47A5A" />
<rect x="28" y="18" width="40" height="16" rx="6" fill="#B86B4A" />
<line x1="48" y1="10" x2="48" y2="18" stroke="#A0796A" strokeWidth="2" strokeLinecap="round" />
<circle cx="48" cy="8" r="3" fill="#D4936F" />
<circle cx="40" cy="26" r="2.5" fill="#1B1C1A" />
<circle cx="56" cy="26" r="2.5" fill="#1B1C1A" />
<circle cx="41" cy="25" r="1" fill="white" opacity="0.7" />
<circle cx="57" cy="25" r="1" fill="white" opacity="0.7" />
<path d="M43 31 Q48 35 53 31" stroke="#1B1C1A" strokeWidth="1.5" strokeLinecap="round" fill="none" />
<rect x="36" y="40" width="24" height="16" rx="4" fill="#D4936F" opacity="0.5" />
<line x1="40" y1="44" x2="56" y2="44" stroke="#B86B4A" strokeWidth="1" strokeLinecap="round" opacity="0.6" />
<line x1="40" y1="48" x2="56" y2="48" stroke="#B86B4A" strokeWidth="1" strokeLinecap="round" opacity="0.6" />
<line x1="40" y1="52" x2="52" y2="52" stroke="#B86B4A" strokeWidth="1" strokeLinecap="round" opacity="0.6" />
<rect x="14" y="36" width="10" height="22" rx="5" fill="#C47A5A" />
<rect x="72" y="36" width="10" height="22" rx="5" fill="#C47A5A" />
<rect x="32" y="66" width="12" height="16" rx="5" fill="#C47A5A" />
<rect x="52" y="66" width="12" height="16" rx="5" fill="#C47A5A" />
<rect x="30" y="78" width="16" height="6" rx="3" fill="#B86B4A" />
<rect x="50" y="78" width="16" height="6" rx="3" fill="#B86B4A" />
<rect x="28" y="30" width="40" height="4" rx="2" fill="#D4936F" opacity="0.3" />
</svg>
)
}
+346
View File
@@ -0,0 +1,346 @@
import { useState } from 'react'
import { mockNewTaskDefaults, mockSessions, mockStatusBar } from '../mocks/data'
/**
* NewTaskModal page -- pixel-perfect replica of the HTML prototype.
* Renders the full app chrome (header, sidebar, content, footer) dimmed/blurred
* behind a centered "New Scheduled Task" modal overlay.
*
* Everything lives in this single file. Mock data is imported from ../mocks/data.
*/
export default function NewTaskModal() {
/* ── form state ─────────────────────────────────────────────────── */
const [taskName, setTaskName] = useState('')
const [description, setDescription] = useState('')
const [systemPrompt, setSystemPrompt] = useState('')
const [permissionMode, setPermissionMode] = useState(mockNewTaskDefaults.permissionModes[0])
const [model, setModel] = useState(mockNewTaskDefaults.models[0])
const [rootFolder, setRootFolder] = useState('')
const [frequency, setFrequency] = useState(mockNewTaskDefaults.frequencies[1])
const [worktree, setWorktree] = useState(false)
return (
<div className="bg-[var(--color-background)] text-[var(--color-on-surface)] font-[var(--font-body)] min-h-screen flex flex-col">
{/* ─── TopAppBar / Header ──────────────────────────────────── */}
<header className="bg-[#FAF9F5] flex justify-between items-center px-6 h-12 w-full z-40">
<div className="flex items-center gap-6">
<span className="text-sm font-bold text-[#1B1C1A] uppercase tracking-tighter font-[var(--font-headline)]">
Claude Code Companion
</span>
<nav className="hidden md:flex gap-4 font-[var(--font-headline)] font-semibold tracking-wide text-sm">
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer">Code</a>
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer">Terminal</a>
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer">History</a>
</nav>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-[#8F482F]">
<span className="material-symbols-outlined cursor-pointer active:opacity-70 text-sm">arrow_back_ios</span>
<span className="material-symbols-outlined cursor-pointer active:opacity-70 text-sm">arrow_forward_ios</span>
</div>
<span className="font-[var(--font-headline)] font-semibold tracking-wide text-sm text-[#87736D] cursor-pointer hover:text-[#8F482F] transition-colors">
Settings
</span>
</div>
</header>
{/* header divider */}
<div className="bg-[#F4F4F0] h-px w-full" />
{/* ─── Main area (sidebar + content + overlay) ──────────────── */}
<main className="flex-1 flex overflow-hidden relative">
{/* ── Sidebar (dimmed) ────────────────────────────────────── */}
<aside className="hidden md:flex flex-col p-4 gap-2 bg-[#F4F4F0] h-full w-[280px] opacity-30 pointer-events-none">
{/* project selector */}
<div className="flex items-center gap-3 px-2 py-3 mb-2">
<div className="w-8 h-8 rounded bg-[var(--color-surface-dim)] flex items-center justify-center">
<span className="material-symbols-outlined text-[var(--color-outline)]">filter</span>
</div>
<div>
<div className="text-[var(--color-on-surface)] font-semibold text-xs">All projects</div>
<div className="text-[10px] text-[var(--color-outline)]">Active Session</div>
</div>
</div>
{/* nav items -- session counts derived from mockSessions */}
<div className="font-[var(--font-body)] text-sm font-medium space-y-1">
<div className="flex items-center gap-3 px-3 py-2 text-[#87736D]">
<span className="material-symbols-outlined">add</span>New session
</div>
<div className="flex items-center gap-3 px-3 py-2 bg-[#FAF9F5] text-[#1B1C1A] rounded-lg relative before:content-[''] before:absolute before:left-[-8px] before:w-1 before:h-4 before:bg-[#8F482F] before:rounded-full">
<span className="material-symbols-outlined">calendar_today</span>Scheduled
</div>
<div className="flex items-center gap-3 px-3 py-2 text-[#87736D]">
<span className="material-symbols-outlined">history</span>Today
{mockSessions.today.length > 0 && (
<span className="ml-auto text-[10px] text-[#87736D]">{mockSessions.today.length}</span>
)}
</div>
<div className="flex items-center gap-3 px-3 py-2 text-[#87736D]">
<span className="material-symbols-outlined">event_note</span>Previous 7 Days
{mockSessions.previous7Days.length > 0 && (
<span className="ml-auto text-[10px] text-[#87736D]">{mockSessions.previous7Days.length}</span>
)}
</div>
</div>
</aside>
{/* ── Content area (dimmed canvas) ────────────────────────── */}
<div className="flex-1 bg-[var(--color-surface-container-low)] flex flex-col p-8 overflow-y-auto relative">
{/* faded background content */}
<div className="max-w-4xl mx-auto w-full space-y-6 opacity-20">
<h1 className="font-[var(--font-headline)] text-3xl font-bold tracking-tight">
Scheduled Tasks
</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="h-40 bg-[var(--color-surface-container-lowest)] rounded-xl border border-[var(--color-outline-variant)]/20 p-4" />
<div className="h-40 bg-[var(--color-surface-container-lowest)] rounded-xl border border-[var(--color-outline-variant)]/20 p-4" />
</div>
</div>
{/* ═══ MODAL OVERLAY ═══════════════════════════════════════ */}
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-[var(--color-on-surface)]/40 backdrop-blur-sm">
<div
className="bg-[var(--color-surface-container-lowest)] w-full max-w-lg rounded-xl overflow-hidden flex flex-col"
style={{
boxShadow:
'0 4px 20px rgba(27,28,26,0.04), 0 12px 40px rgba(27,28,26,0.08)',
}}
>
{/* ── Modal Header ─────────────────────────────────── */}
<div className="px-6 py-4 flex items-center justify-between border-b border-[var(--color-outline-variant)]/10">
<h2 className="font-[var(--font-headline)] font-bold text-lg text-[var(--color-on-surface)]">
New Scheduled Task
</h2>
<span className="material-symbols-outlined text-[var(--color-outline)] cursor-pointer hover:text-[var(--color-on-surface)] transition-colors">
close
</span>
</div>
{/* ── Modal Body ───────────────────────────────────── */}
<div
className="p-6 overflow-y-auto space-y-5"
style={{
maxHeight: 768,
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
{/* Info Banner */}
<div className="bg-[var(--color-surface-container-high)]/50 rounded-lg p-3 flex gap-3 items-start">
<span className="material-symbols-outlined text-[var(--color-primary)] text-sm mt-0.5">
info
</span>
<p className="text-xs text-[var(--color-on-surface-variant)] font-medium">
Local tasks only run while your computer is awake.
</p>
</div>
{/* ── Form ───────────────────────────────────────── */}
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4">
{/* Task Name */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Task Name
</label>
<input
type="text"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
placeholder="e.g., Weekly Code Audit"
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm placeholder:text-[var(--color-outline)]/50 px-4 py-2.5 transition-all outline-none"
/>
</div>
{/* Description */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Description
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief purpose of this schedule..."
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm placeholder:text-[var(--color-outline)]/50 px-4 py-2.5 transition-all outline-none"
/>
</div>
{/* System Prompt */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
System Prompt
</label>
<textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder="Define the agent's goal and behavior..."
rows={3}
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm placeholder:text-[var(--color-outline)]/50 px-4 py-2.5 transition-all resize-none outline-none"
/>
</div>
</div>
{/* Two-column row: Permission Mode + Model */}
<div className="grid grid-cols-2 gap-4">
{/* Permission Mode */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Permission Mode
</label>
<div className="relative">
<select
value={permissionMode}
onChange={(e) => setPermissionMode(e.target.value)}
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm px-4 py-2.5 appearance-none cursor-pointer outline-none"
>
{mockNewTaskDefaults.permissionModes.map((pm) => (
<option key={pm} value={pm}>
{pm}
</option>
))}
</select>
<span className="material-symbols-outlined absolute right-3 top-2.5 pointer-events-none text-[var(--color-outline)] text-sm">
unfold_more
</span>
</div>
</div>
{/* Model */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Model
</label>
<div className="relative">
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm px-4 py-2.5 appearance-none cursor-pointer outline-none"
>
{mockNewTaskDefaults.models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<span className="material-symbols-outlined absolute right-3 top-2.5 pointer-events-none text-[var(--color-outline)] text-sm">
unfold_more
</span>
</div>
</div>
</div>
{/* Root Folder Path */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Root Folder Path
</label>
<div className="flex gap-2">
<input
type="text"
value={rootFolder}
onChange={(e) => setRootFolder(e.target.value)}
placeholder="/users/projects/claude-app"
className="flex-1 bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm placeholder:text-[var(--color-outline)]/50 px-4 py-2.5 transition-all outline-none"
/>
<button className="bg-[var(--color-surface-container-high)] px-3 rounded-lg flex items-center justify-center hover:bg-[var(--color-surface-variant)] transition-colors">
<span className="material-symbols-outlined text-[var(--color-on-surface-variant)] text-sm">
folder_open
</span>
</button>
</div>
</div>
{/* Frequency */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Frequency
</label>
<div className="relative">
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value)}
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm px-4 py-2.5 appearance-none cursor-pointer outline-none"
>
{mockNewTaskDefaults.frequencies.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
<span className="material-symbols-outlined absolute right-3 top-2.5 pointer-events-none text-[var(--color-outline)] text-sm">
schedule
</span>
</div>
</div>
{/* Worktree Checkbox */}
<div className="flex items-center gap-3 pt-2">
<div className="relative flex items-center">
<input
id="worktree"
type="checkbox"
checked={worktree}
onChange={(e) => setWorktree(e.target.checked)}
className="w-4 h-4 rounded text-[var(--color-primary)] focus:ring-[var(--color-primary)] border-[var(--color-outline-variant)] bg-[var(--color-surface-container)] accent-[var(--color-primary)]"
/>
</div>
<label
htmlFor="worktree"
className="text-sm text-[var(--color-on-surface)] font-medium cursor-pointer"
>
Create separate worktree for execution
</label>
</div>
</div>
</div>
{/* ── Modal Footer ─────────────────────────────────── */}
<div className="px-6 py-4 bg-[var(--color-surface-container-low)]/50 flex items-center justify-end gap-3">
<button className="px-5 py-2 text-sm font-semibold text-[var(--color-outline)] hover:text-[var(--color-on-surface)] transition-colors">
Cancel
</button>
<button
className="px-6 py-2 rounded-lg text-sm font-bold text-[var(--color-on-primary)] shadow-sm hover:opacity-90 active:scale-95 transition-all"
style={{
backgroundImage:
'linear-gradient(to bottom, var(--color-primary), var(--color-primary-container))',
}}
>
Create task
</button>
</div>
</div>
</div>
</div>
</main>
{/* ─── Footer / Status Bar ─────────────────────────────────── */}
<footer className="bg-[#FAF9F5] flex items-center justify-between px-4 z-50 fixed bottom-0 left-0 w-full h-8 border-t border-[#87736D]/20 font-[var(--font-body)] text-xs tracking-tight">
<div className="flex items-center gap-3">
<span className="text-[#87736D]">
{mockStatusBar.user} &bull; {mockStatusBar.username} &bull; {mockStatusBar.plan}
</span>
<div className="flex items-center gap-4 ml-4">
<span className="text-[#87736D] hover:bg-[#F4F4F0] px-2 py-0.5 rounded cursor-pointer transition-colors">
{mockStatusBar.branch}
</span>
<span className="text-[#87736D] hover:bg-[#F4F4F0] px-2 py-0.5 rounded cursor-pointer transition-colors">
{mockStatusBar.worktreeToggle}
</span>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-[#8F482F] font-bold cursor-pointer">
{mockStatusBar.localSwitch}
</span>
<div className="flex items-center gap-1 text-[#87736D]">
<span className="material-symbols-outlined text-[14px]">terminal</span>
<span>{mockStatusBar.status === 'Ready' ? 'Active' : mockStatusBar.status}</span>
</div>
</div>
</footer>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react'
import { useTaskStore } from '../stores/taskStore'
import { useUIStore } from '../stores/uiStore'
import { Button } from '../components/shared/Button'
import { TaskList } from '../components/tasks/TaskList'
import { TaskEmptyState } from '../components/tasks/TaskEmptyState'
import { NewTaskModal } from '../components/tasks/NewTaskModal'
export function ScheduledTasks() {
const { tasks, fetchTasks, isLoading } = useTaskStore()
const { activeModal, openModal, closeModal } = useUIStore()
const [initialized, setInitialized] = useState(false)
useEffect(() => {
fetchTasks().then(() => setInitialized(true))
}, [fetchTasks])
return (
<div className="flex-1 overflow-y-auto">
<div className="px-10 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Scheduled tasks</h1>
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
Run tasks on a schedule or whenever you need them.
</p>
</div>
<Button onClick={() => openModal('new-task')}>+ New task</Button>
</div>
{/* Content */}
{!initialized && isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin w-6 h-6 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
</div>
) : tasks.length === 0 ? (
<TaskEmptyState onCreateTask={() => openModal('new-task')} />
) : (
<TaskList tasks={tasks} />
)}
</div>
{/* New Task Modal */}
<NewTaskModal
open={activeModal === 'new-task'}
onClose={closeModal}
/>
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
import { mockStatusBar } from '../mocks/data'
export function ScheduledTasksEmpty() {
return (
<div className="bg-[#FAF9F5] text-[#1B1C1A] flex min-h-screen font-[Inter,sans-serif]">
{/* SideNavBar */}
<aside className="fixed left-0 top-0 h-full w-[280px] bg-[#F4F4F0] flex flex-col p-4 gap-2 text-sm font-medium z-40">
<div className="flex items-center gap-3 px-2 mb-6 mt-12">
<div className="w-8 h-8 rounded bg-[#E3E2DF] flex items-center justify-center">
<span className="material-symbols-outlined text-[#87736D]">filter_list</span>
</div>
<div>
<div className="text-[#1B1C1A] font-bold">All projects</div>
<div className="text-[10px] text-[#87736D] uppercase tracking-widest">Active Session</div>
</div>
</div>
<nav className="flex-1 space-y-1">
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">add</span>
<span>New session</span>
</div>
{/* Active State: Scheduled */}
<div className="px-3 py-2 bg-[#FAF9F5] text-[#1B1C1A] rounded-lg relative before:content-[''] before:absolute before:left-[-8px] before:w-1 before:h-4 before:bg-[#8F482F] before:rounded-full cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">calendar_today</span>
<span>Scheduled</span>
</div>
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">history</span>
<span>Today</span>
</div>
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">event_note</span>
<span>Previous 7 Days</span>
</div>
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">archive</span>
<span>Older</span>
</div>
</nav>
<div className="mt-auto pt-4 border-t border-[#87736D]/10">
<div className="text-[10px] text-[#87736D] uppercase tracking-widest px-3 mb-2">Execution Mode</div>
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">computer</span>
<span>Local Mode</span>
</div>
<div className="px-3 py-2 text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg cursor-pointer duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">cloud</span>
<span>Remote Mode</span>
</div>
</div>
</aside>
{/* Main Canvas */}
<main className="flex-1 ml-[280px] flex flex-col min-h-screen">
{/* TopAppBar */}
<header className="fixed top-0 right-0 left-[280px] z-30 bg-[#FAF9F5] flex justify-between items-center px-6 h-12 w-full border-b border-[#F4F4F0]">
<div className="flex items-center gap-6 h-full">
<div className="text-sm font-bold text-[#1B1C1A] uppercase tracking-tighter font-[Manrope,sans-serif]">Claude Code Companion</div>
<nav className="flex items-center gap-4 h-full font-[Manrope,sans-serif] font-semibold tracking-wide text-sm">
<span className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70 h-full flex items-center">Code</span>
<span className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70 h-full flex items-center">Terminal</span>
<span className="text-[#1B1C1A] border-b-2 border-[#8F482F] pb-1 h-full flex items-center pt-1">History</span>
</nav>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<span className="material-symbols-outlined text-[#87736D] text-sm cursor-pointer">arrow_back_ios</span>
<span className="material-symbols-outlined text-[#87736D] text-sm cursor-pointer">arrow_forward_ios</span>
</div>
<div className="h-4 w-[1px] bg-[#87736D]/20"></div>
<div className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer text-xs font-semibold uppercase tracking-wider">Settings</div>
</div>
</header>
{/* Content Area */}
<div className="flex-1 mt-12 mb-8 flex flex-col items-center justify-center p-8 bg-[#FAF9F5]">
<div className="max-w-2xl w-full text-center">
<h1 className="text-3xl font-[Manrope,sans-serif] font-extrabold text-[#1B1C1A] tracking-tight mb-16">Scheduled tasks</h1>
{/* Empty State Illustration/Card */}
<div className="relative group">
{/* Architectural Background Detail */}
<div className="absolute -inset-4 bg-[#F4F4F0] rounded-[32px] -z-10 transition-all"></div>
<div className="flex flex-col items-center py-20 px-8">
<div className="w-32 h-32 rounded-full bg-[#E9E8E4] flex items-center justify-center mb-10 shadow-sm">
<div className="w-20 h-20 rounded-full bg-white flex items-center justify-center border border-[#DAC1BA]/10">
<span
className="material-symbols-outlined text-[#8F482F] text-5xl"
style={{ fontVariationSettings: "'wght' 300" }}
>
schedule
</span>
</div>
</div>
<p className="text-[#54433E] font-[Inter,sans-serif] text-lg max-w-sm mx-auto leading-relaxed mb-12">
No scheduled tasks yet. Create a recurring task to automate your engineering workflows.
</p>
<button className="group relative px-8 py-4 bg-[#8F482F] text-white rounded-xl font-[Manrope,sans-serif] font-bold text-sm tracking-wide shadow-lg hover:shadow-[#8F482F]/20 transition-all flex items-center gap-3 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-white/10 to-transparent opacity-30"></div>
<span className="material-symbols-outlined text-lg">add_task</span>
<span>+ New task</span>
</button>
</div>
</div>
{/* Subtle Decorative Bento Elements */}
<div className="grid grid-cols-3 gap-4 mt-20 opacity-40">
<div className="h-24 bg-[#F4F4F0] rounded-xl border border-[#DAC1BA]/10 flex flex-col items-center justify-center p-4">
<span className="material-symbols-outlined text-[#87736D] mb-2">commit</span>
<div className="w-12 h-1 bg-[#DAC1BA]/30 rounded-full"></div>
</div>
<div className="h-24 bg-[#F4F4F0] rounded-xl border border-[#DAC1BA]/10 flex flex-col items-center justify-center p-4">
<span className="material-symbols-outlined text-[#87736D] mb-2">terminal</span>
<div className="w-8 h-1 bg-[#DAC1BA]/30 rounded-full"></div>
</div>
<div className="h-24 bg-[#F4F4F0] rounded-xl border border-[#DAC1BA]/10 flex flex-col items-center justify-center p-4">
<span className="material-symbols-outlined text-[#87736D] mb-2">code_blocks</span>
<div className="w-10 h-1 bg-[#DAC1BA]/30 rounded-full"></div>
</div>
</div>
</div>
</div>
{/* Footer */}
<footer className="fixed bottom-0 left-0 w-full h-8 bg-[#FAF9F5] flex items-center justify-between px-4 z-50 border-t border-[#87736D]/20 font-[Inter,sans-serif] text-xs tracking-tight">
<div className="flex items-center gap-3 text-[#87736D]">
<div className="w-2 h-2 rounded-full bg-[#677B4E]"></div>
<span>{mockStatusBar.user} &bull; {mockStatusBar.username} &bull; {mockStatusBar.plan}</span>
</div>
<div className="flex items-center gap-6">
<span className="text-[#87736D] hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors cursor-pointer flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">account_tree</span>
{mockStatusBar.branch}
</span>
<span className="text-[#87736D] hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors cursor-pointer flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">workspaces</span>
{mockStatusBar.worktreeToggle}
</span>
<span className="text-[#8F482F] font-bold hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors cursor-pointer flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">toggle_on</span>
{mockStatusBar.localSwitch}
</span>
</div>
</footer>
</main>
</div>
)
}
+414
View File
@@ -0,0 +1,414 @@
import { mockScheduledTasks, mockStatusBar } from '../mocks/data'
export function ScheduledTasksList() {
const { stats, tasks } = mockScheduledTasks
const task0 = tasks[0]!
const task1 = tasks[1]!
const task2 = tasks[2]!
return (
<div className="bg-[#FAF9F5] text-[#1B1C1A] flex min-h-screen overflow-hidden font-[Inter,sans-serif]">
{/* SideNavBar */}
<aside className="fixed left-0 top-0 h-full w-[280px] bg-[#F4F4F0] flex flex-col p-4 gap-2 z-40">
<div className="mb-6 px-2 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[#AD5F45] flex items-center justify-center">
<span
className="material-symbols-outlined text-white"
style={{ fontVariationSettings: "'FILL' 1" }}
>
folder_managed
</span>
</div>
<div>
<h2 className="font-[Manrope,sans-serif] text-sm font-bold text-[#1B1C1A] uppercase tracking-tighter">All projects</h2>
<p className="text-xs text-[#87736D] font-medium">Active Session</p>
</div>
</div>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">add</span>
New session
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full bg-[#FAF9F5] text-[#1B1C1A] rounded-lg relative before:content-[''] before:absolute before:left-[-8px] before:w-1 before:h-4 before:bg-[#8F482F] before:rounded-full font-medium text-sm duration-200 ease-in-out">
<span
className="material-symbols-outlined"
style={{ fontVariationSettings: "'FILL' 1" }}
>
calendar_today
</span>
Scheduled
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">history</span>
Today
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">event_note</span>
Previous 7 Days
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">archive</span>
Older
</button>
<div className="mt-auto pt-4 flex flex-col gap-2">
<div className="px-2 py-4">
<button className="w-full bg-[#E9E8E4] text-[#1B1C1A] font-[Manrope,sans-serif] text-xs font-bold py-2 rounded-lg flex items-center justify-center gap-2 hover:bg-[#E3E2DF] transition-colors">
<span className="material-symbols-outlined text-[1rem]">search</span>
Search sessions
</button>
</div>
<div className="h-[1px] bg-[#DAC1BA]/20 mx-2 mb-2"></div>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">computer</span>
Local Mode
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">cloud</span>
Remote Mode
</button>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 flex flex-col ml-[280px] min-w-0 h-screen">
{/* TopAppBar */}
<header className="bg-[#FAF9F5] h-12 w-full flex justify-between items-center px-6 z-30">
<div className="flex items-center gap-8">
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] uppercase tracking-tighter text-sm">Claude Code Companion</div>
<nav className="flex items-center gap-6 font-[Manrope,sans-serif] font-semibold tracking-wide text-sm">
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors" href="#">Code</a>
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors" href="#">Terminal</a>
<a className="text-[#1B1C1A] border-b-2 border-[#8F482F] pb-1" href="#">History</a>
</nav>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<button className="p-1 text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70">
<span className="material-symbols-outlined text-[1rem]">arrow_back_ios</span>
</button>
<button className="p-1 text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70">
<span className="material-symbols-outlined text-[1rem]">arrow_forward_ios</span>
</button>
</div>
<button className="font-[Manrope,sans-serif] font-semibold tracking-wide text-sm text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70 flex items-center gap-1">
<span className="material-symbols-outlined text-[1.1rem]">settings</span>
Settings
</button>
</div>
</header>
{/* Separation Line */}
<div className="bg-[#F4F4F0] h-[1px] w-full"></div>
{/* Scrollable Content */}
<section className="flex-1 overflow-y-auto p-12 bg-[#FAF9F5]">
<div className="max-w-5xl mx-auto">
{/* Page Header */}
<div className="flex justify-between items-end mb-12">
<div className="space-y-1">
<h1 className="font-[Manrope,sans-serif] text-3xl font-bold tracking-tight text-[#1B1C1A]">Scheduled Tasks</h1>
<p className="text-[#87736D] text-sm">Manage automated operational routines and background maintenance.</p>
</div>
<button className="bg-[#8F482F] hover:bg-[#AD5F45] text-white px-5 py-2.5 rounded-lg flex items-center gap-2 transition-all shadow-sm font-medium text-sm">
<span className="material-symbols-outlined text-[1.1rem]">add_task</span>
Create new task
</button>
</div>
{/* Bento-style Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
{/* Total Tasks */}
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">Total Tasks</span>
<span className="material-symbols-outlined text-[#8F482F]">analytics</span>
</div>
<div className="text-4xl font-[Manrope,sans-serif] font-extrabold text-[#1B1C1A]">{stats.totalTasks}</div>
<div className="mt-2 flex items-center gap-1 text-[10px] text-[#4F6237] font-bold bg-[#677B4E]/20 px-2 py-0.5 rounded-full w-fit">
<span className="material-symbols-outlined text-[10px]">trending_up</span>
+2 this month
</div>
</div>
{/* Next Run */}
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">Next Run</span>
<span className="material-symbols-outlined text-[#2D628F]">schedule</span>
</div>
<div className="text-xl font-[Manrope,sans-serif] font-bold text-[#1B1C1A]">{stats.nextRun.name}</div>
<p className="text-sm font-[JetBrains_Mono,monospace] text-[#2D628F] mt-1">{stats.nextRun.time}</p>
</div>
{/* System Health */}
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">System Health</span>
<span className="material-symbols-outlined text-[#4F6237]">check_circle</span>
</div>
<div className="text-4xl font-[Manrope,sans-serif] font-extrabold text-[#1B1C1A]">{stats.systemHealth}%</div>
<p className="text-xs text-[#87736D] mt-2 font-medium">{stats.healthPeriod}</p>
</div>
</div>
{/* Operational Tasks Table */}
<div className="bg-white rounded-xl overflow-hidden border border-[#DAC1BA]/20 shadow-[0_4px_20px_rgba(27,28,26,0.04)]">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-[#F4F4F0]/50">
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">Task Name</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">Frequency</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">Last Result</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">Next Execution</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[#DAC1BA]/5">
{/* Task Row 1 - Nightly linting */}
<tr className="group hover:bg-[#F4F4F0]/30 transition-colors">
<td className="px-6 py-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-[#FFDBD0] text-[#8F482F] rounded-lg">
<span className="material-symbols-outlined text-[1.2rem]">code_blocks</span>
</div>
<div>
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-sm">{task0.name}</div>
<div className="text-xs text-[#87736D] font-medium">Root: /projects/companion/src</div>
</div>
</div>
</td>
<td className="px-6 py-5">
<span className="px-2.5 py-1 bg-[#E9E8E4] rounded-full text-xs font-semibold text-[#54433E]">{task0.frequency}</span>
</td>
<td className="px-6 py-5">
<div className="flex items-center gap-1.5 text-[#4F6237] text-xs font-bold">
<span
className="material-symbols-outlined text-[1rem]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
check_circle
</span>
{task0.lastResult}
</div>
</td>
<td className="px-6 py-5">
<div className="font-[JetBrains_Mono,monospace] text-sm font-medium text-[#2D628F]">{task0.nextExecution}</div>
</td>
<td className="px-6 py-5 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 text-[#87736D] hover:text-[#8F482F] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">edit</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#BA1A1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">delete</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#1B1C1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">more_vert</span>
</button>
</div>
</td>
</tr>
{/* Task Row 2 - Clean up temp files */}
<tr className="group hover:bg-[#F4F4F0]/30 transition-colors">
<td className="px-6 py-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-[#CFE5FF] text-[#094A76] rounded-lg">
<span className="material-symbols-outlined text-[1.2rem]">cleaning_services</span>
</div>
<div>
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-sm">{task1.name}</div>
<div className="text-xs text-[#87736D] font-medium">{task1.description}</div>
</div>
</div>
</td>
<td className="px-6 py-5">
<span className="px-2.5 py-1 bg-[#E9E8E4] rounded-full text-xs font-semibold text-[#54433E]">{task1.frequency}</span>
</td>
<td className="px-6 py-5">
<div className="flex items-center gap-1.5 text-[#4F6237] text-xs font-bold">
<span
className="material-symbols-outlined text-[1rem]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
check_circle
</span>
{task1.lastResult}
</div>
</td>
<td className="px-6 py-5">
<div className="font-[JetBrains_Mono,monospace] text-sm font-medium text-[#2D628F]">{task1.nextExecution}</div>
</td>
<td className="px-6 py-5 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 text-[#87736D] hover:text-[#8F482F] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">edit</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#BA1A1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">delete</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#1B1C1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">more_vert</span>
</button>
</div>
</td>
</tr>
{/* Task Row 3 - Database Vacuum */}
<tr className="group hover:bg-[#F4F4F0]/30 transition-colors">
<td className="px-6 py-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-[#D4EAB4] text-[#3B4C24] rounded-lg">
<span className="material-symbols-outlined text-[1.2rem]">database</span>
</div>
<div>
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-sm">{task2.name}</div>
<div className="text-xs text-[#87736D] font-medium">{task2.description}</div>
</div>
</div>
</td>
<td className="px-6 py-5">
<span className="px-2.5 py-1 bg-[#E9E8E4] rounded-full text-xs font-semibold text-[#54433E]">Monthly</span>
</td>
<td className="px-6 py-5">
<div className="flex items-center gap-1.5 text-[#BA1A1A] text-xs font-bold">
<span
className="material-symbols-outlined text-[1rem]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
error
</span>
{task2.lastResult}
</div>
</td>
<td className="px-6 py-5">
<div className="font-[JetBrains_Mono,monospace] text-sm font-medium text-[#2D628F]">{task2.nextExecution}</div>
</td>
<td className="px-6 py-5 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 text-[#87736D] hover:text-[#8F482F] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">edit</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#BA1A1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">delete</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#1B1C1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">more_vert</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
{/* End of list placeholder */}
<div className="p-12 text-center border-t border-[#DAC1BA]/10">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-[#F4F4F0] mb-4">
<span className="material-symbols-outlined text-[#87736D]">history_toggle_off</span>
</div>
<h3 className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-base">End of scheduled list</h3>
<p className="text-sm text-[#87736D] max-w-xs mx-auto mt-1">9 other tasks are currently paused or disabled for maintenance.</p>
</div>
</div>
{/* System Logs / Details Panel */}
<div className="mt-12 flex flex-col md:flex-row gap-8 items-start">
{/* Recent Output Logs */}
<div className="flex-1 space-y-6">
<h2 className="font-[Manrope,sans-serif] text-lg font-bold text-[#1B1C1A]">Recent Output Logs</h2>
<div className="bg-[#DBDAD6] rounded-xl p-6 font-[JetBrains_Mono,monospace] text-[13px] leading-relaxed text-[#54433E] overflow-x-auto shadow-inner">
<div className="flex gap-4 opacity-50 mb-1">
<span className="w-32 shrink-0">2023-11-10 23:01</span>
<span className="text-[#4F6237]">[INFO]</span>
<span>Nightly linting started for repository: companion-main</span>
</div>
<div className="flex gap-4 mb-1">
<span className="w-32 shrink-0">2023-11-10 23:04</span>
<span className="text-[#4F6237]">[INFO]</span>
<span>Processed 1,422 files. No critical issues found.</span>
</div>
<div className="flex gap-4 mb-1">
<span className="w-32 shrink-0">2023-11-10 23:04</span>
<span className="text-[#094A76]">[WARN]</span>
<span className="italic">Found 12 deprecated calls in /legacy/utils.js</span>
</div>
<div className="flex gap-4 mb-1">
<span className="w-32 shrink-0">2023-11-10 23:05</span>
<span className="text-[#4F6237]">[INFO]</span>
<span>Task completed successfully in 242.4s.</span>
</div>
<div className="mt-4 pt-4 border-t border-[#DAC1BA]/20 flex items-center justify-between">
<span className="text-[11px] uppercase tracking-tighter opacity-50">Log stream: active</span>
<button className="text-[#8F482F] font-bold text-xs hover:underline">View Full Artifacts</button>
</div>
</div>
</div>
{/* Resource Allocation Panel */}
<div className="w-full md:w-80 shrink-0">
<div className="bg-[#AD5F45]/10 p-6 rounded-xl border border-[#8F482F]/10">
<h3 className="font-[Manrope,sans-serif] font-bold text-[#8F482F] text-sm mb-3">Resource Allocation</h3>
<div className="space-y-4">
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-bold text-[#87736D] uppercase tracking-wider">
<span>CPU Capacity</span>
<span>42%</span>
</div>
<div className="w-full h-1 bg-[#DAC1BA]/30 rounded-full overflow-hidden">
<div className="h-full bg-[#8F482F]" style={{ width: '42%' }}></div>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-bold text-[#87736D] uppercase tracking-wider">
<span>Memory Load</span>
<span>68%</span>
</div>
<div className="w-full h-1 bg-[#DAC1BA]/30 rounded-full overflow-hidden">
<div className="h-full bg-[#2D628F]" style={{ width: '68%' }}></div>
</div>
</div>
</div>
<div className="mt-6">
<div className="w-full h-24 rounded-lg bg-gradient-to-br from-[#FFDBD0] via-[#FFB59D]/40 to-[#DAC1BA]/20"></div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-[#FAF9F5] border-t border-[#87736D]/20 fixed bottom-0 left-0 w-full h-8 flex items-center justify-between px-4 z-50">
<div className="flex items-center gap-4">
<span className="font-[Inter,sans-serif] text-xs tracking-tight text-[#87736D]">{mockStatusBar.user} &bull; {mockStatusBar.username} &bull; {mockStatusBar.plan}</span>
<div className="h-3 w-[1px] bg-[#87736D]/30"></div>
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[10px] text-[#4F6237]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
fiber_manual_record
</span>
<span className="font-[Inter,sans-serif] text-xs tracking-tight text-[#1B1C1A]">Connected to local node</span>
</div>
</div>
<div className="flex items-center gap-6">
<button className="font-[Inter,sans-serif] text-xs tracking-tight text-[#87736D] hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">account_tree</span>
{mockStatusBar.branch}
</button>
<button className="font-[Inter,sans-serif] text-xs tracking-tight text-[#87736D] hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">layers</span>
{mockStatusBar.worktreeToggle}
</button>
<button className="font-[Inter,sans-serif] text-xs tracking-tight text-[#8F482F] font-bold hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">toggle_on</span>
{mockStatusBar.localSwitch}
</button>
</div>
</footer>
</main>
</div>
)
}
+460
View File
@@ -0,0 +1,460 @@
import { useState } from 'react'
import {
mockPermissionModes,
mockModels,
mockEffortLevels,
mockSessions,
mockStatusBar,
} from '../mocks/data'
/* ────────────────────────────────────────────────────────────────────
Icon helpers for the permission modes (matching HTML prototype exactly)
──────────────────────────────────────────────────────────────────── */
const permissionIcons: Record<string, { icon: string; color: string; selectedColor?: string }> = {
ask: { icon: 'verified_user', color: 'text-outline' },
auto: { icon: 'bolt', color: 'text-outline' },
plan: { icon: 'architecture', color: 'text-tertiary' },
bypass: { icon: 'gavel', color: 'text-error' },
}
const modelIcons: Record<string, string> = {
opus: 'psychology',
sonnet: 'smart_toy',
haiku: 'auto_awesome',
}
/* ════════════════════════════════════════════════════════════════════
SessionControls — full-page component (sidebar + header + chat +
two open dropdown panels + composer + footer)
════════════════════════════════════════════════════════════════════ */
export default function SessionControls() {
const [selectedPermission, setSelectedPermission] = useState('ask')
const [selectedModel, setSelectedModel] = useState('sonnet')
const [selectedEffort, setSelectedEffort] = useState('Medium')
const [showPermissions, setShowPermissions] = useState(true)
const [showModelConfig, setShowModelConfig] = useState(true)
const activeModel = mockModels.find((m) => m.id === selectedModel)
return (
<div className="h-screen w-screen bg-background text-on-surface font-body selection:bg-primary-fixed overflow-hidden relative">
{/* ─── TopAppBar ─────────────────────────────────────────── */}
<header className="bg-[#FAF9F5] font-headline font-semibold tracking-wide text-sm fixed top-0 left-0 right-0 flex justify-between items-center px-6 h-12 z-40">
<div className="flex items-center gap-6">
<span className="text-sm font-bold text-[#1B1C1A] uppercase tracking-tighter">
Claude Code Companion
</span>
<nav className="hidden md:flex gap-4">
<a className="text-[#1B1C1A] border-b-2 border-[#8F482F] pb-1 cursor-pointer active:opacity-70">
Code
</a>
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70">
Terminal
</a>
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70">
History
</a>
</nav>
</div>
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-[#87736D] cursor-pointer">
arrow_back_ios
</span>
<span className="material-symbols-outlined text-[#87736D] cursor-pointer">
arrow_forward_ios
</span>
<button className="ml-2 px-3 py-1 bg-surface-container-high rounded text-[#8F482F] hover:bg-surface-container-highest transition-colors">
Settings
</button>
</div>
</header>
{/* Separator line */}
<div className="bg-[#F4F4F0] h-[1px] w-full fixed top-12 z-40" />
{/* ─── SideNavBar ────────────────────────────────────────── */}
<aside className="bg-[#F4F4F0] font-body text-sm font-medium fixed left-0 top-0 h-full w-[280px] hidden md:flex flex-col p-4 gap-2 pt-16 z-30">
{/* Project header */}
<div className="px-2 mb-4">
<div className="flex items-center gap-3 mb-1">
<img
className="w-8 h-8 rounded-full"
src="https://lh3.googleusercontent.com/aida-public/AB6AXuCjFls9oDx5jX7Zv8P7BA9QbodBzvDJFhVNIiVjAhp_OhnXT6lmE-uYCDDZvNS4kWHssfxAYuiH05KsXLBWgLd4K-8prrjodVjSsKAG1LhvKWN90nyVzDBSrreWkpW7reNC1N_T4J_Pdr9mgAYVwYRS10nvUMZs_ajpTg2CoTtMkQRRGZGZXLk_gU94EoaeDEPNbvwaxOeeTeGgOxwnzcPIUn6EFzqc5Bjug00IDIrhRYiuwEaGNkTuz39mNFxJl2bKiHES5HxUM60"
alt="project avatar"
/>
<div>
<h3 className="text-on-surface font-bold leading-none">All projects</h3>
<p className="text-[10px] text-outline uppercase tracking-widest mt-1">
Active Session
</p>
</div>
</div>
</div>
{/* Nav items */}
<button className="w-full text-left p-2.5 bg-[#FAF9F5] text-[#1B1C1A] rounded-lg relative before:content-[''] before:absolute before:left-[-8px] before:w-1 before:h-4 before:bg-[#8F482F] before:rounded-full before:top-1/2 before:-translate-y-1/2 transition-all duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined text-[#8F482F]">add</span>
<span>New session</span>
</button>
<button className="w-full text-left p-2.5 text-[#87736D] hover:bg-[#EBEBE6] rounded-lg transition-all duration-200 ease-in-out flex items-center gap-3">
<span className="material-symbols-outlined">calendar_today</span>
<span>Scheduled</span>
</button>
<button className="w-full text-left p-2.5 text-[#87736D] hover:bg-[#EBEBE6] rounded-lg transition-all duration-200 ease-in-out flex items-center gap-3" data-count={mockSessions.today.length}>
<span className="material-symbols-outlined">history</span>
<span>Today</span>
</button>
<button className="w-full text-left p-2.5 text-[#87736D] hover:bg-[#EBEBE6] rounded-lg transition-all duration-200 ease-in-out flex items-center gap-3" data-count={mockSessions.previous7Days.length}>
<span className="material-symbols-outlined">event_note</span>
<span>Previous 7 Days</span>
</button>
<button className="w-full text-left p-2.5 text-[#87736D] hover:bg-[#EBEBE6] rounded-lg transition-all duration-200 ease-in-out flex items-center gap-3" data-count={mockSessions.older.length}>
<span className="material-symbols-outlined">archive</span>
<span>Older</span>
</button>
{/* Bottom modes */}
<div className="mt-auto pt-4 border-t border-outline/10 flex flex-col gap-1">
<button className="flex items-center gap-3 p-2 text-outline hover:text-primary transition-colors">
<span className="material-symbols-outlined text-xs">computer</span>
<span className="text-xs">Local Mode</span>
</button>
<button className="flex items-center gap-3 p-2 text-outline hover:text-primary transition-colors">
<span className="material-symbols-outlined text-xs">cloud</span>
<span className="text-xs">Remote Mode</span>
</button>
</div>
</aside>
{/* ─── Main Content Area (blurred / dimmed behind overlays) ── */}
<main className="md:ml-[280px] pt-12 pb-8 min-h-screen blur-[2px] opacity-60">
<div className="max-w-4xl mx-auto px-8 py-12">
<div className="grid grid-cols-12 gap-6">
{/* Main Thread */}
<div className="col-span-8 space-y-8">
{/* AI message */}
<div className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-on-primary font-bold text-xs">
AI
</div>
<span className="font-semibold text-sm">Claude 4.6 Sonnet</span>
</div>
<p className="text-on-surface-variant leading-relaxed">
I've analyzed the{' '}
<code className="bg-surface-dim px-1.5 py-0.5 rounded font-mono text-sm">
auth_provider.go
</code>{' '}
file. The race condition occurs during the token refresh cycle. I recommend
wrapping the session update in a mutex lock.
</p>
</div>
{/* User message */}
<div className="flex justify-end">
<div className="bg-surface-container-highest rounded-xl p-6 max-w-[80%]">
<p className="text-on-surface leading-relaxed">
Can you implement that? Also check if this affects the WebSocket connection
longevity.
</p>
</div>
</div>
{/* Code Block Preview */}
<div className="bg-surface-dim rounded-lg overflow-hidden font-mono text-sm">
<div className="bg-surface-container-high px-4 py-2 flex justify-between items-center">
<span className="text-xs text-on-surface-variant">
internal/auth/provider.go
</span>
<span className="material-symbols-outlined text-sm text-outline cursor-pointer">
content_copy
</span>
</div>
<pre className="p-4 text-on-surface">
<code>{`func (p *Provider) RefreshToken(ctx context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
// logic to refresh token...
return nil
}`}</code>
</pre>
</div>
</div>
{/* Session Meta */}
<div className="col-span-4 space-y-6">
<div className="bg-surface-container-lowest border border-outline-variant/20 rounded-xl p-4">
<h4 className="text-xs font-bold uppercase tracking-widest text-outline mb-4">
Context Files
</h4>
<ul className="space-y-3">
<li className="flex items-center gap-2 text-sm text-on-surface-variant">
<span className="material-symbols-outlined text-sm">description</span>
auth_provider.go
</li>
<li className="flex items-center gap-2 text-sm text-on-surface-variant">
<span className="material-symbols-outlined text-sm">description</span>
main.go
</li>
<li className="flex items-center gap-2 text-sm text-on-surface-variant">
<span className="material-symbols-outlined text-sm">description</span>
session_test.go
</li>
</ul>
</div>
</div>
</div>
</div>
</main>
{/* ─── Overlay Layer (Active Selectors) ──────────────────── */}
<div className="fixed inset-0 z-50 flex flex-col justify-end items-center pointer-events-none p-8">
{/* Floating Dropdown Overlay Container */}
<div className="w-full max-w-2xl flex gap-4 mb-4 pointer-events-auto items-end">
{/* ── Permissions Dropdown ─────────────────────────────── */}
{showPermissions && (
<div
className="w-80 rounded-xl border border-[#E8E6DC] overflow-hidden flex flex-col"
style={{
background: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(20px)',
boxShadow:
'0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08)',
}}
>
<div className="px-4 py-3 bg-surface-container-low border-b border-[#E8E6DC]">
<span className="text-[10px] font-bold uppercase tracking-widest text-outline">
Execution Permissions
</span>
</div>
<div className="p-2 space-y-1">
{mockPermissionModes.map((mode) => {
const meta = permissionIcons[mode.id] || {
icon: mode.icon,
color: 'text-outline',
}
const isSelected = selectedPermission === mode.id
const isPlan = mode.id === 'plan'
const isBypass = mode.id === 'bypass'
let hoverClass = 'hover:bg-surface-container-high'
if (isPlan) hoverClass = 'hover:bg-tertiary-container/10'
if (isBypass) hoverClass = 'hover:bg-error-container/20'
let labelColor = ''
if (isPlan) labelColor = 'text-tertiary'
if (isBypass) labelColor = 'text-error'
return (
<button
key={mode.id}
onClick={() => setSelectedPermission(mode.id)}
className={`w-full text-left p-3 rounded-lg ${hoverClass} transition-colors flex gap-3 group`}
>
<span
className={`material-symbols-outlined ${meta.color} mt-0.5`}
>
{meta.icon}
</span>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className={`text-sm font-semibold ${labelColor}`}>
{mode.label}
</span>
{isSelected && (
<span className="material-symbols-outlined text-primary text-sm">
check_circle
</span>
)}
</div>
<p className="text-xs text-on-surface-variant">{mode.description}</p>
</div>
</button>
)
})}
</div>
</div>
)}
{/* ── Model & Effort Dropdown ─────────────────────────── */}
{showModelConfig && (
<div
className="w-64 rounded-xl border border-[#E8E6DC] overflow-hidden flex flex-col"
style={{
background: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(20px)',
boxShadow:
'0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08)',
}}
>
<div className="px-4 py-3 bg-surface-container-low border-b border-[#E8E6DC]">
<span className="text-[10px] font-bold uppercase tracking-widest text-outline">
Model Configuration
</span>
</div>
{/* Models */}
<div className="p-2">
{mockModels.map((model) => {
const isActive = selectedModel === model.id
const icon = modelIcons[model.id] || 'smart_toy'
return (
<button
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={`w-full text-left p-2.5 rounded-lg flex items-center justify-between transition-colors ${
isActive
? 'bg-primary/5 text-primary'
: 'hover:bg-surface-container-high'
}`}
>
<div
className={`flex items-center gap-2 ${
isActive ? 'font-semibold' : ''
}`}
>
<span
className={`material-symbols-outlined text-sm ${
isActive ? '' : 'text-outline'
}`}
>
{icon}
</span>
<span className="text-sm">{model.name}</span>
</div>
{isActive && (
<span className="material-symbols-outlined text-sm">
radio_button_checked
</span>
)}
</button>
)
})}
</div>
{/* Divider */}
<div className="mx-4 h-[1px] bg-[#E8E6DC]" />
{/* Effort levels */}
<div className="p-2">
<div className="px-2 mb-2">
<span className="text-[9px] font-bold text-outline uppercase tracking-tighter">
Thinking Effort
</span>
</div>
<div className="grid grid-cols-2 gap-1">
{mockEffortLevels.map((level) => {
const isActive = selectedEffort === level
/* The HTML uses "Med" for the label display of "Medium" */
const displayLabel =
level === 'Medium' ? 'Med' : level
return (
<button
key={level}
onClick={() => setSelectedEffort(level)}
className={`text-xs py-2 px-3 rounded-md transition-all ${
isActive
? 'bg-primary text-on-primary'
: 'border border-outline-variant/30 hover:border-primary'
} ${level === 'Max' ? 'font-bold' : ''}`}
>
{displayLabel}
</button>
)
})}
</div>
</div>
</div>
)}
</div>
{/* ── The Composer (Anchor) ─────────────────────────────── */}
<div
className="w-full max-w-2xl p-4 rounded-xl border border-outline-variant/15 pointer-events-auto flex flex-col gap-3"
style={{
background: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(20px)',
boxShadow:
'0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08)',
}}
>
<textarea
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none resize-none font-body text-on-surface placeholder:text-outline"
placeholder="Reply to Claude..."
rows={2}
/>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
{/* Permission pill */}
<button
onClick={() => setShowPermissions((v) => !v)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-container-low border border-[#E8E6DC] text-xs font-medium hover:bg-surface-container-high transition-all"
>
<span className="material-symbols-outlined text-base">
{permissionIcons[selectedPermission]?.icon || 'verified_user'}
</span>
{mockPermissionModes.find((m) => m.id === selectedPermission)?.label ||
'Ask permissions'}
</button>
{/* Model pill */}
<button
onClick={() => setShowModelConfig((v) => !v)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-surface-container-low border border-[#E8E6DC] text-xs font-medium hover:bg-surface-container-high transition-all"
>
<span className="material-symbols-outlined text-base">
{modelIcons[selectedModel] || 'smart_toy'}
</span>
{activeModel?.name || 'Sonnet 4.6'}
</button>
{/* Attach file button */}
<button className="p-1.5 rounded-lg text-outline hover:bg-surface-container-low transition-colors">
<span className="material-symbols-outlined">attach_file</span>
</button>
</div>
{/* Run button */}
<button className="bg-primary text-on-primary px-4 py-1.5 rounded-lg font-semibold text-sm flex items-center gap-2 hover:opacity-90 transition-opacity">
Run
<span className="material-symbols-outlined text-base">send</span>
</button>
</div>
</div>
</div>
{/* ─── Footer / Status Bar ───────────────────────────────── */}
<footer className="bg-[#FAF9F5] font-body text-xs tracking-tight fixed bottom-0 left-0 w-full h-8 border-t border-[#87736D]/20 flex items-center justify-between px-4 z-[60]">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 rounded-full bg-primary-fixed flex items-center justify-center">
<span
className="material-symbols-outlined text-[10px] text-on-primary-fixed"
style={{ fontVariationSettings: "'FILL' 1" }}
>
person
</span>
</div>
<span className="text-outline">
{mockStatusBar.user} &bull; {mockStatusBar.username} &bull; {mockStatusBar.plan}
</span>
</div>
</div>
<div className="flex items-center gap-4">
<button className="text-primary font-bold hover:bg-[#F4F4F0] transition-colors px-2 py-0.5 rounded">
{mockStatusBar.branch}
</button>
<button className="text-[#87736D] hover:bg-[#F4F4F0] transition-colors px-2 py-0.5 rounded">
{mockStatusBar.worktreeToggle}
</button>
<button className="text-[#87736D] hover:bg-[#F4F4F0] transition-colors px-2 py-0.5 rounded">
{mockStatusBar.localSwitch}
</button>
</div>
</footer>
</div>
)
}
+297
View File
@@ -0,0 +1,297 @@
import { useState, useEffect } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
import { settingsApi } from '../api/settings'
import type { PermissionMode, EffortLevel, ModelInfo } from '../types/settings'
type SettingsTab = 'model' | 'permissions' | 'general'
export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('model')
const setActiveView = useUIStore((s) => s.setActiveView)
return (
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
{/* Header */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-[var(--color-border)]">
<button
onClick={() => setActiveView('code')}
className="p-1.5 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors text-[var(--color-text-secondary)]"
>
<span className="material-symbols-outlined text-[20px]">arrow_back</span>
</button>
<h1 className="text-lg font-bold text-[var(--color-text-primary)]">Settings</h1>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Tab navigation */}
<div className="w-48 border-r border-[var(--color-border)] py-3 flex-shrink-0">
<TabButton
icon="auto_awesome"
label="Model"
active={activeTab === 'model'}
onClick={() => setActiveTab('model')}
/>
<TabButton
icon="shield"
label="Permissions"
active={activeTab === 'permissions'}
onClick={() => setActiveTab('permissions')}
/>
<TabButton
icon="tune"
label="General"
active={activeTab === 'general'}
onClick={() => setActiveTab('general')}
/>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto px-8 py-6">
{activeTab === 'model' && <ModelSettings />}
{activeTab === 'permissions' && <PermissionSettings />}
{activeTab === 'general' && <GeneralSettings />}
</div>
</div>
</div>
)
}
function TabButton({ icon, label, active, onClick }: { icon: string; label: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-left transition-colors ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="material-symbols-outlined text-[18px]">{icon}</span>
{label}
</button>
)
}
// ─── Model Settings ──────────────────────────────────────────
function ModelSettings() {
const { availableModels, currentModel, effortLevel, setModel, setEffort, fetchAll } = useSettingsStore()
const [customModelId, setCustomModelId] = useState('')
const [showCustom, setShowCustom] = useState(false)
useEffect(() => { fetchAll() }, [fetchAll])
const handleSelectModel = async (model: ModelInfo) => {
await setModel(model.id)
}
const handleCustomModel = async () => {
const id = customModelId.trim()
if (!id) return
await setModel(id)
setShowCustom(false)
setCustomModelId('')
}
const MODEL_ICONS: Record<string, string> = {
'opus': 'diamond',
'sonnet': 'auto_awesome',
'haiku': 'bolt',
} as const
const getModelIcon = (id: string) => {
if (id.includes('opus')) return MODEL_ICONS['opus']
if (id.includes('sonnet')) return MODEL_ICONS['sonnet']
if (id.includes('haiku')) return MODEL_ICONS['haiku']
return 'smart_toy'
}
const EFFORT_LABELS: Record<EffortLevel, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
max: 'Max',
}
return (
<div className="max-w-xl">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">Model</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">Choose the AI model for new conversations.</p>
<div className="flex flex-col gap-2 mb-6">
{availableModels.map((model) => {
const isSelected = currentModel?.id === model.id
return (
<button
key={model.id}
onClick={() => handleSelectModel(model)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${
isSelected
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-secondary)]">
{getModelIcon(model.id)}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
<div className="text-xs text-[var(--color-text-tertiary)]">{model.description} · {model.context} context</div>
</div>
{isSelected && (
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
check_circle
</span>
)}
</button>
)
})}
{/* Custom model */}
{showCustom ? (
<div className="flex items-center gap-2 px-4 py-3 rounded-xl border border-[var(--color-border)]">
<input
type="text"
value={customModelId}
onChange={(e) => setCustomModelId(e.target.value)}
placeholder="Enter model ID (e.g. claude-sonnet-4-6-20250514)"
className="flex-1 text-sm bg-transparent outline-none text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)]"
onKeyDown={(e) => e.key === 'Enter' && handleCustomModel()}
/>
<button onClick={handleCustomModel} className="px-3 py-1 text-xs font-semibold text-white bg-[var(--color-brand)] rounded-lg hover:opacity-90">
Apply
</button>
<button onClick={() => setShowCustom(false)} className="px-2 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]">
Cancel
</button>
</div>
) : (
<button
onClick={() => setShowCustom(true)}
className="flex items-center gap-3 px-4 py-3 rounded-xl border border-dashed border-[var(--color-border)] hover:border-[var(--color-border-focus)] text-left transition-colors"
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-tertiary)]">add</span>
<span className="text-sm text-[var(--color-text-secondary)]">Use custom model ID</span>
</button>
)}
</div>
{/* Effort level */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">Effort Level</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">Controls how much computation the model uses.</p>
<div className="flex gap-2">
{(['low', 'medium', 'high', 'max'] as EffortLevel[]).map((level) => (
<button
key={level}
onClick={() => setEffort(level)}
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
effortLevel === level
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{EFFORT_LABELS[level]}
</button>
))}
</div>
</div>
)
}
// ─── Permission Settings ──────────────────────────────────────
function PermissionSettings() {
const { permissionMode, setPermissionMode } = useSettingsStore()
const MODES: Array<{ mode: PermissionMode; icon: string; label: string; desc: string }> = [
{ mode: 'default', icon: 'verified_user', label: 'Ask permissions', desc: 'Ask before executing tools' },
{ mode: 'acceptEdits', icon: 'edit_note', label: 'Accept edits', desc: 'Auto-approve file edits, ask for others' },
{ mode: 'plan', icon: 'architecture', label: 'Plan mode', desc: 'Think and plan without executing' },
{ mode: 'bypassPermissions', icon: 'bolt', label: 'Bypass all', desc: 'Skip all permission checks (dangerous)' },
]
return (
<div className="max-w-xl">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">Permission Mode</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">Controls how tool execution permissions are handled.</p>
<div className="flex flex-col gap-2">
{MODES.map(({ mode, icon, label, desc }) => {
const isSelected = permissionMode === mode
return (
<button
key={mode}
onClick={() => setPermissionMode(mode)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${
isSelected
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-secondary)]">{icon}</span>
<div className="flex-1">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{label}</div>
<div className="text-xs text-[var(--color-text-tertiary)]">{desc}</div>
</div>
{isSelected && (
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
check_circle
</span>
)}
</button>
)
})}
</div>
</div>
)
}
// ─── General Settings ──────────────────────────────────────────
function GeneralSettings() {
const [apiBase, setApiBase] = useState('')
const [saved, setSaved] = useState(false)
useEffect(() => {
settingsApi.getUser().then((s) => {
setApiBase((s as any).apiBaseUrl || '')
}).catch(() => {})
}, [])
const handleSave = async () => {
const updates: Record<string, unknown> = {}
if (apiBase) updates.apiBaseUrl = apiBase
await settingsApi.updateUser(updates)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
return (
<div className="max-w-xl">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">General</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">Advanced configuration settings.</p>
<div className="flex flex-col gap-4">
<div>
<label className="block text-xs font-semibold text-[var(--color-text-secondary)] mb-1.5">API Base URL</label>
<input
type="text"
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder="https://api.anthropic.com"
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
/>
<p className="text-[10px] text-[var(--color-text-tertiary)] mt-1">Override the Anthropic API endpoint. Leave empty for default.</p>
</div>
<button
onClick={handleSave}
className="self-start px-4 py-2 text-xs font-semibold text-white bg-[var(--color-brand)] rounded-lg hover:opacity-90 transition-opacity"
>
{saved ? 'Saved!' : 'Save Changes'}
</button>
</div>
</div>
)
}
+235
View File
@@ -0,0 +1,235 @@
import { useState } from 'react'
import { mockToolInspection } from '../mocks/data'
export function ToolInspection() {
const [activeDiffTab, setActiveDiffTab] = useState<'split' | 'unified'>('split')
const { toolType, toolName, description, filePath, dryRunStatus, linesChanged, diffLines } =
mockToolInspection
return (
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
{/* Separator */}
<div className="h-px w-full bg-[var(--color-surface-container)]" />
{/* Content */}
<main className="flex-1 overflow-y-auto p-8 max-w-6xl mx-auto w-full">
<div className="flex flex-col gap-6">
{/* ── Title row + action buttons ─────────────────── */}
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-[var(--color-primary-fixed)] text-[#75331C] text-[10px] font-bold rounded uppercase tracking-widest">
{toolType}
</span>
<h1 className="font-[var(--font-headline)] font-extrabold text-2xl text-[var(--color-on-surface)] tracking-tight">
{toolName}
</h1>
</div>
<p className="text-[var(--color-on-surface-variant)] font-medium">{description}</p>
</div>
<div className="flex gap-2">
<button className="px-4 py-2 bg-[var(--color-surface-container-high)] rounded-lg text-sm font-semibold hover:bg-[var(--color-surface-variant)] transition-all">
Revert Change
</button>
<button className="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-on-primary)] rounded-lg text-sm font-semibold shadow-sm hover:opacity-90 transition-all">
Apply to All
</button>
</div>
</div>
{/* ── Metadata cards ─────────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-[var(--color-surface-container-low)] rounded-xl p-4 flex flex-col gap-1">
<span className="text-[10px] font-bold text-[var(--color-outline)] uppercase tracking-wider">
Target File
</span>
<div className="flex items-center gap-2 text-[var(--color-on-surface)]">
<span className="material-symbols-outlined text-[18px]">description</span>
<span className="font-[var(--font-mono)] text-sm">{filePath}</span>
</div>
</div>
<div className="bg-[var(--color-surface-container-low)] rounded-xl p-4 flex flex-col gap-1">
<span className="text-[10px] font-bold text-[var(--color-outline)] uppercase tracking-wider">
Status
</span>
<div className="flex items-center gap-2 text-[var(--color-tertiary)]">
<span
className="material-symbols-outlined text-[18px]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
check_circle
</span>
<span className="font-semibold text-sm">{dryRunStatus}</span>
</div>
</div>
<div className="bg-[var(--color-surface-container-low)] rounded-xl p-4 flex flex-col gap-1">
<span className="text-[10px] font-bold text-[var(--color-outline)] uppercase tracking-wider">
Lines Modified
</span>
<div className="flex items-center gap-2 text-[var(--color-on-surface)]">
<span className="material-symbols-outlined text-[18px]">edit_note</span>
<span className="font-semibold text-sm">
+{linesChanged.added} / -{linesChanged.removed} lines
</span>
</div>
</div>
</div>
{/* ── Diff Viewer ────────────────────────────────── */}
<div className="bg-[var(--color-surface-dim)] rounded-xl overflow-hidden border border-[var(--color-outline-variant)]/20 shadow-sm">
<div className="px-4 py-2.5 bg-[var(--color-surface-container-high)] flex items-center justify-between border-b border-[var(--color-outline-variant)]/20">
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-error)] opacity-30" />
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-primary-fixed-dim)]" />
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-tertiary-container)] opacity-30" />
</div>
<span className="font-[var(--font-mono)] text-xs text-[var(--color-outline)] px-2 border-l border-[var(--color-outline-variant)]/30">
{filePath} — Diff View
</span>
</div>
<div className="flex items-center gap-4">
<span className="text-[11px] text-[var(--color-outline)] font-medium">
L{diffLines[0]?.lineNo ?? 1} — L{diffLines[diffLines.length - 1]?.lineNo ?? 1}
</span>
<div className="flex bg-[var(--color-surface-container-low)] rounded p-0.5">
<button
onClick={() => setActiveDiffTab('split')}
className={`px-2 py-1 text-[10px] font-bold uppercase ${
activeDiffTab === 'split'
? 'bg-[var(--color-surface)] rounded shadow-sm text-[var(--color-on-surface)]'
: 'text-[var(--color-outline)]'
}`}
>
Split
</button>
<button
onClick={() => setActiveDiffTab('unified')}
className={`px-2 py-1 text-[10px] font-bold uppercase ${
activeDiffTab === 'unified'
? 'bg-[var(--color-surface)] rounded shadow-sm text-[var(--color-on-surface)]'
: 'text-[var(--color-outline)]'
}`}
>
Unified
</button>
</div>
</div>
</div>
<div className="font-[var(--font-mono)] text-[13px] leading-relaxed p-4 overflow-x-auto whitespace-pre">
{diffLines.map((line, idx) => {
const isAdded = line.type === 'added'
const isRemoved = line.type === 'removed'
let rowBg = ''
if (isAdded) rowBg = 'bg-[rgba(103,123,78,0.15)]'
else if (isRemoved) rowBg = 'bg-[rgba(143,72,47,0.15)]'
let lineNoColor = 'text-[var(--color-outline)] opacity-40'
if (isAdded) lineNoColor = 'text-[var(--color-tertiary)] opacity-40'
else if (isRemoved) lineNoColor = 'text-[var(--color-error)] opacity-40'
const prefix = isAdded ? '+ ' : isRemoved ? '- ' : ' '
return (
<div key={idx} className={`flex w-full ${rowBg}`}>
<span
className={`w-10 flex-shrink-0 text-right pr-4 select-none ${lineNoColor}`}
>
{line.lineNo}
</span>
<span className="text-[var(--color-on-surface-variant)]">
{prefix}
{line.content}
</span>
</div>
)
})}
</div>
</div>
{/* ── Implementation Context ─────────────────────── */}
<div className="p-6 bg-[var(--color-surface-container-lowest)] rounded-2xl border border-[var(--color-outline-variant)]/10">
<h3 className="font-[var(--font-headline)] font-bold text-sm text-[var(--color-on-surface)] mb-4">
Implementation Context
</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="mt-1 w-6 h-6 rounded bg-[var(--color-primary-fixed)] flex items-center justify-center">
<span className="material-symbols-outlined text-[14px] text-[#75331C]">
psychology
</span>
</div>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-[var(--color-outline)] mb-1">
Reasoning
</p>
<p className="text-sm text-[var(--color-on-surface-variant)] leading-relaxed">
The <code className="font-[var(--font-mono)]">legacyAuthService</code> was
deprecated in RFC-204. The new SDK provides automatic session refresh and
better error typing. This migration ensures the login flow is compliant with
the new security standards.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-1 w-6 h-6 rounded bg-[#D4EAB4] flex items-center justify-center">
<span className="material-symbols-outlined text-[14px] text-[#3B4C24]">
science
</span>
</div>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-[var(--color-outline)] mb-1">
Impact Analysis
</p>
<p className="text-sm text-[var(--color-on-surface-variant)] leading-relaxed">
No changes needed in calling components. The interface remains compatible but
internal state management is improved.
</p>
</div>
</div>
</div>
<div className="flex items-center justify-center">
<div className="w-full h-32 rounded-xl bg-[var(--color-surface-container)] relative overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-primary)]/5 to-[var(--color-secondary)]/5" />
<div className="absolute inset-0 flex items-center justify-center gap-4">
<div className="flex flex-col items-center gap-1">
<div className="p-2 bg-[var(--color-surface)] rounded-lg shadow-sm">
<span className="material-symbols-outlined text-[var(--color-outline)]">
description
</span>
</div>
<span className="text-[9px] font-bold text-[var(--color-outline)]">
auth.ts
</span>
</div>
<span className="material-symbols-outlined text-[var(--color-outline)] animate-pulse">
keyboard_double_arrow_right
</span>
<div className="flex flex-col items-center gap-1">
<div className="p-2 bg-[var(--color-surface)] rounded-lg shadow-sm border border-[var(--color-tertiary)]/20">
<span className="material-symbols-outlined text-[var(--color-tertiary)]">
check_circle
</span>
</div>
<span className="text-[9px] font-bold text-[var(--color-tertiary)]">
Verified
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
)
}
+105
View File
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MessageEntry } from '../types/session'
const { sendMock } = vi.hoisted(() => ({
sendMock: vi.fn(),
}))
vi.mock('../api/websocket', () => ({
wsManager: {
connect: vi.fn(),
disconnect: vi.fn(),
onMessage: vi.fn(() => () => {}),
clearHandlers: vi.fn(),
send: sendMock,
},
}))
vi.mock('../api/sessions', () => ({
sessionsApi: {
getMessages: vi.fn(async () => ({ messages: [] })),
getSlashCommands: vi.fn(async () => ({ commands: [] })),
},
}))
vi.mock('./teamStore', () => ({
useTeamStore: {
getState: () => ({
handleTeamCreated: vi.fn(),
handleTeamUpdate: vi.fn(),
handleTeamDeleted: vi.fn(),
}),
},
}))
import { mapHistoryMessagesToUiMessages, useChatStore } from './chatStore'
const initialState = useChatStore.getState()
describe('chatStore history mapping', () => {
beforeEach(() => {
sendMock.mockReset()
useChatStore.setState({
...initialState,
messages: [],
chatState: 'idle',
connectionState: 'disconnected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
connectedSessionId: null,
slashCommands: [],
})
})
it('drops persisted thinking blocks when restoring transcript history', () => {
const messages: MessageEntry[] = [
{
id: 'assistant-1',
type: 'assistant',
timestamp: '2026-04-06T00:00:00.000Z',
model: 'opus',
content: [
{ type: 'thinking', thinking: 'internal reasoning' },
{ type: 'text', text: '目录结构分析' },
{ type: 'tool_use', name: 'Read', id: 'tool-1', input: { file_path: 'src/App.tsx' } },
],
},
{
id: 'user-1',
type: 'user',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok', is_error: false },
],
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped.map((message) => message.type)).toEqual([
'assistant_text',
'tool_use',
'tool_result',
])
expect(mapped.some((message) => message.type === 'thinking')).toBe(false)
})
it('sends permission mode updates to the active session only', () => {
useChatStore.getState().setSessionPermissionMode('acceptEdits')
expect(sendMock).not.toHaveBeenCalled()
useChatStore.setState({ connectedSessionId: 'session-1' })
useChatStore.getState().setSessionPermissionMode('acceptEdits')
expect(sendMock).toHaveBeenCalledWith({
type: 'set_permission_mode',
mode: 'acceptEdits',
})
})
})
+473
View File
@@ -0,0 +1,473 @@
import { create } from 'zustand'
import { wsManager } from '../api/websocket'
import { sessionsApi } from '../api/sessions'
import { useTeamStore } from './teamStore'
import type { MessageEntry } from '../types/session'
import type { PermissionMode } from '../types/settings'
import type { AttachmentRef, ChatState, UIAttachment, UIMessage, ServerMessage, TokenUsage } from '../types/chat'
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
type ChatStore = {
messages: UIMessage[]
chatState: ChatState
connectionState: ConnectionState
streamingText: string
streamingToolInput: string
activeToolUseId: string | null
activeToolName: string | null
activeThinkingId: string | null
pendingPermission: {
requestId: string
toolName: string
input: unknown
description?: string
} | null
tokenUsage: TokenUsage
elapsedSeconds: number
connectedSessionId: string | null
slashCommands: Array<{ name: string; description: string }>
// Actions
connectToSession: (sessionId: string) => void
disconnectSession: () => void
sendMessage: (content: string, attachments?: AttachmentRef[]) => void
respondToPermission: (requestId: string, allowed: boolean, rule?: string) => void
setSessionPermissionMode: (mode: PermissionMode) => void
stopGeneration: () => void
loadHistory: (sessionId: string) => Promise<void>
clearMessages: () => void
handleServerMessage: (msg: ServerMessage) => void
}
let msgCounter = 0
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
let elapsedTimer: ReturnType<typeof setInterval> | null = null
export const useChatStore = create<ChatStore>((set, get) => ({
messages: [],
chatState: 'idle',
connectionState: 'disconnected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
connectedSessionId: null,
slashCommands: [],
connectToSession: (sessionId: string) => {
const current = get().connectedSessionId
if (current === sessionId) return
// Disconnect previous
if (current) wsManager.disconnect()
set({
connectedSessionId: sessionId,
connectionState: 'connecting',
messages: [],
chatState: 'idle',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
elapsedSeconds: 0,
slashCommands: [],
})
// Clear all previous handlers before registering new ones
// This prevents handler accumulation causing message duplication
wsManager.clearHandlers()
wsManager.connect(sessionId)
wsManager.onMessage((msg) => {
if (msg.type === 'connected') {
set({ connectionState: 'connected' })
}
get().handleServerMessage(msg)
})
// Load history
get().loadHistory(sessionId)
sessionsApi.getSlashCommands(sessionId)
.then(({ commands }) => {
if (get().connectedSessionId === sessionId) {
set({ slashCommands: commands })
}
})
.catch(() => {
if (get().connectedSessionId === sessionId) {
set({ slashCommands: [] })
}
})
},
disconnectSession: () => {
wsManager.disconnect()
if (elapsedTimer) clearInterval(elapsedTimer)
set({ connectedSessionId: null, chatState: 'idle' })
},
sendMessage: (content: string, attachments?: AttachmentRef[]) => {
const userFacingContent = content.trim()
const uiAttachments: UIAttachment[] | undefined =
attachments && attachments.length > 0
? attachments.map((attachment) => ({
type: attachment.type,
name: attachment.name || attachment.path || attachment.mimeType || attachment.type,
data: attachment.data,
mimeType: attachment.mimeType,
}))
: undefined
// Add user message to UI
set((s) => ({
messages: [...s.messages, {
id: nextId(),
type: 'user_text',
content: userFacingContent,
attachments: uiAttachments,
timestamp: Date.now(),
}],
chatState: 'thinking',
elapsedSeconds: 0,
streamingText: '',
}))
// Start elapsed timer
if (elapsedTimer) clearInterval(elapsedTimer)
elapsedTimer = setInterval(() => {
set((s) => ({ elapsedSeconds: s.elapsedSeconds + 1 }))
}, 1000)
wsManager.send({ type: 'user_message', content, attachments })
},
respondToPermission: (requestId: string, allowed: boolean, rule?: string) => {
wsManager.send({ type: 'permission_response', requestId, allowed, ...(rule ? { rule } : {}) })
set({ pendingPermission: null, chatState: allowed ? 'tool_executing' : 'idle' })
},
setSessionPermissionMode: (mode) => {
if (!get().connectedSessionId) return
wsManager.send({ type: 'set_permission_mode', mode })
},
stopGeneration: () => {
wsManager.send({ type: 'stop_generation' })
if (elapsedTimer) clearInterval(elapsedTimer)
set({ chatState: 'idle' })
},
loadHistory: async (sessionId: string) => {
try {
const { messages } = await sessionsApi.getMessages(sessionId)
const uiMessages = mapHistoryMessagesToUiMessages(messages)
set((state) => {
if (state.connectedSessionId !== sessionId || state.messages.length > 0) {
return state
}
return { ...state, messages: uiMessages }
})
} catch {
// Session may not have messages yet
}
},
clearMessages: () => set({ messages: [], streamingText: '', chatState: 'idle' }),
handleServerMessage: (msg: ServerMessage) => {
switch (msg.type) {
case 'connected':
break
case 'status':
set({
chatState: msg.state,
...(msg.state === 'idle' ? { activeThinkingId: null } : {}),
})
if (msg.state === 'idle' && elapsedTimer) {
clearInterval(elapsedTimer)
elapsedTimer = null
}
break
case 'content_start':
if (msg.blockType === 'text') {
set({ streamingText: '', chatState: 'streaming', activeThinkingId: null })
} else if (msg.blockType === 'tool_use') {
set({
activeToolUseId: msg.toolUseId ?? null,
activeToolName: msg.toolName ?? null,
streamingToolInput: '',
chatState: 'tool_executing',
activeThinkingId: null,
})
}
break
case 'content_delta':
if (msg.text !== undefined) {
set((s) => ({ streamingText: s.streamingText + msg.text }))
}
if (msg.toolInput !== undefined) {
set((s) => ({ streamingToolInput: s.streamingToolInput + msg.toolInput }))
}
break
case 'thinking':
// Merge consecutive thinking deltas into one message
set((s) => {
const last = s.messages[s.messages.length - 1]
if (last && last.type === 'thinking') {
// Append to existing thinking message
const updated = [...s.messages]
updated[updated.length - 1] = { ...last, content: last.content + msg.text }
return { messages: updated, chatState: 'thinking', activeThinkingId: last.id }
}
const id = nextId()
// Create new thinking message
return {
messages: [...s.messages, { id, type: 'thinking', content: msg.text, timestamp: Date.now() }],
chatState: 'thinking',
activeThinkingId: id,
}
})
break
case 'tool_use_complete':
set((s) => ({
messages: [...s.messages, {
id: nextId(),
type: 'tool_use',
toolName: msg.toolName || s.activeToolName || 'unknown',
toolUseId: msg.toolUseId || s.activeToolUseId || '',
input: msg.input,
timestamp: Date.now(),
}],
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
streamingToolInput: '',
}))
break
case 'tool_result':
set((s) => ({
messages: [...s.messages, {
id: nextId(),
type: 'tool_result',
toolUseId: msg.toolUseId,
content: msg.content,
isError: msg.isError,
timestamp: Date.now(),
}],
chatState: 'thinking',
activeThinkingId: null,
}))
break
case 'permission_request':
set({
pendingPermission: {
requestId: msg.requestId,
toolName: msg.toolName,
input: msg.input,
description: msg.description,
},
chatState: 'permission_pending',
activeThinkingId: null,
})
set((s) => ({
messages: [...s.messages, {
id: nextId(),
type: 'permission_request',
requestId: msg.requestId,
toolName: msg.toolName,
input: msg.input,
description: msg.description,
timestamp: Date.now(),
}],
}))
break
case 'message_complete': {
// Flush streaming text as a message
const text = get().streamingText
if (text) {
set((s) => ({
messages: [...s.messages, { id: nextId(), type: 'assistant_text', content: text, timestamp: Date.now() }],
streamingText: '',
}))
}
set({ tokenUsage: msg.usage, chatState: 'idle', activeThinkingId: null })
if (elapsedTimer) {
clearInterval(elapsedTimer)
elapsedTimer = null
}
break
}
case 'error':
set((s) => ({
messages: [...s.messages, { id: nextId(), type: 'error', message: msg.message, code: msg.code, timestamp: Date.now() }],
chatState: 'idle',
activeThinkingId: null,
}))
if (elapsedTimer) {
clearInterval(elapsedTimer)
elapsedTimer = null
}
break
case 'team_created':
useTeamStore.getState().handleTeamCreated(msg.teamName)
break
case 'team_update':
useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members)
break
case 'team_deleted':
useTeamStore.getState().handleTeamDeleted(msg.teamName)
break
case 'task_update':
break
case 'system_notification':
// Cache slash commands from CLI init
if (msg.subtype === 'slash_commands' && Array.isArray(msg.data)) {
set({ slashCommands: msg.data as Array<{ name: string; description: string }> })
}
break
case 'pong':
break
}
},
}))
type AssistantHistoryBlock = {
type: string
text?: string
thinking?: string
name?: string
id?: string
input?: unknown
}
type UserHistoryBlock = {
type: string
text?: string
tool_use_id?: string
content?: unknown
is_error?: boolean
source?: { data?: string }
mimeType?: string
media_type?: string
name?: string
}
export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] {
const uiMessages: UIMessage[] = []
for (const msg of messages) {
const timestamp = new Date(msg.timestamp).getTime()
if (msg.type === 'user' && typeof msg.content === 'string') {
uiMessages.push({
id: msg.id || nextId(),
type: 'user_text',
content: msg.content,
timestamp,
})
continue
}
if (msg.type === 'assistant' && typeof msg.content === 'string') {
uiMessages.push({
id: msg.id || nextId(),
type: 'assistant_text',
content: msg.content,
timestamp,
model: msg.model,
})
continue
}
if (msg.type === 'assistant' && Array.isArray(msg.content)) {
for (const block of msg.content as AssistantHistoryBlock[]) {
if (block.type === 'text' && block.text) {
uiMessages.push({
id: nextId(),
type: 'assistant_text',
content: block.text,
timestamp,
model: msg.model,
})
} else if (block.type === 'tool_use') {
uiMessages.push({
id: nextId(),
type: 'tool_use',
toolName: block.name ?? 'unknown',
toolUseId: block.id ?? '',
input: block.input,
timestamp,
})
}
}
continue
}
if (msg.type === 'user' && Array.isArray(msg.content)) {
const textParts: string[] = []
const attachments: UIAttachment[] = []
for (const block of msg.content as UserHistoryBlock[]) {
if (block.type === 'text' && block.text) {
textParts.push(block.text)
} else if (block.type === 'image') {
attachments.push({
type: 'image',
name: block.name || 'image',
data: block.source?.data,
mimeType: block.mimeType || block.media_type,
})
} else if (block.type === 'file') {
attachments.push({
type: 'file',
name: block.name || 'file',
})
} else if (block.type === 'tool_result') {
uiMessages.push({
id: nextId(),
type: 'tool_result',
toolUseId: block.tool_use_id ?? '',
content: block.content,
isError: !!block.is_error,
timestamp,
})
}
}
if (textParts.length > 0 || attachments.length > 0) {
uiMessages.push({
id: nextId(),
type: 'user_text',
content: textParts.join('\n'),
attachments: attachments.length > 0 ? attachments : undefined,
timestamp,
})
}
}
}
return uiMessages
}
+60
View File
@@ -0,0 +1,60 @@
import { create } from 'zustand'
import { sessionsApi } from '../api/sessions'
import type { SessionListItem } from '../types/session'
type SessionStore = {
sessions: SessionListItem[]
activeSessionId: string | null
isLoading: boolean
error: string | null
fetchSessions: (project?: string) => Promise<void>
createSession: (workDir?: string) => Promise<string>
deleteSession: (id: string) => Promise<void>
renameSession: (id: string, title: string) => Promise<void>
setActiveSession: (id: string | null) => void
}
export const useSessionStore = create<SessionStore>((set, get) => ({
sessions: [],
activeSessionId: null,
isLoading: false,
error: null,
fetchSessions: async (project?: string) => {
set({ isLoading: true, error: null })
try {
const { sessions } = await sessionsApi.list({ project, limit: 100 })
set({ sessions, isLoading: false })
} catch (err) {
set({ error: (err as Error).message, isLoading: false })
}
},
createSession: async (workDir?: string) => {
const { sessionId: id } = await sessionsApi.create(workDir ?? '.')
// Refresh session list
await get().fetchSessions()
set({ activeSessionId: id })
return id
},
deleteSession: async (id: string) => {
await sessionsApi.delete(id)
set((s) => ({
sessions: s.sessions.filter((session) => session.id !== id),
activeSessionId: s.activeSessionId === id ? null : s.activeSessionId,
}))
},
renameSession: async (id: string, title: string) => {
await sessionsApi.rename(id, title)
set((s) => ({
sessions: s.sessions.map((session) =>
session.id === id ? { ...session, title } : session,
),
}))
},
setActiveSession: (id) => set({ activeSessionId: id }),
}))
+56
View File
@@ -0,0 +1,56 @@
import { create } from 'zustand'
import { settingsApi } from '../api/settings'
import { modelsApi } from '../api/models'
import type { PermissionMode, EffortLevel, ModelInfo } from '../types/settings'
type SettingsStore = {
permissionMode: PermissionMode
currentModel: ModelInfo | null
effortLevel: EffortLevel
availableModels: ModelInfo[]
isLoading: boolean
fetchAll: () => Promise<void>
setPermissionMode: (mode: PermissionMode) => Promise<void>
setModel: (modelId: string) => Promise<void>
setEffort: (level: EffortLevel) => Promise<void>
}
export const useSettingsStore = create<SettingsStore>((set) => ({
permissionMode: 'default',
currentModel: null,
effortLevel: 'high',
availableModels: [],
isLoading: false,
fetchAll: async () => {
set({ isLoading: true })
try {
const [{ mode }, { models }, { model }, { level }] = await Promise.all([
settingsApi.getPermissionMode(),
modelsApi.list(),
modelsApi.getCurrent(),
modelsApi.getEffort(),
])
set({ permissionMode: mode, availableModels: models, currentModel: model, effortLevel: level, isLoading: false })
} catch {
set({ isLoading: false })
}
},
setPermissionMode: async (mode) => {
set({ permissionMode: mode })
await settingsApi.setPermissionMode(mode)
},
setModel: async (modelId) => {
await modelsApi.setCurrent(modelId)
const { model } = await modelsApi.getCurrent()
set({ currentModel: model })
},
setEffort: async (level) => {
set({ effortLevel: level })
await modelsApi.setEffort(level)
},
}))
+63
View File
@@ -0,0 +1,63 @@
import { create } from 'zustand'
import { tasksApi } from '../api/tasks'
import type { CronTask, CreateTaskInput, TaskRun } from '../types/task'
type TaskStore = {
tasks: CronTask[]
recentRuns: TaskRun[]
isLoading: boolean
error: string | null
fetchTasks: () => Promise<void>
createTask: (input: CreateTaskInput) => Promise<void>
updateTask: (id: string, updates: Partial<CronTask>) => Promise<void>
deleteTask: (id: string) => Promise<void>
fetchRecentRuns: () => Promise<void>
fetchTaskRuns: (taskId: string) => Promise<TaskRun[]>
}
export const useTaskStore = create<TaskStore>((set) => ({
tasks: [],
recentRuns: [],
isLoading: false,
error: null,
fetchTasks: async () => {
set({ isLoading: true, error: null })
try {
const { tasks } = await tasksApi.list()
set({ tasks, isLoading: false })
} catch (err) {
set({ error: (err as Error).message, isLoading: false })
}
},
createTask: async (input) => {
const { task } = await tasksApi.create(input)
set((s) => ({ tasks: [...s.tasks, task] }))
},
updateTask: async (id, updates) => {
const { task } = await tasksApi.update(id, updates)
set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? task : t)) }))
},
deleteTask: async (id) => {
await tasksApi.delete(id)
set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) }))
},
fetchRecentRuns: async () => {
try {
const { runs } = await tasksApi.getRecentRuns()
set({ recentRuns: runs })
} catch {
// ignore
}
},
fetchTaskRuns: async (taskId) => {
const { runs } = await tasksApi.getTaskRuns(taskId)
return runs
},
}))
+106
View File
@@ -0,0 +1,106 @@
import { create } from 'zustand'
import { teamsApi } from '../api/teams'
import type { TeamSummary, TeamDetail, TeamMember, TranscriptMessage, AgentColor } from '../types/team'
import { AGENT_COLORS } from '../types/team'
import type { TeamMemberStatus } from '../types/chat'
type TeamStore = {
teams: TeamSummary[]
activeTeam: TeamDetail | null
viewingAgentId: string | null
agentTranscript: TranscriptMessage[]
memberColors: Map<string, AgentColor>
fetchTeams: () => Promise<void>
fetchTeamDetail: (name: string) => Promise<void>
fetchMemberTranscript: (teamName: string, agentId: string) => Promise<void>
setViewingAgent: (agentId: string | null) => void
clearTeam: () => void
// WebSocket handlers
handleTeamCreated: (teamName: string) => void
handleTeamUpdate: (teamName: string, members: TeamMemberStatus[]) => void
handleTeamDeleted: (teamName: string) => void
}
export const useTeamStore = create<TeamStore>((set, get) => ({
teams: [],
activeTeam: null,
viewingAgentId: null,
agentTranscript: [],
memberColors: new Map(),
fetchTeams: async () => {
try {
const { teams } = await teamsApi.list()
set({ teams })
} catch {
// ignore
}
},
fetchTeamDetail: async (name: string) => {
try {
const detail = await teamsApi.get(name)
// Assign colors to members
const colors = new Map<string, AgentColor>()
detail.members.forEach((m, i) => {
colors.set(m.agentId, AGENT_COLORS[i % AGENT_COLORS.length]!)
})
set({ activeTeam: detail, memberColors: colors })
} catch {
// ignore
}
},
fetchMemberTranscript: async (teamName: string, agentId: string) => {
try {
const { messages } = await teamsApi.getMemberTranscript(teamName, agentId)
set({ agentTranscript: messages, viewingAgentId: agentId })
} catch {
// ignore
}
},
setViewingAgent: (agentId) => {
if (!agentId) {
set({ viewingAgentId: null, agentTranscript: [] })
} else {
set({ viewingAgentId: agentId })
// Fetch transcript if we have an active team
const team = get().activeTeam
if (team) {
get().fetchMemberTranscript(team.name, agentId)
}
}
},
clearTeam: () => set({ activeTeam: null, viewingAgentId: null, agentTranscript: [], memberColors: new Map() }),
handleTeamCreated: (teamName: string) => {
set((s) => ({
teams: [...s.teams, { name: teamName, memberCount: 0 }],
}))
// Auto-fetch detail
get().fetchTeamDetail(teamName)
},
handleTeamUpdate: (teamName: string, members: TeamMemberStatus[]) => {
const team = get().activeTeam
if (team && team.name === teamName) {
const colors = get().memberColors
const updatedMembers: TeamMember[] = members.map((m, i) => ({
...m,
color: colors.get(m.agentId) ?? AGENT_COLORS[i % AGENT_COLORS.length]!,
}))
set({ activeTeam: { ...team, members: updatedMembers } })
}
},
handleTeamDeleted: (teamName: string) => {
set((s) => ({
teams: s.teams.filter((t) => t.name !== teamName),
activeTeam: s.activeTeam?.name === teamName ? null : s.activeTeam,
}))
},
}))
+71
View File
@@ -0,0 +1,71 @@
import { create } from 'zustand'
export type Toast = {
id: string
type: 'success' | 'error' | 'warning' | 'info'
message: string
duration?: number
}
type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings'
type UIStore = {
theme: 'light' | 'dark'
sidebarOpen: boolean
activeView: ActiveView
activeModal: string | null
toasts: Toast[]
setTheme: (theme: 'light' | 'dark') => void
toggleTheme: () => void
toggleSidebar: () => void
setSidebarOpen: (open: boolean) => void
setActiveView: (view: ActiveView) => void
openModal: (id: string) => void
closeModal: () => void
addToast: (toast: Omit<Toast, 'id'>) => void
removeToast: (id: string) => void
}
let toastCounter = 0
export const useUIStore = create<UIStore>((set) => ({
theme: 'light',
sidebarOpen: true,
activeView: 'code',
activeModal: null,
toasts: [],
setTheme: (theme) => {
document.documentElement.setAttribute('data-theme', theme)
set({ theme })
},
toggleTheme: () => {
set((state) => {
const next = state.theme === 'light' ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', next)
return { theme: next }
})
},
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setActiveView: (view) => set({ activeView: view }),
openModal: (id) => set({ activeModal: id }),
closeModal: () => set({ activeModal: null }),
addToast: (toast) => {
const id = `toast-${++toastCounter}`
set((s) => ({ toasts: [...s.toasts, { ...toast, id }] }))
// Auto-remove after duration
const duration = toast.duration ?? 4000
if (duration > 0) {
setTimeout(() => {
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }))
}, duration)
}
},
removeToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}))
+213
View File
@@ -0,0 +1,213 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* ─── highlight.js dark theme (VS Code Dark+ inspired) ────────── */
.hljs {
background: transparent;
color: #24292f;
}
.hljs-keyword { color: #cf222e; }
.hljs-built_in { color: #8250df; }
.hljs-type { color: #953800; }
.hljs-literal { color: #0550ae; }
.hljs-number { color: #0550ae; }
.hljs-string { color: #0a3069; }
.hljs-regexp { color: #0a3069; }
.hljs-symbol { color: #24292f; }
.hljs-variable { color: #953800; }
.hljs-template-variable { color: #953800; }
.hljs-link { color: #0a3069; }
.hljs-selector-id { color: #8250df; }
.hljs-selector-class { color: #8250df; }
.hljs-selector-attr { color: #116329; }
.hljs-selector-pseudo { color: #116329; }
.hljs-title { color: #8250df; }
.hljs-title.function_ { color: #8250df; }
.hljs-title.class_ { color: #8250df; }
.hljs-tag { color: #116329; }
.hljs-name { color: #116329; }
.hljs-attr { color: #0550ae; }
.hljs-attribute { color: #0550ae; }
.hljs-comment { color: #6e7781; font-style: italic; }
.hljs-doctag { color: #6e7781; }
.hljs-meta { color: #953800; }
.hljs-meta .hljs-keyword { color: #953800; }
.hljs-meta .hljs-string { color: #0a3069; }
.hljs-section { color: #8250df; }
.hljs-subst { color: #24292f; }
.hljs-deletion { color: #cf222e; background: rgba(255,235,233,0.9); }
.hljs-addition { color: #116329; background: rgba(218,251,225,0.9); }
.hljs-params { color: #24292f; }
.hljs-property { color: #0550ae; }
.hljs-punctuation { color: #57606a; }
/* ─── Tailwind Theme Override (matches prototype design system) ─── */
@theme {
--color-primary: #8F482F;
--color-primary-container: #AD5F45;
--color-primary-fixed: #FFDBD0;
--color-primary-fixed-dim: #FFB59D;
--color-on-primary: #FFFFFF;
--color-on-surface: #1B1C1A;
--color-on-surface-variant: #54433E;
--color-surface: #FAF9F5;
--color-surface-bright: #FAF9F5;
--color-surface-dim: #DBDAD6;
--color-surface-container: #EFEEEA;
--color-surface-container-low: #F4F4F0;
--color-surface-container-high: #E9E8E4;
--color-surface-container-highest: #E3E2DF;
--color-surface-container-lowest: #FFFFFF;
--color-surface-variant: #E3E2DF;
--color-background: #FAF9F5;
--color-outline: #87736D;
--color-outline-variant: #DAC1BA;
--color-secondary: #2D628F;
--color-secondary-container: #9ACBFE;
--color-tertiary: #4F6237;
--color-tertiary-container: #677B4E;
--color-error: #BA1A1A;
--color-error-container: #FFDAD6;
--color-inverse-surface: #2F312E;
--color-inverse-on-surface: #F2F1ED;
--color-inverse-primary: #FFB59D;
--color-success: #16A34A;
--color-warning: #CA8A04;
--font-headline: 'Manrope', sans-serif;
--font-body: 'Inter', sans-serif;
--font-label: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
/* ─── Semantic Aliases (used by layout & infrastructure components) ── */
:root {
/* Layout dimensions */
--sidebar-width: 280px;
--titlebar-height: 40px;
--statusbar-height: 36px;
/* Surface aliases */
--color-surface-sidebar: var(--color-surface-container-low);
--color-surface-hover: var(--color-surface-container-high);
--color-surface-selected: var(--color-surface-container);
/* Border aliases */
--color-border: var(--color-outline-variant);
--color-border-focus: var(--color-primary);
--color-border-separator: var(--color-outline-variant);
/* Text aliases */
--color-text-primary: var(--color-on-surface);
--color-text-secondary: var(--color-on-surface-variant);
--color-text-tertiary: var(--color-outline);
/* Info surface (for team status bar, notifications) */
--color-surface-info: var(--color-surface-container-low);
/* Brand & accents */
--color-brand: var(--color-primary);
--color-text-accent: var(--color-secondary);
/* Shadows */
--shadow-dropdown: 0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08);
/* Button colors */
--color-btn-primary-bg: var(--color-primary);
--color-btn-primary-fg: var(--color-on-primary);
--color-btn-primary-bg-hover: var(--color-primary-container);
--color-btn-primary-bg-active: var(--color-primary);
/* User message bubble */
--color-surface-user-msg: var(--color-surface-container);
/* Radii */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
}
/* ─── Material Symbols ─────────────────────────────────────────── */
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
vertical-align: middle;
}
/* ─── Base styles ──────────────────────────────────────────────── */
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
font-family: var(--font-body);
color: var(--color-on-surface);
background-color: var(--color-background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Tauri drag region */
[data-tauri-drag-region] {
-webkit-app-region: drag;
}
button, input, textarea, select, a, [role="button"] {
-webkit-app-region: no-drag;
}
/* Custom shadow from prototype */
.custom-shadow {
box-shadow: 0 4px 20px rgba(27, 28, 26, 0.04), 0 12px 40px rgba(27, 28, 26, 0.08);
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-outline); border-radius: 9999px; opacity: 0.3; }
::-webkit-scrollbar-thumb:hover { opacity: 0.6; }
/* Animations */
@keyframes shimmer {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.animate-shimmer { animation: shimmer 1.5s ease-in-out infinite; }
.animate-spin { animation: spin 1s linear infinite; }
.animate-pulse-dot { animation: pulse-dot 1.5s ease-in-out infinite; }
/* Progress bar */
@keyframes progress-fill {
from { width: 0%; }
}
+75
View File
@@ -0,0 +1,75 @@
import type { PermissionMode } from './settings'
// Source: src/server/ws/events.ts
// ─── Client → Server ──────────────────────────────────────────────
export type ClientMessage =
| { type: 'user_message'; content: string; attachments?: AttachmentRef[] }
| { type: 'permission_response'; requestId: string; allowed: boolean; rule?: string }
| { type: 'set_permission_mode'; mode: PermissionMode }
| { type: 'stop_generation' }
| { type: 'ping' }
export type AttachmentRef = {
type: 'file' | 'image'
name?: string
path?: string
data?: string
mimeType?: string
}
export type UIAttachment = {
type: 'file' | 'image'
name: string
data?: string
mimeType?: string
}
// ─── Server → Client ──────────────────────────────────────────────
export type ServerMessage =
| { type: 'connected'; sessionId: string }
| { type: 'content_start'; blockType: 'text' | 'tool_use'; toolName?: string; toolUseId?: string }
| { type: 'content_delta'; text?: string; toolInput?: string }
| { type: 'tool_use_complete'; toolName: string; toolUseId: string; input: unknown }
| { type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean }
| { type: 'permission_request'; requestId: string; toolName: string; input: unknown; description?: string }
| { type: 'message_complete'; usage: TokenUsage }
| { type: 'thinking'; text: string }
| { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number }
| { type: 'error'; message: string; code: string; retryable?: boolean }
| { type: 'system_notification'; subtype: string; message?: string; data?: unknown }
| { type: 'pong' }
| { type: 'team_update'; teamName: string; members: TeamMemberStatus[] }
| { type: 'team_created'; teamName: string }
| { type: 'team_deleted'; teamName: string }
| { type: 'task_update'; taskId: string; status: string; progress?: string }
export type TokenUsage = {
input_tokens: number
output_tokens: number
cache_read_tokens?: number
cache_creation_tokens?: number
}
export type ChatState = 'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending'
export type TeamMemberStatus = {
agentId: string
role: string
status: 'running' | 'idle' | 'completed' | 'error'
currentTask?: string
}
// ─── UI Message model (rendered in MessageList) ───────────────────
export type UIMessage =
| { id: string; type: 'user_text'; content: string; timestamp: number; attachments?: UIAttachment[] }
| { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string }
| { id: string; type: 'thinking'; content: string; timestamp: number }
| { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number }
| { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number }
| { id: string; type: 'system'; content: string; timestamp: number }
| { id: string; type: 'permission_request'; requestId: string; toolName: string; input: unknown; description?: string; timestamp: number }
| { id: string; type: 'error'; message: string; code: string; timestamp: number }
+26
View File
@@ -0,0 +1,26 @@
// Source: src/server/services/sessionService.ts
export type SessionListItem = {
id: string
title: string
createdAt: string
modifiedAt: string
messageCount: number
projectPath: string
workDir: string | null
workDirExists: boolean
}
export type MessageEntry = {
id: string
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
content: unknown
timestamp: string
model?: string
parentUuid?: string
isSidechain?: boolean
}
export type SessionDetail = SessionListItem & {
messages: MessageEntry[]
}
+20
View File
@@ -0,0 +1,20 @@
// Source: src/server/api/models.ts, src/server/api/settings.ts
export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions' | 'dontAsk'
export type EffortLevel = 'low' | 'medium' | 'high' | 'max'
export type ModelInfo = {
id: string
name: string
description: string
context: number
}
export type UserSettings = {
model?: string
modelContext?: string
effort?: EffortLevel
permissionMode?: PermissionMode
[key: string]: unknown
}
+48
View File
@@ -0,0 +1,48 @@
// Source: src/server/services/cronService.ts
export type CronTask = {
id: string
name: string
description?: string
cron: string
prompt: string
enabled: boolean
recurring?: boolean
permanent?: boolean
createdAt: number
lastRunAt?: number
lastFiredAt?: string
nextRunAt?: number
permissionMode?: string
model?: string
folderPath?: string
useWorktree?: boolean
}
export type CreateTaskInput = {
name: string
description?: string
cron: string
prompt: string
enabled?: boolean
recurring?: boolean
permanent?: boolean
permissionMode?: string
model?: string
folderPath?: string
useWorktree?: boolean
}
export type TaskRun = {
id: string
taskId: string
taskName: string
startedAt: string
completedAt?: string
status: 'running' | 'completed' | 'failed' | 'timeout'
prompt: string
output?: string
error?: string
exitCode?: number
durationMs?: number
}
+32
View File
@@ -0,0 +1,32 @@
// Source: src/server/services/teamService.ts, src/server/ws/events.ts
export type TeamSummary = {
name: string
memberCount: number
createdAt?: string
}
export type TeamMember = {
agentId: string
role: string
status: 'running' | 'idle' | 'completed' | 'error'
currentTask?: string
color?: AgentColor
}
export type TeamDetail = {
name: string
members: TeamMember[]
createdAt?: string
}
export type TranscriptMessage = {
id: string
type: string
content: unknown
timestamp: string
}
export type AgentColor = 'red' | 'blue' | 'green' | 'yellow' | 'purple' | 'orange' | 'pink' | 'cyan'
export const AGENT_COLORS: AgentColor[] = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan']
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
const host = process.env.TAURI_DEV_HOST
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
// Vite options tailored for Tauri development
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host ? { protocol: 'ws', host, port: 1421 } : undefined,
watch: {
ignored: ['**/src-tauri/**'],
},
},
})
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
test: {
environment: 'jsdom',
globals: true,
css: true,
setupFiles: [],
},
})
+173 -9
View File
@@ -9,6 +9,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { fileURLToPath } from 'node:url'
import { ConversationService } from '../services/conversationService.js'
// ============================================================================
@@ -27,9 +28,9 @@ describe('ConversationService', () => {
expect(svc.getActiveSessions()).toEqual([])
})
it('should return false when sending message to non-existent session', () => {
it('should return false when sending message to non-existent session', async () => {
const svc = new ConversationService()
const result = svc.sendMessage('no-such-session', 'hello')
const result = await svc.sendMessage('no-such-session', 'hello')
expect(result).toBe(false)
})
@@ -39,6 +40,102 @@ describe('ConversationService', () => {
expect(result).toBe(false)
})
it('should forward suggested permission updates for allow-for-session decisions', () => {
const svc = new ConversationService()
const sent: unknown[] = []
;(svc as any).sessions.set('session-1', {
proc: null,
outputCallbacks: [],
workDir: process.cwd(),
sdkToken: 'token',
sdkSocket: {
send(data: string) {
sent.push(JSON.parse(data))
},
},
pendingOutbound: [],
stderrLines: [],
sdkMessages: [],
pendingPermissionRequests: new Map([
['req-1', {
toolName: 'Bash',
input: { command: 'ls src' },
permissionSuggestions: [
{
type: 'addRules',
rules: [{ toolName: 'Bash', ruleContent: 'ls src' }],
behavior: 'allow',
destination: 'localSettings',
},
],
}],
]),
})
const result = svc.respondToPermission('session-1', 'req-1', true, 'always')
expect(result).toBe(true)
expect(sent).toHaveLength(1)
expect(sent[0]).toMatchObject({
type: 'control_response',
response: {
response: {
behavior: 'allow',
updatedPermissions: [
{
type: 'addRules',
rules: [{ toolName: 'Bash', ruleContent: 'ls src' }],
behavior: 'allow',
destination: 'session',
},
],
},
},
})
})
it('should send set_permission_mode requests to active sessions', () => {
const svc = new ConversationService()
const sent: unknown[] = []
;(svc as any).sessions.set('session-2', {
proc: null,
outputCallbacks: [],
workDir: process.cwd(),
sdkToken: 'token',
sdkSocket: {
send(data: string) {
sent.push(JSON.parse(data))
},
},
pendingOutbound: [],
stderrLines: [],
sdkMessages: [],
pendingPermissionRequests: new Map(),
})
const result = svc.setPermissionMode('session-2', 'acceptEdits')
expect(result).toBe(true)
expect(sent).toHaveLength(1)
expect(sent[0]).toMatchObject({
type: 'control_request',
request: {
subtype: 'set_permission_mode',
mode: 'acceptEdits',
},
})
})
it('should not inject a desktop-specific ask override in default permission mode', () => {
const svc = new ConversationService()
expect((svc as any).getPermissionArgs('default', false)).toEqual([
'--permission-mode',
'default',
])
})
it('should return false when sending interrupt to non-existent session', () => {
const svc = new ConversationService()
const result = svc.sendInterrupt('no-such-session')
@@ -57,7 +154,7 @@ describe('ConversationService', () => {
})
// ============================================================================
// WebSocket integration tests (with real server, CLI falls back to echo)
// WebSocket integration tests (with mock CLI using the SDK websocket protocol)
// ============================================================================
describe('WebSocket Chat Integration', () => {
@@ -66,9 +163,51 @@ describe('WebSocket Chat Integration', () => {
let wsUrl: string
let tmpDir: string
async function runTurn(sessionId: string, content: string): Promise<any[]> {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close()
reject(new Error(`Timed out waiting for completion for session ${sessionId}`))
}, 30000)
ws.onmessage = (e) => {
const msg = JSON.parse(e.data as string)
messages.push(msg)
if (msg.type === 'connected') {
ws.send(JSON.stringify({ type: 'user_message', content }))
}
if (msg.type === 'error') {
clearTimeout(timeout)
ws.close()
reject(new Error(msg.message))
}
if (msg.type === 'message_complete') {
clearTimeout(timeout)
ws.close()
resolve()
}
}
ws.onerror = () => {
clearTimeout(timeout)
ws.close()
reject(new Error(`WebSocket error for session ${sessionId}`))
}
})
return messages
}
const originalCliPath = process.env.CLAUDE_CLI_PATH
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-conv-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.CLAUDE_CLI_PATH = fileURLToPath(
new URL('./fixtures/mock-sdk-cli.ts', import.meta.url)
)
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
const port = 15000 + Math.floor(Math.random() * 1000)
@@ -83,6 +222,11 @@ describe('WebSocket Chat Integration', () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true })
}
if (originalCliPath) {
process.env.CLAUDE_CLI_PATH = originalCliPath
} else {
delete process.env.CLAUDE_CLI_PATH
}
delete process.env.CLAUDE_CONFIG_DIR
})
@@ -141,7 +285,7 @@ describe('WebSocket Chat Integration', () => {
expect(messages.some((m) => m.type === 'status' && m.state === 'idle')).toBe(true)
})
it('should send user_message and receive fallback echo response', async () => {
it('should send user_message and receive streamed SDK response', async () => {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/chat-test-3`)
@@ -154,11 +298,10 @@ describe('WebSocket Chat Integration', () => {
JSON.stringify({ type: 'user_message', content: 'Hello from test' })
)
}
// Wait until we receive idle status after the echo
// Wait until we receive completion after the streamed response
if (
msg.type === 'status' &&
msg.state === 'idle' &&
messages.length > 3
msg.type === 'message_complete' &&
messages.some((entry) => entry.type === 'thinking')
) {
ws.close()
resolve()
@@ -177,9 +320,10 @@ describe('WebSocket Chat Integration', () => {
const types = messages.map((m) => m.type)
expect(types).toContain('connected')
expect(types).toContain('status')
// Fallback echo produces content_start, content_delta, message_complete, status
// Mock SDK flow produces text streaming, thinking, and completion events.
expect(types).toContain('content_start')
expect(types).toContain('content_delta')
expect(types).toContain('thinking')
expect(types).toContain('message_complete')
// Verify thinking was first status
@@ -254,4 +398,24 @@ describe('WebSocket Chat Integration', () => {
expect(messages.some((m) => m.type === 'pong')).toBe(true)
})
it('should start a placeholder REST session and continue it on a later reconnect', async () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: process.cwd() }),
})
expect(createRes.status).toBe(201)
const { sessionId } = await createRes.json() as { sessionId: string }
const firstTurn = await runTurn(sessionId, 'reply with first')
expect(firstTurn.some((m) => m.type === 'message_complete')).toBe(true)
expect(firstTurn.some((m) => m.type === 'error')).toBe(false)
await new Promise((resolve) => setTimeout(resolve, 1000))
const secondTurn = await runTurn(sessionId, 'reply with second')
expect(secondTurn.some((m) => m.type === 'message_complete')).toBe(true)
expect(secondTurn.some((m) => m.type === 'error')).toBe(false)
})
})
@@ -0,0 +1,112 @@
const args = process.argv.slice(2)
function getArg(name: string): string | undefined {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}
function emit(ws: WebSocket, payload: Record<string, unknown>) {
ws.send(JSON.stringify(payload) + '\n')
}
function extractUserText(message: any): string {
const content = message?.message?.content
if (!Array.isArray(content)) return ''
return content
.filter((block: any) => block?.type === 'text' && typeof block.text === 'string')
.map((block: any) => block.text)
.join(' ')
}
const sdkUrl = getArg('--sdk-url')
const sessionId = getArg('--session-id') || crypto.randomUUID()
if (!sdkUrl) {
console.error('Missing --sdk-url')
process.exit(1)
}
const ws = new WebSocket(sdkUrl)
ws.addEventListener('open', () => {
emit(ws, {
type: 'system',
subtype: 'init',
model: 'mock-opus',
slash_commands: [{ name: 'help', description: 'Show help' }],
session_id: sessionId,
})
})
ws.addEventListener('message', (event) => {
const payload = typeof event.data === 'string' ? event.data : String(event.data)
const lines = payload.split('\n').map(line => line.trim()).filter(Boolean)
for (const line of lines) {
const parsed = JSON.parse(line)
if (parsed.type === 'user') {
const text = extractUserText(parsed)
emit(ws, {
type: 'stream_event',
event: { type: 'message_start' },
session_id: sessionId,
})
emit(ws, {
type: 'stream_event',
event: {
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' },
},
session_id: sessionId,
})
emit(ws, {
type: 'stream_event',
event: {
type: 'content_block_delta',
index: 0,
delta: { type: 'thinking_delta', thinking: 'Mock thinking...' },
},
session_id: sessionId,
})
emit(ws, {
type: 'stream_event',
event: {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: `Echo: ${text}` },
},
session_id: sessionId,
})
emit(ws, {
type: 'stream_event',
event: { type: 'content_block_stop', index: 0 },
session_id: sessionId,
})
emit(ws, {
type: 'result',
subtype: 'success',
is_error: false,
result: `Echo: ${text}`,
usage: { input_tokens: 3, output_tokens: 2 },
session_id: sessionId,
})
}
if (parsed.type === 'control_request' && parsed.request?.subtype === 'interrupt') {
emit(ws, {
type: 'result',
subtype: 'success',
is_error: false,
result: 'Interrupted',
usage: { input_tokens: 0, output_tokens: 0 },
session_id: sessionId,
})
}
}
})
ws.addEventListener('close', () => {
process.exit(0)
})
+78 -5
View File
@@ -101,6 +101,15 @@ function makeMetaUserEntry(): Record<string, unknown> {
}
}
function makeSessionMetaEntry(workDir: string): Record<string, unknown> {
return {
type: 'session-meta',
isMeta: true,
workDir,
timestamp: '2026-01-01T00:00:00.000Z',
}
}
// ============================================================================
// SessionService tests
// ============================================================================
@@ -237,24 +246,53 @@ describe('SessionService', () => {
expect(messages).toHaveLength(2)
})
it('should recover workDir from session-meta entries', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [
makeSnapshotEntry(),
makeSessionMetaEntry('/tmp/from-meta'),
makeUserEntry('Hello'),
])
const workDir = await service.getSessionWorkDir(sessionId)
expect(workDir).toBe('/tmp/from-meta')
})
it('should recover workDir from transcript cwd when session-meta is missing', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [
makeSnapshotEntry(),
{
...makeUserEntry('Hello'),
cwd: '/tmp/from-cwd',
},
])
const workDir = await service.getSessionWorkDir(sessionId)
expect(workDir).toBe('/tmp/from-cwd')
})
// --------------------------------------------------------------------------
// createSession
// --------------------------------------------------------------------------
it('should create a new session file', async () => {
const { sessionId } = await service.createSession('/tmp/my-project')
const workDir = path.join(tmpDir, 'workspace', 'my-project')
await fs.mkdir(workDir, { recursive: true })
const { sessionId } = await service.createSession(workDir)
expect(sessionId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
)
// Verify the file was created
const filePath = path.join(tmpDir, 'projects', '-tmp-my-project', `${sessionId}.jsonl`)
const sanitized = workDir.replace(/\//g, '-')
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
const stat = await fs.stat(filePath)
expect(stat.isFile()).toBe(true)
// Verify the file contains the initial snapshot entry
// Verify the file starts with the initial snapshot entry
const content = await fs.readFile(filePath, 'utf-8')
const entry = JSON.parse(content.trim())
const entry = JSON.parse(content.trim().split('\n')[0]!)
expect(entry.type).toBe('file-history-snapshot')
})
@@ -262,6 +300,12 @@ describe('SessionService', () => {
expect(service.createSession('')).rejects.toThrow('workDir is required')
})
it('should throw when workDir does not exist', async () => {
expect(service.createSession('/tmp/definitely-missing-claude-code-haha')).rejects.toThrow(
'Working directory does not exist'
)
})
// --------------------------------------------------------------------------
// deleteSession
// --------------------------------------------------------------------------
@@ -349,6 +393,34 @@ describe('SessionService', () => {
const detail = await service.getSession(sessionId)
expect(detail!.title).toBe('Untitled Session')
})
it('should detect placeholder launch info for desktop-created sessions', async () => {
const { sessionId } = await service.createSession(os.tmpdir())
const launchInfo = await service.getSessionLaunchInfo(sessionId)
expect(launchInfo).not.toBeNull()
expect(launchInfo!.workDir).toBe(os.tmpdir())
expect(launchInfo!.transcriptMessageCount).toBe(0)
expect(launchInfo!.customTitle).toBeNull()
})
it('should detect resumable launch info for transcript sessions', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const userUuid = crypto.randomUUID()
await writeSessionFile('-tmp-project', sessionId, [
makeSnapshotEntry(),
{ type: 'session-meta', isMeta: true, workDir: '/tmp/project', timestamp: '2026-01-01T00:00:00.000Z' },
makeUserEntry('Hello again', userUuid),
makeAssistantEntry('Welcome back', userUuid),
{ type: 'custom-title', customTitle: 'Saved chat', timestamp: '2026-01-01T00:03:00.000Z' },
])
const launchInfo = await service.getSessionLaunchInfo(sessionId)
expect(launchInfo).not.toBeNull()
expect(launchInfo!.workDir).toBe('/tmp/project')
expect(launchInfo!.transcriptMessageCount).toBe(2)
expect(launchInfo!.customTitle).toBe('Saved chat')
})
})
// ============================================================================
@@ -409,10 +481,11 @@ describe('Sessions API', () => {
})
it('POST /api/sessions should create a session', async () => {
const workDir = await fs.mkdtemp(path.join(tmpDir, 'api-session-'))
const res = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir: '/tmp/test-project' }),
body: JSON.stringify({ workDir }),
})
expect(res.status).toBe(201)

Some files were not shown because too many files have changed in this diff Show More