diff --git a/console/src/api/chat-types.ts b/console/src/api/chat-types.ts new file mode 100644 index 00000000..89f755bc --- /dev/null +++ b/console/src/api/chat-types.ts @@ -0,0 +1,132 @@ +export interface ChatRecentSession { + sessionId: string; + title: string; + lastActive: string; + messageCount: number; +} + +export interface ChatRecentResponse { + sessions: ChatRecentSession[]; +} + +export interface ChatHistoryMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + id?: number | string | null; +} + +export interface AssistantPresentation { + agentId?: string | null; + displayName?: string | null; + imageUrl?: string | null; +} + +export interface BranchFamily { + anchorSessionId: string; + anchorMessageId: number | string; + variants: string[]; +} + +export interface BootstrapAutostart { + status: string; + fileName: string; +} + +export interface ChatHistoryResponse { + sessionId?: string; + history: ChatHistoryMessage[]; + assistantPresentation?: AssistantPresentation | null; + branchFamilies?: BranchFamily[]; + bootstrapAutostart?: BootstrapAutostart | null; +} + +export interface ChatCommandSuggestion { + id: string; + label: string; + insertText: string; + description: string; + depth?: number; +} + +export interface ChatCommandsResponse { + commands: ChatCommandSuggestion[]; +} + +export interface ChatArtifact { + filename?: string; + path?: string; + mimeType?: string; + type?: string; +} + +export interface ChatStreamTextDelta { + type: 'text'; + delta: string; +} + +export interface ChatStreamApproval { + type: 'approval'; + approvalId: string; + prompt: string; + summary?: string; + intent?: string; + reason?: string; + toolName?: string; + args?: unknown; + allowSession?: boolean; + allowAgent?: boolean; + allowAll?: boolean; + expiresAt?: number | null; +} + +export type ChatStreamEvent = ChatStreamTextDelta | ChatStreamApproval; + +export interface ChatStreamResult { + status?: string; + error?: string; + sessionId?: string; + userMessageId?: number | string | null; + assistantMessageId?: number | string | null; + result?: string; + artifacts?: ChatArtifact[]; + toolsUsed?: string[]; +} + +export interface MediaItem { + filename: string; + path: string; + mimeType: string; +} + +export interface MediaUploadResponse { + media: MediaItem; +} + +export interface AppStatusResponse { + defaultAgentId?: string; + version?: string; +} + +export interface BranchResponse { + sessionId: string; +} + +export interface CommandResponse { + status?: string; + error?: string; +} + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant' | 'system' | 'thinking' | 'approval'; + content: string; + rawContent?: string; + sessionId: string; + messageId?: number | string | null; + media?: MediaItem[]; + artifacts?: ChatArtifact[]; + replayRequest?: { content: string; media: MediaItem[] } | null; + pendingApproval?: ChatStreamApproval | null; + assistantPresentation?: AssistantPresentation | null; + branchKey?: string | null; +} diff --git a/console/src/api/chat.ts b/console/src/api/chat.ts new file mode 100644 index 00000000..5f73e526 --- /dev/null +++ b/console/src/api/chat.ts @@ -0,0 +1,108 @@ +import type { + AppStatusResponse, + BranchResponse, + ChatCommandsResponse, + ChatHistoryResponse, + ChatRecentResponse, + CommandResponse, + MediaUploadResponse, +} from './chat-types'; +import { requestJson } from './client'; + +export function fetchAppStatus(token: string): Promise { + return requestJson('/api/status', { token }); +} + +export function fetchChatRecent( + token: string, + userId: string, + channelId = 'web', + limit = 10, +): Promise { + const params = new URLSearchParams({ + userId, + channelId, + limit: String(limit), + }); + return requestJson( + `/api/chat/recent?${params.toString()}`, + { token }, + ); +} + +export function fetchChatHistory( + token: string, + sessionId: string, + limit = 80, +): Promise { + const params = new URLSearchParams({ + sessionId, + limit: String(limit), + }); + return requestJson(`/api/history?${params.toString()}`, { + token, + }); +} + +export function fetchChatCommands( + token: string, + query?: string, +): Promise { + const url = query + ? `/api/chat/commands?q=${encodeURIComponent(query)}` + : '/api/chat/commands'; + return requestJson(url, { token }); +} + +export function createChatBranch( + token: string, + sessionId: string, + beforeMessageId: number | string, +): Promise { + return requestJson('/api/chat/branch', { + token, + method: 'POST', + body: { sessionId, beforeMessageId }, + }); +} + +export function executeCommand( + token: string, + sessionId: string, + userId: string, + args: string[], +): Promise { + return requestJson('/api/command', { + token, + method: 'POST', + body: { + sessionId, + guildId: null, + channelId: 'web', + userId, + username: 'web', + args, + }, + }); +} + +export function uploadMedia( + token: string, + file: File, +): Promise { + return requestJson('/api/media/upload', { + token, + method: 'POST', + rawBody: file, + extraHeaders: { + 'Content-Type': file.type || 'application/octet-stream', + 'X-Hybridclaw-Filename': encodeURIComponent(file.name || 'upload'), + }, + }); +} + +export function artifactUrl(path: string, token?: string): string { + const params = new URLSearchParams({ path }); + if (token) params.set('token', token); + return `/api/artifact?${params.toString()}`; +} diff --git a/console/src/api/client.ts b/console/src/api/client.ts index d5380a60..42b7508e 100644 --- a/console/src/api/client.ts +++ b/console/src/api/client.ts @@ -36,7 +36,7 @@ import type { export const TOKEN_STORAGE_KEY = 'hybridclaw_token'; export const AUTH_REQUIRED_EVENT = 'hybridclaw:auth-required'; -function requestHeaders(token: string, body?: unknown): HeadersInit { +export function requestHeaders(token: string, body?: unknown): HeadersInit { const trimmed = token.trim(); return { ...(trimmed ? { Authorization: `Bearer ${trimmed}` } : {}), @@ -48,7 +48,7 @@ function requestHeaders(token: string, body?: unknown): HeadersInit { }; } -function dispatchAuthRequired(message: string): void { +export function dispatchAuthRequired(message: string): void { clearStoredToken(); window.dispatchEvent( new CustomEvent(AUTH_REQUIRED_EVENT, { @@ -57,19 +57,27 @@ function dispatchAuthRequired(message: string): void { ); } -async function requestJson( +export async function requestJson( pathname: string, options: { token: string; method?: 'GET' | 'PUT' | 'DELETE' | 'POST'; body?: unknown; + rawBody?: BodyInit; + extraHeaders?: HeadersInit; onAuthError?: 'dispatch' | 'ignore'; }, ): Promise { const response = await fetch(pathname, { method: options.method || 'GET', - headers: requestHeaders(options.token, options.body), - body: options.body === undefined ? undefined : JSON.stringify(options.body), + headers: { + ...requestHeaders(options.token, options.body), + ...options.extraHeaders, + }, + body: + options.body !== undefined + ? JSON.stringify(options.body) + : (options.rawBody ?? undefined), }); const payload = (await response.json().catch(() => ({}))) as { diff --git a/console/src/components/sidebar/navigation.ts b/console/src/components/sidebar/navigation.ts index 712568b2..836bd3c6 100644 --- a/console/src/components/sidebar/navigation.ts +++ b/console/src/components/sidebar/navigation.ts @@ -2,6 +2,7 @@ import type { ComponentType } from 'react'; import { Audit, Channels, + Chat, Cog, Config, Dashboard, @@ -34,6 +35,7 @@ export const SIDEBAR_NAV_GROUPS: ReadonlyArray = [ label: 'Overview', items: [ { to: '/', label: 'Dashboard', icon: Dashboard }, + { to: '/chat', label: 'Chat', icon: Chat }, { to: '/audit', label: 'Audit', icon: Audit }, { to: '/jobs', label: 'Jobs', icon: Jobs }, ], diff --git a/console/src/lib/chat-helpers.ts b/console/src/lib/chat-helpers.ts new file mode 100644 index 00000000..e316192f --- /dev/null +++ b/console/src/lib/chat-helpers.ts @@ -0,0 +1,146 @@ +import type { ChatStreamApproval } from '../api/chat-types'; + +export const DEFAULT_AGENT_ID = 'main'; + +export type ApprovalAction = + | 'once' + | 'always' + | 'session' + | 'agent' + | 'all' + | 'deny'; + +export function randomHex(bytes: number): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr, (v) => v.toString(16).padStart(2, '0')).join(''); +} + +export function generateWebSessionId(agentId = DEFAULT_AGENT_ID): string { + const normalized = agentId.trim().toLowerCase(); + return `agent:${encodeURIComponent(normalized)}:channel:web:chat:dm:peer:${randomHex(8)}`; +} + +export function readStoredUserId(): string { + const key = 'hybridclaw_user_id'; + const stored = localStorage.getItem(key); + if (stored) return stored; + const id = `web-user-${randomHex(4)}`; + localStorage.setItem(key, id); + return id; +} + +export function readStoredSessionId(): string { + return localStorage.getItem('hybridclaw_session') ?? ''; +} + +export function storeSessionId(id: string): void { + localStorage.setItem('hybridclaw_session', id); +} + +let msgCounter = 0; +export function nextMsgId(): string { + msgCounter += 1; + return `local-${msgCounter}-${Date.now()}`; +} + +export function copyToClipboard(text: string): void { + void navigator.clipboard?.writeText(text).catch(() => { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + }); +} + +export function buildApprovalSummary( + approval: ChatStreamApproval | null, +): string { + if (!approval) return ''; + if (approval.summary) return approval.summary; + const lines: string[] = []; + if (approval.intent) lines.push(`Approval needed for: ${approval.intent}`); + if (approval.reason) lines.push(`Why: ${approval.reason}`); + lines.push(`Approval ID: ${approval.approvalId}`); + return lines.join('\n'); +} + +const APPROVAL_COMMAND_MAP: Record = { + once: '/approve once', + always: '/approve always', + session: '/approve session', + agent: '/approve agent', + all: '/approve all', + deny: '/approve no', +}; + +export function buildApprovalCommand( + action: ApprovalAction, + approvalId: string, +): string | null { + const base = APPROVAL_COMMAND_MAP[action]; + if (!base) return null; + const id = approvalId.trim(); + return id ? `${base} ${id}` : base; +} + +function buildClipboardUploadFilename(file: File): string { + const existingName = (file.name ?? '').trim(); + if (existingName) return existingName; + const extensionMap: Record = { + 'application/pdf': '.pdf', + 'image/gif': '.gif', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'text/plain': '.txt', + }; + const ext = extensionMap[(file.type ?? '').trim().toLowerCase()] ?? ''; + return `clipboard${ext}`; +} + +function normalizeUploadFile(file: File): File { + const filename = buildClipboardUploadFilename(file); + if (file.name === filename) return file; + return new File([file], filename, { + type: file.type || 'application/octet-stream', + lastModified: Number.isFinite(file.lastModified) + ? file.lastModified + : Date.now(), + }); +} + +export function extractClipboardFiles( + clipboardData: DataTransfer | null, +): File[] { + if (!clipboardData) return []; + const files: File[] = []; + for (const file of Array.from(clipboardData.files)) { + files.push(normalizeUploadFile(file)); + } + if (files.length > 0) return files; + const seen = new Set(); + for (const item of Array.from(clipboardData.items)) { + if (item.kind !== 'file') continue; + const file = item.getAsFile(); + if (!file) continue; + const normalized = normalizeUploadFile(file); + const key = `${normalized.name}:${normalized.size}:${normalized.type}:${normalized.lastModified}`; + if (seen.has(key)) continue; + seen.add(key); + files.push(normalized); + } + return files; +} + +export function isScrolledNearBottom( + el: HTMLElement | null, + threshold = 64, +): boolean { + if (!el) return true; + return el.scrollHeight - el.scrollTop - el.clientHeight <= threshold; +} diff --git a/console/src/lib/chat-stream.ts b/console/src/lib/chat-stream.ts new file mode 100644 index 00000000..79cd18d1 --- /dev/null +++ b/console/src/lib/chat-stream.ts @@ -0,0 +1,145 @@ +import type { ChatStreamApproval, ChatStreamResult } from '../api/chat-types'; +import { dispatchAuthRequired, requestHeaders } from '../api/client'; + +export interface ChatStreamCallbacks { + onTextDelta: (delta: string) => void; + onApproval: (event: ChatStreamApproval) => void; +} + +export async function requestChatStream( + url: string, + options: { + token: string; + body: unknown; + signal?: AbortSignal; + callbacks: ChatStreamCallbacks; + }, +): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + ...requestHeaders(options.token, options.body), + Accept: 'application/x-ndjson', + }, + body: JSON.stringify(options.body), + signal: options.signal, + }); + + if (!response.ok) { + const errorText = (await response.text().catch(() => '')).trim(); + let errorMessage = `${response.status} ${response.statusText}`; + if (errorText) { + try { + const payload = JSON.parse(errorText) as { + error?: string; + text?: string; + }; + errorMessage = payload.error || payload.text || errorText; + } catch { + errorMessage = errorText; + } + } + if (response.status === 401) { + dispatchAuthRequired(errorMessage); + } + throw new Error(errorMessage); + } + + const { callbacks } = options; + + const parseLine = (line: string): ChatStreamResult | null => { + const trimmedLine = String(line || '').trim(); + if (!trimmedLine) return null; + + let payload: Record; + try { + payload = JSON.parse(trimmedLine) as Record; + } catch { + return null; + } + if (!payload || typeof payload !== 'object') return null; + + if (payload.type === 'text' && typeof payload.delta === 'string') { + callbacks.onTextDelta(payload.delta as string); + return null; + } + + if ( + payload.type === 'approval' && + typeof payload.approvalId === 'string' && + typeof payload.prompt === 'string' + ) { + callbacks.onApproval(payload as unknown as ChatStreamApproval); + return null; + } + + if ( + payload.type === 'result' && + payload.result && + typeof payload.result === 'object' + ) { + return payload.result as ChatStreamResult; + } + + if ( + typeof payload.status === 'string' && + Array.isArray(payload.toolsUsed) + ) { + return payload as unknown as ChatStreamResult; + } + + return null; + }; + + if (!response.body) { + const text = await response.text().catch(() => ''); + let finalResult: ChatStreamResult | null = null; + for (const line of text.split('\n')) { + const result = parseLine(line); + if (result) finalResult = result; + } + if (!finalResult) { + throw new Error('Chat stream ended without a result payload.'); + } + return finalResult; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let finalResult: ChatStreamResult | null = null; + + const consumeBufferedLines = (includeRemainder = false): void => { + const lines = buffer.split('\n'); + const remainder = includeRemainder ? (lines.pop() ?? '') : null; + buffer = includeRemainder ? '' : (lines.pop() ?? ''); + + for (const line of lines) { + const result = parseLine(line); + if (result) finalResult = result; + } + + if (includeRemainder && remainder?.trim()) { + const result = parseLine(remainder); + if (result) finalResult = result; + } + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + consumeBufferedLines(); + } + buffer += decoder.decode(); + consumeBufferedLines(true); + } finally { + reader.releaseLock(); + } + + if (!finalResult) { + throw new Error('Chat stream ended without a result payload.'); + } + return finalResult; +} diff --git a/console/src/lib/markdown.ts b/console/src/lib/markdown.ts new file mode 100644 index 00000000..67d636f3 --- /dev/null +++ b/console/src/lib/markdown.ts @@ -0,0 +1,275 @@ +// Ported from the legacy docs/chat.html inline renderer. + +const HTML_ESCAPE_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +function escapeHtml(raw: string): string { + return String(raw).replace(/[&<>"']/g, (ch) => HTML_ESCAPE_MAP[ch]); +} + +function sanitizeUrl(rawUrl: string): string { + const url = String(rawUrl || '').trim(); + if (!url) return ''; + const lowered = url.toLowerCase(); + if ( + lowered.startsWith('http://') || + lowered.startsWith('https://') || + lowered.startsWith('mailto:') + ) { + return url; + } + return ''; +} + +function renderInlineMarkdown(raw: string): string { + let text = String(raw || ''); + const inlineCode: string[] = []; + const links: string[] = []; + + text = text.replace(/`([^`\n]+)`/g, (_match, code: string) => { + const idx = inlineCode.push(`${escapeHtml(code)}`) - 1; + return `@@IC${idx}@@`; + }); + + text = text.replace( + /\[([^\]]+)\]\(([^)\s]+)\)/g, + (_match, label: string, href: string) => { + const safeHref = sanitizeUrl(href); + const safeLabel = escapeHtml(label); + const html = safeHref + ? `${safeLabel}` + : safeLabel; + const idx = links.push(html) - 1; + return `@@LK${idx}@@`; + }, + ); + + text = escapeHtml(text); + text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); + text = text.replace(/\*([^*]+)\*/g, '$1'); + text = text.replace(/__([^_]+)__/g, '$1'); + text = text.replace(/_([^_]+)_/g, '$1'); + + text = text.replace( + /@@LK(\d+)@@/g, + (_match, index: string) => links[Number(index)] || '', + ); + text = text.replace( + /@@IC(\d+)@@/g, + (_match, index: string) => inlineCode[Number(index)] || '', + ); + return text; +} + +function splitMarkdownTableRow(line: string): string[] { + let value = String(line || '').trim(); + if (value.startsWith('|')) value = value.slice(1); + if (value.endsWith('|')) value = value.slice(0, -1); + return value.split('|').map((cell) => cell.trim()); +} + +function isMarkdownTableSeparator(line: string): boolean { + const cells = splitMarkdownTableRow(line); + if (!cells.length) return false; + return cells.every((cell) => /^:?-{3,}:?$/.test(cell)); +} + +function isMarkdownTableBodyRow(line: string): boolean { + const trimmed = String(line || '').trim(); + return ( + trimmed.includes('|') && + trimmed !== '|' && + !isMarkdownTableSeparator(trimmed) + ); +} + +function cellAlignment(separatorCell: string): 'left' | 'center' | 'right' { + const trimmed = String(separatorCell || '').trim(); + if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center'; + if (trimmed.endsWith(':')) return 'right'; + return 'left'; +} + +function renderMarkdownTable( + headerLine: string, + separatorLine: string, + bodyLines: string[], +): string { + const headers = splitMarkdownTableRow(headerLine); + const separators = splitMarkdownTableRow(separatorLine); + const alignments = headers.map((_, index) => + cellAlignment(separators[index] || '---'), + ); + const bodyRows = bodyLines.map((line) => splitMarkdownTableRow(line)); + + const thead = `${headers + .map( + (cell, index) => + `${renderInlineMarkdown(cell)}`, + ) + .join('')}`; + const tbody = `${bodyRows + .map( + (row) => + `${headers + .map( + (_, index) => + `${renderInlineMarkdown(row[index] || '')}`, + ) + .join('')}`, + ) + .join('')}`; + return `
${thead}${tbody}
`; +} + +export function renderMarkdown(raw: string): string { + let text = String(raw || '').replace(/\r\n/g, '\n'); + const codeBlocks: string[] = []; + + text = text.replace( + /```([^\n`]*)\n([\s\S]*?)```/g, + (_match, _lang: string, code: string) => { + const idx = + codeBlocks.push( + `
${escapeHtml(code.replace(/\n$/, ''))}
`, + ) - 1; + return `@@CB${idx}@@`; + }, + ); + + const lines = text.split('\n'); + const out: string[] = []; + let paragraphLines: string[] = []; + let openList = ''; + + const closeList = (): void => { + if (openList) { + out.push(openList === 'ul' ? '' : ''); + openList = ''; + } + }; + + const flushParagraph = (): void => { + if (paragraphLines.length === 0) return; + const html = paragraphLines + .map((line) => renderInlineMarkdown(line)) + .join('
'); + out.push(`

${html}

`); + paragraphLines = []; + }; + + let index = 0; + while (index < lines.length) { + const line = lines[index]; + const codeMatch = line.match(/^@@CB(\d+)@@$/); + if (codeMatch) { + flushParagraph(); + closeList(); + out.push(codeBlocks[Number(codeMatch[1])] || ''); + index += 1; + continue; + } + + if (!line.trim()) { + flushParagraph(); + if (openList) { + let ahead = index + 1; + while (ahead < lines.length && !(lines[ahead] || '').trim()) ahead++; + const nextNonEmpty = ahead < lines.length ? lines[ahead] : ''; + const continuesList = + (openList === 'ul' && /^\s*[-*+]\s+/.test(nextNonEmpty)) || + (openList === 'ol' && /^\s*\d+\.\s+/.test(nextNonEmpty)); + if (!continuesList) closeList(); + } + index += 1; + continue; + } + + const nextLine = lines[index + 1] || ''; + if (line.includes('|') && isMarkdownTableSeparator(nextLine)) { + flushParagraph(); + closeList(); + const bodyLines: string[] = []; + let tableIndex = index + 2; + while ( + tableIndex < lines.length && + isMarkdownTableBodyRow(lines[tableIndex]) + ) { + bodyLines.push(lines[tableIndex]); + tableIndex += 1; + } + out.push(renderMarkdownTable(line, nextLine, bodyLines)); + index = tableIndex; + continue; + } + + const heading = line.match(/^(#{1,3})\s+(.*)$/); + if (heading) { + flushParagraph(); + closeList(); + const level = heading[1].length; + out.push(`${renderInlineMarkdown(heading[2])}`); + index += 1; + continue; + } + + const quote = line.match(/^>\s?(.*)$/); + if (quote) { + flushParagraph(); + closeList(); + out.push(`
${renderInlineMarkdown(quote[1])}
`); + index += 1; + continue; + } + + const hline = line.match(/^\s*((\*\s*){3,}|(-\s*){3,}|(_\s*){3,})\s*$/); + if (hline) { + flushParagraph(); + closeList(); + out.push('
'); + index += 1; + continue; + } + + const unordered = line.match(/^\s*[-*+]\s+(.*)$/); + if (unordered) { + flushParagraph(); + if (openList !== 'ul') { + closeList(); + out.push('
    '); + openList = 'ul'; + } + out.push(`
  • ${renderInlineMarkdown(unordered[1])}
  • `); + index += 1; + continue; + } + + const ordered = line.match(/^\s*\d+\.\s+(.*)$/); + if (ordered) { + flushParagraph(); + if (openList !== 'ol') { + closeList(); + out.push('
      '); + openList = 'ol'; + } + out.push(`
    1. ${renderInlineMarkdown(ordered[1])}
    2. `); + index += 1; + continue; + } + + paragraphLines.push(line); + index += 1; + } + + flushParagraph(); + closeList(); + + return out + .join('\n') + .replace(/@@CB(\d+)@@/g, (_m, i: string) => codeBlocks[Number(i)] || ''); +} diff --git a/console/src/router.tsx b/console/src/router.tsx index d1e22aa4..aad50120 100644 --- a/console/src/router.tsx +++ b/console/src/router.tsx @@ -34,6 +34,19 @@ function TerminalRouteComponent() { ); } +const LazyChatPage = lazy(async () => { + const mod = await import('./routes/chat'); + return { default: mod.ChatPage }; +}); + +function ChatRouteComponent() { + return ( + Loading chat…}> + + + ); +} + function RootLayout() { return ( @@ -136,6 +149,12 @@ const toolsRoute = createRoute({ component: ToolsPage, }); +const chatRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/chat', + component: ChatRouteComponent, +}); + const routeTree = rootRoute.addChildren([ dashboardRoute, terminalRoute, @@ -152,6 +171,7 @@ const routeTree = rootRoute.addChildren([ skillsRoute, pluginsRoute, toolsRoute, + chatRoute, ]); export const router = createRouter({ diff --git a/console/src/routes/chat/chat-page.module.css b/console/src/routes/chat/chat-page.module.css new file mode 100644 index 00000000..7283f808 --- /dev/null +++ b/console/src/routes/chat/chat-page.module.css @@ -0,0 +1,851 @@ +/* ── Chat Page Layout ─────────────────────────────────────── */ + +.chatPage { + display: grid; + grid-template-columns: 260px 1fr; + height: 100%; + min-height: 0; + overflow: hidden; +} + +/* ── Chat Sidebar ─────────────────────────────────────────── */ + +.sidebar { + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + background: var(--sidebar); + border-right: 1px solid var(--line); + padding: 16px 12px; + gap: 8px; +} + +.sidebarHeader { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 6px 12px; +} + +.sidebarLogo { + width: 28px; + height: 28px; + flex-shrink: 0; +} + +.sidebarBrand { + font-size: 1rem; + font-weight: 700; + color: var(--text); + letter-spacing: -0.02em; +} + +.newChatButton { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + padding: 9px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--line-strong); + background: var(--panel-bg); + color: var(--text); + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: background 0.15s; +} + +.newChatButton:hover { + background: var(--accent-soft); +} + +.sidebarLabel { + margin: 12px 0 4px; + padding: 0 6px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.7rem; + color: var(--muted-foreground); + font-weight: 700; +} + +.sessionList { + display: flex; + flex-direction: column; + gap: 2px; + list-style: none; + margin: 0; + padding: 0; +} + +.sessionItem { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.12s; + overflow: hidden; +} + +.sessionItem:hover { + background: var(--panel-bg); +} + +.sessionItemActive { + background: var(--panel-bg); + box-shadow: var(--shadow-raised); +} + +.sessionTitle { + font-size: 0.84rem; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sessionTime { + font-size: 0.72rem; + color: var(--muted-foreground); +} + +/* ── Main Chat Area ───────────────────────────────────────── */ + +.chatMain { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + position: relative; +} + +.messageArea { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 24px 24px 12px; + scroll-behavior: smooth; +} + +.messageList { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 860px; + margin: 0 auto; + width: 100%; +} + +/* ── Empty State ──────────────────────────────────────────── */ + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1 1 auto; + min-height: 0; + padding: 48px 24px; +} + +.greeting { + font-size: clamp(1.6rem, 3vw, 2.2rem); + font-weight: 700; + color: var(--muted-foreground); + text-align: center; + letter-spacing: -0.02em; + line-height: 1.3; +} + +/* ── Message Blocks ───────────────────────────────────────── */ + +.messageBlock { + display: flex; + flex-direction: column; + gap: 6px; +} + +.messageBlockUser { + align-items: flex-end; +} + +.messageBlockAssistant, +.messageBlockSystem { + align-items: flex-start; +} + +.agentLabel { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + font-weight: 600; + color: var(--muted-foreground); + padding: 0 4px; +} + +.agentAvatar { + width: 20px; + height: 20px; + border-radius: 999px; + object-fit: cover; +} + +.agentInitial { + width: 20px; + height: 20px; + border-radius: 999px; + background: var(--primary); + color: var(--primary-foreground); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 700; +} + +.bubble { + padding: 10px 14px; + border-radius: 14px; + max-width: 85%; + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.55; + font-size: 0.92rem; +} + +.bubbleUser { + background: linear-gradient(135deg, var(--primary), var(--ring)); + color: var(--primary-foreground); + border-bottom-right-radius: 4px; +} + +.bubbleAssistant { + background: var(--card); + color: var(--card-foreground); + border: 1px solid var(--line); + border-bottom-left-radius: 4px; + box-shadow: var(--shadow-card); +} + +.bubbleSystem { + background: var(--accent-soft); + color: var(--accent-foreground); + font-size: 0.84rem; + align-self: center; + max-width: 90%; + text-align: center; +} + +/* ── Markdown Content ─────────────────────────────────────── */ + +.markdownContent { + line-height: 1.6; +} + +.markdownContent h1, +.markdownContent h2, +.markdownContent h3 { + margin: 0.6em 0 0.3em; + font-weight: 600; + color: var(--foreground); + letter-spacing: -0.02em; +} + +.markdownContent h1 { + font-size: 1.25rem; +} + +.markdownContent h2 { + font-size: 1.12rem; +} + +.markdownContent h3 { + font-size: 1rem; +} + +.markdownContent p { + margin: 0.4em 0; +} + +.markdownContent ul, +.markdownContent ol { + margin: 0.4em 0; + padding-left: 20px; +} + +.markdownContent li { + margin: 2px 0; +} + +.markdownContent blockquote { + margin: 0.5em 0; + padding: 4px 12px; + border-left: 3px solid var(--line-strong); + color: var(--muted-foreground); +} + +.markdownContent code { + background: var(--muted); + color: var(--foreground); + padding: 2px 5px; + border-radius: 4px; + font-size: 0.86em; +} + +.markdownContent pre { + margin: 0.5em 0; + padding: 12px 14px; + background: var(--terminal); + color: var(--terminal-foreground); + border-radius: var(--radius-sm); + overflow-x: auto; + font-size: 0.84rem; + line-height: 1.5; +} + +.markdownContent pre code { + background: transparent; + color: inherit; + padding: 0; + border-radius: 0; + font-size: inherit; +} + +.markdownContent hr { + border: none; + border-top: 1px solid var(--line); + margin: 0.8em 0; +} + +.markdownContent a { + color: var(--primary); + text-decoration: none; +} + +.markdownContent a:hover { + text-decoration: underline; +} + +.markdownContent table { + border-collapse: collapse; + width: 100%; + margin: 0.5em 0; + font-size: 0.86rem; +} + +.markdownContent th, +.markdownContent td { + padding: 6px 10px; + border: 1px solid var(--line); + text-align: left; +} + +.markdownContent th { + background: var(--muted); + font-weight: 600; +} + +.markdownContent tbody tr:nth-child(even) { + background: var(--muted); +} + +/* ── Message Actions ──────────────────────────────────────── */ + +.messageActions { + display: flex; + gap: 4px; + padding: 0 4px; + opacity: 0; + transition: opacity 0.15s; +} + +.messageBlock:hover .messageActions { + opacity: 1; +} + +.actionButton { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font-size: 0.82rem; + transition: + background 0.12s, + color 0.12s; +} + +.actionButton:hover { + background: var(--muted); + color: var(--text); +} + +.actionButtonSuccess { + color: var(--success); +} + +/* ── Branch Switcher ──────────────────────────────────────── */ + +.branchSwitcher { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + color: var(--muted-foreground); + padding: 0 4px; +} + +.branchButton { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font-size: 0.8rem; +} + +.branchButton:hover { + background: var(--muted); + color: var(--text); +} + +.branchButton:disabled { + opacity: 0.4; + cursor: default; +} + +/* ── Approval Actions ─────────────────────────────────────── */ + +.approvalActions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.approvalAllow { + padding: 6px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--success-border); + background: var(--success-soft); + color: var(--success); + cursor: pointer; + font-size: 0.82rem; + font-weight: 500; +} + +.approvalAllow:hover { + opacity: 0.85; +} + +.approvalDeny { + padding: 6px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--danger-border); + background: var(--danger-soft); + color: var(--danger); + cursor: pointer; + font-size: 0.82rem; + font-weight: 500; +} + +.approvalDeny:hover { + opacity: 0.85; +} + +.approvalAllow:disabled, +.approvalDeny:disabled { + opacity: 0.5; + cursor: default; +} + +/* ── Artifacts ────────────────────────────────────────────── */ + +.artifactCard { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + margin-top: 8px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--muted); +} + +.artifactFilename { + font-size: 0.84rem; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.artifactBadge { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 4px; + background: var(--accent-soft); + color: var(--accent-foreground); + font-weight: 500; + flex-shrink: 0; +} + +.artifactDownload { + margin-left: auto; + font-size: 0.82rem; + color: var(--primary); + text-decoration: none; + flex-shrink: 0; +} + +.artifactDownload:hover { + text-decoration: underline; +} + +.artifactPreview { + margin-top: 8px; + max-width: 100%; + border-radius: var(--radius-sm); +} + +.artifactPreview img { + max-width: 100%; + border-radius: var(--radius-sm); +} + +/* ── Thinking Indicator ───────────────────────────────────── */ + +.thinking { + display: flex; + align-items: center; + gap: 4px; + padding: 10px 14px; +} + +.thinkingDot { + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--muted-foreground); + animation: thinkingBounce 1.2s infinite ease-in-out; +} + +.thinkingDot:nth-child(2) { + animation-delay: 0.15s; +} + +.thinkingDot:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes thinkingBounce { + 0%, + 80%, + 100% { + transform: scale(0.6); + opacity: 0.4; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* ── Composer ─────────────────────────────────────────────── */ + +.composerWrapper { + flex-shrink: 0; + padding: 0 24px 20px; +} + +.composer { + display: flex; + flex-direction: column; + max-width: 860px; + margin: 0 auto; + width: 100%; + border: 1px solid var(--line-strong); + border-radius: 24px; + background: var(--panel-bg); + box-shadow: var(--shadow-raised); + overflow: hidden; +} + +.pendingMediaRow { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 14px 0; +} + +.mediaChip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 6px; + background: var(--muted); + font-size: 0.78rem; + color: var(--text); + max-width: 180px; +} + +.mediaChipName { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mediaChipRemove { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font-size: 0.7rem; + flex-shrink: 0; +} + +.mediaChipRemove:hover { + color: var(--danger); +} + +.composerRow { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 10px 14px; +} + +.composerInput { + flex: 1 1 auto; + min-height: 24px; + max-height: 180px; + resize: none; + border: none; + outline: none; + background: transparent; + color: var(--text); + font-size: 0.92rem; + line-height: 1.5; + padding: 2px 0; +} + +.composerInput::placeholder { + color: var(--muted-foreground); +} + +.attachButton, +.sendButton { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 999px; + cursor: pointer; + flex-shrink: 0; + transition: + background 0.12s, + color 0.12s; +} + +.attachButton { + background: transparent; + color: var(--muted-foreground); + font-size: 1.2rem; +} + +.attachButton:hover { + background: var(--muted); + color: var(--text); +} + +.sendButton { + background: var(--primary); + color: var(--primary-foreground); + font-size: 0.9rem; +} + +.sendButton:disabled { + opacity: 0.5; + cursor: default; +} + +.sendButton.stopping { + background: var(--danger-soft); + color: var(--danger); +} + +/* ── Slash Suggestions ────────────────────────────────────── */ + +.slashSuggestions { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + max-height: 220px; + overflow-y: auto; + background: var(--popover); + border: 1px solid var(--line); + border-radius: var(--radius-md); + box-shadow: var(--shadow-popover); + margin-bottom: 4px; + z-index: 20; +} + +.suggestionItem { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 14px; + cursor: pointer; + transition: background 0.1s; +} + +.suggestionItem:hover, +.suggestionItemActive { + background: var(--muted); +} + +.suggestionLabel { + font-size: 0.86rem; + font-weight: 500; + color: var(--text); +} + +.suggestionDesc { + font-size: 0.76rem; + color: var(--muted-foreground); +} + +/* ── Error Banner ─────────────────────────────────────────── */ + +.errorBanner { + padding: 8px 14px; + margin: 0 24px; + border-radius: var(--radius-sm); + background: var(--danger-soft); + border: 1px solid var(--danger-border); + color: var(--danger); + font-size: 0.84rem; + max-width: 860px; + margin-left: auto; + margin-right: auto; + width: calc(100% - 48px); +} + +/* ── Edit Mode ────────────────────────────────────────────── */ + +.editArea { + width: 100%; + min-height: 48px; + max-height: 200px; + resize: vertical; + padding: 8px 10px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--panel-bg); + color: var(--text); + font-size: 0.92rem; + line-height: 1.5; + font-family: inherit; +} + +.editButtons { + display: flex; + gap: 6px; + margin-top: 6px; +} + +/* ── Responsive ───────────────────────────────────────────── */ + +@media (max-width: 900px) { + .chatPage { + grid-template-columns: 1fr; + } + + .sidebar { + display: none; + } + + .sidebarOpen { + display: flex; + position: fixed; + inset: 0; + z-index: 100; + width: 280px; + max-width: 85vw; + animation: slideIn 0.2s ease; + } + + .sidebarBackdrop { + position: fixed; + inset: 0; + z-index: 99; + background: rgba(0, 0, 0, 0.4); + border: none; + padding: 0; + cursor: default; + } + + .mobileHeader { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--line); + flex-shrink: 0; + } + + .messageArea { + padding: 16px 16px 8px; + } + + .composerWrapper { + padding: 0 16px 12px; + } +} + +@media (min-width: 901px) { + .mobileHeader { + display: none; + } + + .sidebarBackdrop { + display: none; + } +} + +@keyframes slideIn { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} diff --git a/console/src/routes/chat/chat-page.tsx b/console/src/routes/chat/chat-page.tsx new file mode 100644 index 00000000..65f3d751 --- /dev/null +++ b/console/src/routes/chat/chat-page.tsx @@ -0,0 +1,399 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + createChatBranch, + fetchAppStatus, + fetchChatHistory, + fetchChatRecent, + uploadMedia, +} from '../../api/chat'; +import type { ChatMessage, MediaItem } from '../../api/chat-types'; +import { useAuth } from '../../auth'; +import { + type ApprovalAction, + buildApprovalCommand, + copyToClipboard, + DEFAULT_AGENT_ID, + generateWebSessionId, + isScrolledNearBottom, + nextMsgId, + readStoredSessionId, + readStoredUserId, + storeSessionId, +} from '../../lib/chat-helpers'; +import { cx } from '../../lib/cx'; +import css from './chat-page.module.css'; +import { ChatSidebar } from './chat-sidebar'; +import { Composer } from './composer'; +import { EditInline, MessageBlock } from './message-block'; +import { useChatStream } from './use-chat-stream'; + +export function ChatPage() { + const auth = useAuth(); + const queryClient = useQueryClient(); + const userId = useRef(readStoredUserId()).current; + const defaultAgentIdRef = useRef(DEFAULT_AGENT_ID); + + const [sessionId, setSessionId] = useState(() => { + const stored = readStoredSessionId(); + return stored || generateWebSessionId(); + }); + const [messages, setMessages] = useState([]); + const [error, setError] = useState(''); + const [editingId, setEditingId] = useState(null); + const [approvalBusy, setApprovalBusy] = useState(false); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [branchFamilies, setBranchFamilies] = useState>( + new Map(), + ); + + const messagesEndRef = useRef(null); + const messageAreaRef = useRef(null); + const sessionIdRef = useRef(sessionId); + sessionIdRef.current = sessionId; + + const pendingEditRef = useRef<{ + content: string; + media: MediaItem[]; + } | null>(null); + + const refreshRecent = useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: ['chat-recent', auth.token, userId], + }); + }, [queryClient, auth.token, userId]); + + const getSessionId = useCallback(() => sessionIdRef.current, []); + + const stream = useChatStream({ + token: auth.token, + userId, + getSessionId, + setMessages, + setSessionId, + setError, + refreshRecent, + }); + + // Stable ref for history-load effect to call sendMessage without stale closure + const sendMessageRef = useRef(stream.sendMessage); + sendMessageRef.current = stream.sendMessage; + + useEffect(() => { + storeSessionId(sessionId); + }, [sessionId]); + + useEffect(() => { + void fetchAppStatus(auth.token) + .then((status) => { + if (status.defaultAgentId) { + defaultAgentIdRef.current = status.defaultAgentId + .trim() + .toLowerCase(); + } + }) + .catch(() => {}); + }, [auth.token]); + + const recentQuery = useQuery({ + queryKey: ['chat-recent', auth.token, userId], + queryFn: () => fetchChatRecent(auth.token, userId), + staleTime: 10_000, + }); + const recentSessions = recentQuery.data?.sessions ?? []; + + // Load history when session changes; flush queued edit if pending + useEffect(() => { + if (!sessionId) return; + let cancelled = false; + + void fetchChatHistory(auth.token, sessionId) + .then((data) => { + if (cancelled) return; + // Only update sessionId if the server returned a different one + if (data.sessionId && data.sessionId !== sessionId) { + setSessionId(data.sessionId); + } + + const loaded: ChatMessage[] = (data.history ?? []).map((msg) => ({ + id: nextMsgId(), + role: msg.role, + content: msg.content, + rawContent: msg.content, + sessionId: data.sessionId ?? sessionId, + messageId: msg.id ?? null, + media: [], + artifacts: [], + replayRequest: + msg.role === 'user' ? { content: msg.content, media: [] } : null, + assistantPresentation: data.assistantPresentation ?? null, + })); + setMessages(loaded); + setBranchFamilies( + new Map( + (data.branchFamilies ?? []).map((bf) => [ + `${bf.anchorSessionId}:${bf.anchorMessageId}`, + bf.variants, + ]), + ), + ); + + const pending = pendingEditRef.current; + if (pending) { + pendingEditRef.current = null; + void sendMessageRef.current(pending.content, pending.media); + } + }) + .catch((err) => { + if (!cancelled) + setError(err instanceof Error ? err.message : String(err)); + }); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId, auth.token]); + + const scrollRafRef = useRef(0); + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-runs on message changes to auto-scroll + useEffect(() => { + if (scrollRafRef.current) return; + scrollRafRef.current = requestAnimationFrame(() => { + scrollRafRef.current = 0; + if (isScrolledNearBottom(messageAreaRef.current)) { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }); + }, [messages]); + useEffect(() => { + return () => { + if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current); + }; + }, []); + + /* ── Handlers ───────────────────────────────────────────── */ + + const resetChatState = useCallback(() => { + setMessages([]); + setError(''); + setEditingId(null); + setMobileSidebarOpen(false); + }, []); + + const handleEditSave = useCallback( + async (msg: ChatMessage, newContent: string) => { + setEditingId(null); + if (!msg.messageId || !msg.sessionId) return; + try { + const branch = await createChatBranch( + auth.token, + msg.sessionId, + msg.messageId, + ); + pendingEditRef.current = { + content: newContent, + media: msg.media ?? [], + }; + setSessionId(branch.sessionId); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, + [auth.token], + ); + + const handleRegenerate = useCallback( + (msg: ChatMessage) => { + if (!msg.replayRequest) return; + void stream.sendMessage( + msg.replayRequest.content, + msg.replayRequest.media, + { hideUser: true }, + ); + }, + [stream.sendMessage], + ); + + const handleApprovalAction = useCallback( + async (action: ApprovalAction, approvalId: string) => { + const cmd = buildApprovalCommand(action, approvalId); + if (!cmd) return; + setApprovalBusy(true); + try { + await stream.sendMessage(cmd, [], { hideUser: true }); + } finally { + setApprovalBusy(false); + } + }, + [stream.sendMessage], + ); + + const handleUploadFiles = useCallback( + async (files: File[]): Promise => { + const results = await Promise.allSettled( + files.map((file) => uploadMedia(auth.token, file)), + ); + const uploaded: MediaItem[] = []; + for (const r of results) { + if (r.status === 'fulfilled' && r.value.media) { + uploaded.push(r.value.media); + } else if (r.status === 'rejected') { + const err = r.reason; + setError(err instanceof Error ? err.message : String(err)); + } + } + return uploaded; + }, + [auth.token], + ); + + const handleNewChat = useCallback(() => { + if (stream.isActive()) { + setError('Stop the current run before starting a new chat.'); + return; + } + resetChatState(); + setSessionId(generateWebSessionId(defaultAgentIdRef.current)); + refreshRecent(); + }, [stream, resetChatState, refreshRecent]); + + const handleOpenSession = useCallback( + (targetId: string) => { + if (stream.isActive()) { + setError('Stop the current run before switching chats.'); + return; + } + resetChatState(); + setSessionId(targetId); + }, + [stream, resetChatState], + ); + + const handleBranchNav = useCallback( + (msg: ChatMessage, direction: -1 | 1) => { + const key = msg.branchKey; + if (!key) return; + const variants = branchFamilies.get(key); + if (!variants || variants.length < 2) return; + const nextIdx = variants.indexOf(msg.sessionId) + direction; + if (nextIdx < 0 || nextIdx >= variants.length) return; + handleOpenSession(variants[nextIdx]); + }, + [branchFamilies, handleOpenSession], + ); + + const branchInfoMap = useMemo(() => { + const map = new Map(); + for (const msg of messages) { + const key = msg.branchKey; + if (!key) continue; + const variants = branchFamilies.get(key); + if (!variants || variants.length < 2) continue; + map.set(msg.id, { + current: variants.indexOf(msg.sessionId) + 1, + total: variants.length, + }); + } + return map; + }, [messages, branchFamilies]); + + /* ── Render ─────────────────────────────────────────────── */ + + const isEmpty = messages.length === 0; + + const sidebarProps = { + sessions: recentSessions, + activeSessionId: sessionId, + onNewChat: handleNewChat, + onOpenSession: handleOpenSession, + } as const; + + return ( +
      +
      + +
      + + {mobileSidebarOpen ? ( + <> + + HybridClaw +
      + + {isEmpty ? ( +
      +

      + Ready to claw through your to-do list? +

      +
      + ) : ( +
      +
      + {messages.map((msg) => + editingId === msg.id ? ( +
      + + void handleEditSave(msg, newContent) + } + onCancel={() => setEditingId(null)} + /> +
      + ) : ( + setEditingId(m.id)} + onRegenerate={handleRegenerate} + onApprovalAction={handleApprovalAction} + approvalBusy={approvalBusy} + branchInfo={branchInfoMap.get(msg.id) ?? null} + onBranchNav={(dir) => handleBranchNav(msg, dir)} + /> + ), + )} +
      +
      +
      + )} + + {error ?
      {error}
      : null} + + void stream.sendMessage(content, media)} + onStop={() => void stream.stopRequest()} + onUploadFiles={handleUploadFiles} + token={auth.token} + /> +
      + + ); +} diff --git a/console/src/routes/chat/chat-sidebar.tsx b/console/src/routes/chat/chat-sidebar.tsx new file mode 100644 index 00000000..247ffbe9 --- /dev/null +++ b/console/src/routes/chat/chat-sidebar.tsx @@ -0,0 +1,59 @@ +import type { ChatRecentSession } from '../../api/chat-types'; +import { cx } from '../../lib/cx'; +import { formatRelativeTime } from '../../lib/format'; +import css from './chat-page.module.css'; + +export function ChatSidebar(props: { + sessions: ChatRecentSession[]; + activeSessionId: string; + onNewChat: () => void; + onOpenSession: (sessionId: string) => void; +}) { + return ( + <> +
      + HybridClaw + HybridClaw +
      + + {props.sessions.length > 0 ? ( + <> +
      Recent
      +
        + {props.sessions.map((s) => ( +
      • props.onOpenSession(s.sessionId)} + onKeyDown={(e) => { + if (e.key === 'Enter') props.onOpenSession(s.sessionId); + }} + > + + {s.title || 'Untitled'} + + + {formatRelativeTime(s.lastActive)} + +
      • + ))} +
      + + ) : null} + + ); +} diff --git a/console/src/routes/chat/composer.tsx b/console/src/routes/chat/composer.tsx new file mode 100644 index 00000000..7dc0f1e1 --- /dev/null +++ b/console/src/routes/chat/composer.tsx @@ -0,0 +1,285 @@ +import { + type ChangeEvent, + type ClipboardEvent, + type KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { fetchChatCommands } from '../../api/chat'; +import type { ChatCommandSuggestion, MediaItem } from '../../api/chat-types'; +import { extractClipboardFiles } from '../../lib/chat-helpers'; +import { cx } from '../../lib/cx'; +import css from './chat-page.module.css'; + +function SlashSuggestions(props: { + items: ChatCommandSuggestion[]; + activeIndex: number; + onSelect: (item: ChatCommandSuggestion) => void; +}) { + if (props.items.length === 0) return null; + return ( +
      + {props.items.map((item, i) => ( +
      { + e.preventDefault(); + props.onSelect(item); + }} + > + {item.label} + {item.description ? ( + {item.description} + ) : null} +
      + ))} +
      + ); +} + +export function Composer(props: { + isStreaming: boolean; + onSend: (content: string, media: MediaItem[]) => void; + onStop: () => void; + onUploadFiles: (files: File[]) => Promise; + token: string; +}) { + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + const [pendingMedia, setPendingMedia] = useState([]); + const [uploading, setUploading] = useState(0); + const [suggestions, setSuggestions] = useState([]); + const [activeIdx, setActiveIdx] = useState(0); + const [showSuggestions, setShowSuggestions] = useState(false); + const suggestTimerRef = useRef | null>(null); + const suggestSeqRef = useRef(0); + + useEffect(() => { + return () => { + if (suggestTimerRef.current) clearTimeout(suggestTimerRef.current); + }; + }, []); + + const resize = () => { + const ta = textareaRef.current; + if (!ta) return; + ta.style.height = '24px'; + ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`; + }; + + const fetchSuggestions = useCallback( + async (query: string) => { + suggestSeqRef.current += 1; + const seq = suggestSeqRef.current; + try { + const res = await fetchChatCommands(props.token, query || undefined); + if (seq !== suggestSeqRef.current) return; + setSuggestions(res.commands ?? []); + setActiveIdx(0); + setShowSuggestions((res.commands ?? []).length > 0); + } catch { + if (seq !== suggestSeqRef.current) return; + setSuggestions([]); + setShowSuggestions(false); + } + }, + [props.token], + ); + + const handleInput = () => { + resize(); + const val = textareaRef.current?.value ?? ''; + if (val.startsWith('/')) { + if (suggestTimerRef.current) clearTimeout(suggestTimerRef.current); + suggestTimerRef.current = setTimeout(() => { + void fetchSuggestions(val); + }, 150); + } else { + setShowSuggestions(false); + } + }; + + const applySuggestion = (item: ChatCommandSuggestion) => { + if (!textareaRef.current) return; + textareaRef.current.value = `${item.insertText} `; + setShowSuggestions(false); + resize(); + textareaRef.current.focus(); + }; + + const submit = () => { + if (props.isStreaming) { + props.onStop(); + return; + } + const val = (textareaRef.current?.value ?? '').trim(); + if (!val && pendingMedia.length === 0) return; + if (uploading > 0) return; + props.onSend(val, pendingMedia); + if (textareaRef.current) textareaRef.current.value = ''; + setPendingMedia([]); + resize(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (showSuggestions && suggestions.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIdx((i) => (i + 1) % suggestions.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIdx((i) => (i - 1 + suggestions.length) % suggestions.length); + return; + } + if (e.key === 'Tab' || e.key === 'Enter') { + e.preventDefault(); + applySuggestion(suggestions[activeIdx]); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setShowSuggestions(false); + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }; + + const handlePaste = (e: ClipboardEvent) => { + const files = extractClipboardFiles(e.clipboardData); + if (files.length > 0) { + e.preventDefault(); + void doUpload(files); + } + }; + + const doUpload = async (files: File[]) => { + setUploading((n) => n + files.length); + try { + const uploaded = await props.onUploadFiles(files); + if (uploaded.length > 0) { + setPendingMedia((prev) => [...prev, ...uploaded]); + } + } finally { + setUploading((n) => Math.max(0, n - files.length)); + } + }; + + const handleFileChange = (e: ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + if (files.length > 0) void doUpload(files); + e.target.value = ''; + }; + + const removeMedia = (index: number) => { + setPendingMedia((prev) => prev.filter((_, i) => i !== index)); + }; + + return ( +
      +
      + {showSuggestions ? ( + + ) : null} + {pendingMedia.length > 0 || uploading > 0 ? ( +
      + {pendingMedia.map((m, i) => ( + + {m.filename} + + + ))} + {uploading > 0 ? ( + Uploading… + ) : null} +
      + ) : null} +
      + +