mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -0,0 +1 @@
|
|||||||
|
# Override parent bunfig to avoid preload interference
|
||||||
@@ -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>
|
||||||
Generated
+4295
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AppShell } from './components/layout/AppShell'
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <AppShell />
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 || ' '}</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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} • {mockStatusBar.username} • {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} • {mockStatusBar.username} • {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} • {mockStatusBar.username} • {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} • {mockStatusBar.username} • {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -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) })),
|
||||||
|
}))
|
||||||
@@ -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%; }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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']
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -9,6 +9,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
|||||||
import * as fs from 'fs/promises'
|
import * as fs from 'fs/promises'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as os from 'os'
|
import * as os from 'os'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import { ConversationService } from '../services/conversationService.js'
|
import { ConversationService } from '../services/conversationService.js'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -27,9 +28,9 @@ describe('ConversationService', () => {
|
|||||||
expect(svc.getActiveSessions()).toEqual([])
|
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 svc = new ConversationService()
|
||||||
const result = svc.sendMessage('no-such-session', 'hello')
|
const result = await svc.sendMessage('no-such-session', 'hello')
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,6 +40,102 @@ describe('ConversationService', () => {
|
|||||||
expect(result).toBe(false)
|
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', () => {
|
it('should return false when sending interrupt to non-existent session', () => {
|
||||||
const svc = new ConversationService()
|
const svc = new ConversationService()
|
||||||
const result = svc.sendInterrupt('no-such-session')
|
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', () => {
|
describe('WebSocket Chat Integration', () => {
|
||||||
@@ -66,9 +163,51 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
let wsUrl: string
|
let wsUrl: string
|
||||||
let tmpDir: 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 () => {
|
beforeAll(async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-conv-'))
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-conv-'))
|
||||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
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 })
|
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
|
||||||
|
|
||||||
const port = 15000 + Math.floor(Math.random() * 1000)
|
const port = 15000 + Math.floor(Math.random() * 1000)
|
||||||
@@ -83,6 +222,11 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
if (tmpDir) {
|
if (tmpDir) {
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
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
|
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)
|
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 messages: any[] = []
|
||||||
const ws = new WebSocket(`${wsUrl}/ws/chat-test-3`)
|
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' })
|
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 (
|
if (
|
||||||
msg.type === 'status' &&
|
msg.type === 'message_complete' &&
|
||||||
msg.state === 'idle' &&
|
messages.some((entry) => entry.type === 'thinking')
|
||||||
messages.length > 3
|
|
||||||
) {
|
) {
|
||||||
ws.close()
|
ws.close()
|
||||||
resolve()
|
resolve()
|
||||||
@@ -177,9 +320,10 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
const types = messages.map((m) => m.type)
|
const types = messages.map((m) => m.type)
|
||||||
expect(types).toContain('connected')
|
expect(types).toContain('connected')
|
||||||
expect(types).toContain('status')
|
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_start')
|
||||||
expect(types).toContain('content_delta')
|
expect(types).toContain('content_delta')
|
||||||
|
expect(types).toContain('thinking')
|
||||||
expect(types).toContain('message_complete')
|
expect(types).toContain('message_complete')
|
||||||
|
|
||||||
// Verify thinking was first status
|
// Verify thinking was first status
|
||||||
@@ -254,4 +398,24 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
|
|
||||||
expect(messages.some((m) => m.type === 'pong')).toBe(true)
|
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)
|
||||||
|
})
|
||||||
@@ -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
|
// SessionService tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -237,24 +246,53 @@ describe('SessionService', () => {
|
|||||||
expect(messages).toHaveLength(2)
|
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
|
// createSession
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
it('should create a new session file', async () => {
|
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(
|
expect(sessionId).toMatch(
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
/^[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
|
// 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)
|
const stat = await fs.stat(filePath)
|
||||||
expect(stat.isFile()).toBe(true)
|
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 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')
|
expect(entry.type).toBe('file-history-snapshot')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -262,6 +300,12 @@ describe('SessionService', () => {
|
|||||||
expect(service.createSession('')).rejects.toThrow('workDir is required')
|
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
|
// deleteSession
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@@ -349,6 +393,34 @@ describe('SessionService', () => {
|
|||||||
const detail = await service.getSession(sessionId)
|
const detail = await service.getSession(sessionId)
|
||||||
expect(detail!.title).toBe('Untitled Session')
|
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 () => {
|
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`, {
|
const res = await fetch(`${baseUrl}/api/sessions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ workDir: '/tmp/test-project' }),
|
body: JSON.stringify({ workDir }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(201)
|
expect(res.status).toBe(201)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user