From a0eafed3f886eb5e7af8b0f9924d1a1d303a98ab Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Tue, 14 Jul 2026 15:55:14 +0530 Subject: [PATCH] feat(chat): plain-language chat surface for non-technical teammates (v0.189.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new Chat page renders a claude-code session as a messaging app instead of a terminal — message bubbles, friendly activity cards, and inline approve/decline buttons — so support/sales/marketing can drive and oversee agents without the TUI. Reads the session's claude transcript (GET /api/sessions/:id/conversation, parsed by src/edge/conversation.ts) and drives the human's turns via POST .../reply and POST /api/chat/start, reusing the exact resident-deliver / transcript-resume path Slack thread-continuity already uses (deliverToResident/reviveResident). No gate, approvals, or DB changes — every effect still passes the mediated gateway. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 +- package-lock.json | 4 +- package.json | 2 +- src/edge/conversation.ts | 180 +++++++++++++++++++++++++ src/server.ts | 45 ++++++- web/src/App.tsx | 280 ++++++++++++++++++++++++++++++++++++++- web/src/lib/api.ts | 20 +++ 7 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 src/edge/conversation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fc4afc1..e7901ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ new version heading in the same commit. ## [Unreleased] +## [0.191.0] — 2026-07-14 +### Added +- **Chat — a plain-language window onto a claude-code run for non-technical teammates.** A new **Chat** + page (nav next to Agents) renders a session as a messaging app instead of a terminal: message bubbles, + friendly activity cards ("Sent a Slack message" ✓ / "Read a file"), and **inline approvals/questions** + as Approve/Decline buttons — the governance surface in language support/sales/marketing can act on. It + reads the session's claude transcript (`GET /api/sessions/:id/conversation`, parsed by + `src/edge/conversation.ts`) and drives the human's turns via `POST /api/sessions/:id/reply` and + `POST /api/chat/start` — reusing the **exact** resident-deliver / transcript-resume path Slack + thread-continuity already uses (`deliverToResident`/`reviveResident`), so no governance, gate, or DB + changes: the gate hook + approvals still mediate every effect. Message-level polling (2s), no terminal. + (`src/edge/conversation.ts`, `src/server.ts`, `web/src/App.tsx`, `web/src/lib/api.ts`.) + ## [0.190.0] — 2026-07-14 ### Added - **Callee agents can poke the caller back when they're really done — async delegation, no polling.** A @@ -38,7 +51,6 @@ new version heading in the same commit. *webhook* url, not our (present) top-level one. Fix: **omit `hook_attributes` entirely** — that's an App with no webhook, exactly what we want, and no required url. (`src/server.ts` `githubAppManifest`; `scripts/github-per-member-test.cjs` now 79/79.) - ## [0.188.0] — 2026-07-14 ### Added - **Set the GitHub App slug by hand when it can't be auto-detected.** The "Install the App" button needs diff --git a/package-lock.json b/package-lock.json index 6cc508d..616620f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.190.0", + "version": "0.191.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.190.0", + "version": "0.191.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 60cf474..71b243c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.190.0", + "version": "0.191.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/src/edge/conversation.ts b/src/edge/conversation.ts new file mode 100644 index 0000000..b72cf31 --- /dev/null +++ b/src/edge/conversation.ts @@ -0,0 +1,180 @@ +// A NON-TECHNICAL view of a claude-code session. +// +// Claude Code writes a structured JSONL transcript per session (one file, named by the pinned +// `--session-id`, under `$CLAUDE_CONFIG_DIR/projects//.jsonl`). agent-os already +// pins that id (`term_sessions.claude_session_id`), so we can locate the file by NAME alone — +// regardless of which agent workspace (cwd) the run used. +// +// This module turns that raw transcript (thinking blocks, tool_use JSON, tool_result payloads) into +// a clean chat timeline a support/sales/marketing user can read: plain message bubbles + friendly +// "activity" cards ("Sent a Slack message", "Read a file") instead of tool JSON. It is READ-ONLY and +// has no dependency on how the session is driven — the native chat UI and the ttyd terminal are two +// windows onto the same underlying run. + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +/** One entry in the human-readable timeline. */ +export type ChatTurn = + | { kind: 'user'; text: string; ts: number } + | { kind: 'assistant'; text: string; ts: number } + | { + kind: 'activity'; + tool: string; + /** friendly one-liner, e.g. "Sent a Slack message" */ + label: string; + /** short human hint (a filename, a URL host, a query) — the UI may hide this behind a toggle */ + detail?: string; + status: 'running' | 'ok' | 'error'; + ts: number; + }; + +export interface Conversation { + turns: ChatTurn[]; + /** true once we found and parsed a transcript file (false = the run hasn't written one yet) */ + found: boolean; +} + +const claudeConfigDir = (): string => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + +/** Locate `.jsonl` under any project dir. Filename is unique, so cwd escaping is irrelevant. */ +function findTranscript(claudeSessionId: string): string | undefined { + const projects = path.join(claudeConfigDir(), 'projects'); + let dirs: string[]; + try { + dirs = fs.readdirSync(projects); + } catch { + return undefined; + } + const wanted = `${claudeSessionId}.jsonl`; + for (const d of dirs) { + const candidate = path.join(projects, d, wanted); + if (fs.existsSync(candidate)) return candidate; + } + return undefined; +} + +/** "slack_send" / "getFileContent" → "Slack send" / "Get file content" — last-resort humanizer. */ +function humanize(raw: string): string { + const words = raw + .replace(/[_-]+/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .trim() + .toLowerCase(); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +const basename = (p: unknown): string | undefined => + typeof p === 'string' && p ? p.split('/').pop() || p : undefined; + +const host = (u: unknown): string | undefined => { + if (typeof u !== 'string') return undefined; + try { + return new URL(u).host; + } catch { + return u.slice(0, 60); + } +}; + +const clip = (s: unknown, n = 80): string | undefined => + typeof s === 'string' && s ? (s.length > n ? s.slice(0, n) + '…' : s) : undefined; + +/** Map a tool_use block to a friendly label + short detail, tuned for a non-technical reader. */ +function friendlyTool(name: string, input: Record): { label: string; detail?: string } { + switch (name) { + case 'Bash': + return { label: 'Ran a command', detail: clip(input.command) }; + case 'Read': + return { label: 'Read a file', detail: basename(input.file_path) }; + case 'Edit': + case 'Write': + case 'NotebookEdit': + return { label: 'Updated a file', detail: basename(input.file_path) }; + case 'Grep': + case 'Glob': + return { label: 'Searched the files', detail: clip(input.pattern) }; + case 'WebFetch': + return { label: 'Read a web page', detail: host(input.url) }; + case 'WebSearch': + return { label: 'Searched the web', detail: clip(input.query) }; + case 'Task': + case 'Agent': + return { label: 'Asked a helper to dig in', detail: clip(input.description) }; + case 'ToolSearch': + return { label: 'Looked up the tools it needs' }; + case 'TodoWrite': + return { label: 'Updated its plan' }; + } + // agent-os MCP tools arrive as mcp____. + const mcp = name.match(/^mcp__[^_]*(?:_[^_]+)*__(.+)$/) || name.match(/^mcp__.+?__(.+)$/); + const bare = mcp ? mcp[1] : name; + if (/^slack_/.test(bare)) return { label: 'Sent a Slack message', detail: clip(input.text ?? input.message ?? input.channel) }; + if (/^discord_/.test(bare)) return { label: 'Sent a Discord message', detail: clip(input.content ?? input.message) }; + if (/^(remember|recall|revise|forget)$/.test(bare)) return { label: 'Used its memory' }; + if (/^kb_/.test(bare)) return { label: 'Used the knowledge base', detail: clip(input.query ?? input.slug ?? input.title) }; + if (/^task_/.test(bare)) return { label: 'Updated the task board', detail: clip(input.title) }; + if (/^(report|update|notify|publish)$/.test(bare)) return { label: 'Posted an update' }; + if (bare === 'ask') return { label: 'Asked a question', detail: clip(input.question) }; + if (/^secret_/.test(bare)) return { label: 'Used a stored credential', detail: clip(input.key) }; + if (bare === 'directory_lookup') return { label: 'Looked someone up' }; + return { label: humanize(bare), detail: clip((input as any).query ?? (input as any).title) }; +} + +/** Parse a claude transcript line's `message.content` into ordered turns; merge tool results by id. */ +export function readConversation(claudeSessionId: string): Conversation { + const file = findTranscript(claudeSessionId); + if (!file) return { turns: [], found: false }; + + let raw: string; + try { + raw = fs.readFileSync(file, 'utf8'); + } catch { + return { turns: [], found: false }; + } + + const turns: ChatTurn[] = []; + // tool_use_id → index in `turns`, so the tool_result on the NEXT user message resolves the card. + const activityById = new Map(); + + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + let o: any; + try { + o = JSON.parse(line); + } catch { + continue; + } + if (o.type !== 'assistant' && o.type !== 'user') continue; + const ts = Date.parse(o.timestamp) || 0; + const content = o.message?.content; + + if (typeof content === 'string') { + const text = content.trim(); + if (text) turns.push({ kind: o.type === 'user' ? 'user' : 'assistant', text, ts }); + continue; + } + if (!Array.isArray(content)) continue; + + for (const b of content) { + if (!b || typeof b !== 'object') continue; + if (b.type === 'text') { + const text = String(b.text || '').trim(); + if (text) turns.push({ kind: o.type === 'user' ? 'user' : 'assistant', text, ts }); + } else if (b.type === 'tool_use') { + const { label, detail } = friendlyTool(String(b.name || ''), (b.input || {}) as Record); + activityById.set(String(b.id), turns.length); + turns.push({ kind: 'activity', tool: String(b.name || ''), label, detail, status: 'running', ts }); + } else if (b.type === 'tool_result') { + const idx = activityById.get(String(b.tool_use_id)); + if (idx != null) { + const card = turns[idx]; + if (card && card.kind === 'activity') card.status = b.is_error ? 'error' : 'ok'; + } + } + // thinking blocks are deliberately dropped — noise for a non-technical reader. + } + } + + return { turns, found: true }; +} diff --git a/src/server.ts b/src/server.ts index 7a007ae..e1e961e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,7 +17,8 @@ import { exampleCapabilities } from './capabilities/examples'; import { evaluate } from './observability/evaluation'; import { TerminalManager, AGENT_OS_OPERATING_NOTES } from './terminal'; import { classifyActivity, clipText, ActivityCategory, ActivityEffect } from './state/session-activity'; -import { Automation, Automations, nextCronRun, derivedConcurrencyCap } from './edge/automations'; +import { readConversation } from './edge/conversation'; +import { Automation, Automations, nextCronRun, derivedConcurrencyCap, chatTitle } from './edge/automations'; import { SlackSocket } from './edge/slack-socket'; import { DiscordSocket } from './edge/discord-socket'; import { DreamingEngine, recommendationResolved } from './edge/dreaming'; @@ -1893,6 +1894,48 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: const s = tm.createSession(agent, String(b.title || task), task, me.id); return sendJson(res, 200, { id: s.id, tmux: s.tmux }); } + // ─── Native chat surface (non-technical) — a plain-language window onto a claude-code run, an + // alternative to the ttyd terminal. Governance is UNCHANGED: the gate hook + approvals still + // mediate every effect; this only reads the transcript and drives replies via the same + // resident deliver/revive path Slack thread-continuity uses. + // + // Start a chat: spawn a RESIDENT interactive session (kept warm for fast follow-ups, like Slack chat). + if (method === 'POST' && p === '/api/chat/start') { + const b = await readBody(req); + const agent = String(b.agent || '').trim(); + const message = String(b.message || '').trim(); + if (!agent || !message) return sendJson(res, 400, { error: 'agent and message are required' }); + if (!os.team.canRun(me, agent)) return sendJson(res, 403, { error: `you are not assigned to run "${agent}"` }); + // spawnedBy=chat provenance, runAs=me (accountable human for the turn), resident=true. + const s = tm.createSession(agent, chatTitle(message, agent), message, `chat:${me.id}`, false, undefined, undefined, me.id, undefined, true); + return sendJson(res, 200, { id: s.id, tmux: s.tmux }); + } + // Read the friendly conversation timeline for a session (poll this like the rest of the console). + const convoMatch = p.match(/^\/api\/sessions\/([\w-]+)\/conversation$/); + if (method === 'GET' && convoMatch) { + const id = convoMatch[1]; + const agent = tm.sessionAgent(id); + if (!agent) return sendJson(res, 404, { error: 'unknown session' }); + if (!tm.canViewSession(id, me)) return sendJson(res, 403, { error: 'not allowed to view this session' }); + const claudeId = tm.sessionClaudeId(id); + const convo = claudeId ? readConversation(claudeId) : { turns: [], found: false }; + return sendJson(res, 200, { agent, ...convo }); + } + // Reply into a session (the human's next turn). Warm path types into the live resident pane; cold + // path (a reaped session) resumes the SAME transcript seeded with the message — identical to a Slack + // thread follow-up. The replier becomes the accountable run-as for this turn. + const chatReplyMatch = p.match(/^\/api\/sessions\/([\w-]+)\/reply$/); + if (method === 'POST' && chatReplyMatch) { + const id = chatReplyMatch[1]; + if (!tm.sessionAgent(id)) return sendJson(res, 404, { error: 'unknown session' }); + if (!tm.canViewSession(id, me)) return sendJson(res, 403, { error: 'not allowed to reply to this session' }); + const b = await readBody(req); + const message = String(b.message || '').trim(); + if (!message) return sendJson(res, 400, { error: 'message is required' }); + if (tm.deliverToResident(id, message)) return sendJson(res, 200, { status: 'delivered' }); + if (tm.reviveResident(id, message, me.id)) return sendJson(res, 200, { status: 'revived' }); + return sendJson(res, 409, { error: 'this session could not accept the message' }); + } // Session activity: "which agent-os primitives did this run use?" — the session's audit stream, // classified into a chronological timeline + a grouped count summary. Same visibility as the terminal // (canViewSession), so a member sees the activity of the runs they can attach to, not just admins. diff --git a/web/src/App.tsx b/web/src/App.tsx index 861ba11..f2dd379 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react' import { Inbox as InboxIcon, TerminalSquare, Play, Plus, Check, X, Square, Rocket, Plug, Trash2, Users, User, LogOut, Copy, Zap, Brain, Building2, ChevronDown, SlidersHorizontal, Pencil, FileText, HelpCircle, CheckCircle2, XCircle, Clock, Send, LayoutGrid, List, ArrowLeft, Bot, FolderTree, Folder, File as FileIcon, FileCode, Save, ChevronRight, Sparkles, Package, Image as ImageIcon, Film, Download, Search, BookText, BookOpen, History as HistoryIcon, ScrollText, Bell, AlertTriangle, Activity, Lightbulb, Moon, Upload, FolderPlus, ListChecks, PanelLeftClose, PanelLeftOpen, RefreshCw, ThumbsUp, ThumbsDown, Target, ExternalLink, Paperclip, KeyRound } from 'lucide-react' import { Wrench, Code2, Bug, MessageSquare, Mail, Megaphone, PenTool, Database, Server, Cloud, Shield, Calendar, LineChart, BarChart3, DollarSign, ShoppingCart, Headphones, Cog, Compass, Flag, Heart, Star, Globe, GitBranch, Palette, Camera, Music, Feather, Wand2, Boxes, Terminal, Webhook, CalendarClock, Hash, Cpu, MoreHorizontal, Power, PowerOff, Pin, PinOff, type LucideIcon } from 'lucide-react' import ReactMarkdown from 'react-markdown' @@ -12,7 +12,7 @@ import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu' -import { api, EFFORTS, PERMISSION_MODES, type PermissionMode, type StateResp, type AgentInfo, type Session, type Msg, type Member, type Role, type TeamResp, type MemberIdentity, type IdentityProvider, IDENTITY_PROVIDERS, type Automation, type Task, type TaskEvent, type TaskAttachment, type TaskStatus, type AddTaskReq, type Goal, type GoalEvent, type GoalStatus, type GoalCounts, type GoalProgress, type AddGoalReq, type MemoryRecord, type MemoryHealth, type MemoryBackend, type MemorySettings, type MemorySettingsReq, type OllamaStatus, type KbPage, type KbRevision, type AgentRevision, type AgentStats, type Recommendation, type DigestConfig, type DigestModel, type DreamingState, type Measurement, type Insights, type ImprovementTile, type MemoryCleanupPlan, type KbTidyPlan, type StuckGoal, type TroubledAutomation, type PolicyDocument, type PolicyRule, type PolicyOutcome, type PolicyOp, type DirListing, type FileEntry, type FileContent, type Artifact, type SkillSummary, type SkillsResp, type CatalogSkill, type CatalogAgent, type SkillSource, type RemoteSkill, type SkillshHit, type SkillRequest, type SecretRequest, type IntegrationsResp, type SlackStatus, type DiscordStatus, type AuditEvent, type Effort, type RuntimeTuning, type Concurrency, type SecretMeta, type UpdateStatus, type UpdateApplyResult, type ActivityEvent, type ActivitySummaryRow, type SystemMetrics } from '@/lib/api' +import { api, EFFORTS, PERMISSION_MODES, type PermissionMode, type StateResp, type AgentInfo, type Session, type Msg, type Member, type Role, type TeamResp, type MemberIdentity, type IdentityProvider, IDENTITY_PROVIDERS, type Automation, type Task, type TaskEvent, type TaskAttachment, type TaskStatus, type AddTaskReq, type Goal, type GoalEvent, type GoalStatus, type GoalCounts, type GoalProgress, type AddGoalReq, type MemoryRecord, type MemoryHealth, type MemoryBackend, type MemorySettings, type MemorySettingsReq, type OllamaStatus, type KbPage, type KbRevision, type AgentRevision, type AgentStats, type Recommendation, type DigestConfig, type DigestModel, type DreamingState, type Measurement, type Insights, type ImprovementTile, type MemoryCleanupPlan, type KbTidyPlan, type StuckGoal, type TroubledAutomation, type PolicyDocument, type PolicyRule, type PolicyOutcome, type PolicyOp, type DirListing, type FileEntry, type FileContent, type Artifact, type SkillSummary, type SkillsResp, type CatalogSkill, type CatalogAgent, type SkillSource, type RemoteSkill, type SkillshHit, type SkillRequest, type SecretRequest, type IntegrationsResp, type SlackStatus, type DiscordStatus, type AuditEvent, type Effort, type RuntimeTuning, type Concurrency, type SecretMeta, type UpdateStatus, type UpdateApplyResult, type ActivityEvent, type ActivitySummaryRow, type SystemMetrics, type ChatTurn } from '@/lib/api' import { type Branding, type PublicBranding, type NotificationPrefs, DEFAULT_NOTIFICATION_PREFS } from '@/lib/api' import { applyAccent, applyFavicon, faviconDataUri, readableOn } from '@/lib/branding' import { ConnectorsPage, GithubMineCard } from '@/connectors' @@ -22,9 +22,9 @@ import { Xterm } from './Xterm' // Terminal font-size bounds (shared by TerminalFrame's state and the ImageDropZone stepper). const TERM_FONT_MIN = 8, TERM_FONT_MAX = 40 -type Route = 'overview' | 'inbox' | 'sessions' | 'agents' | 'new-agent' | 'connectors' | 'team' | 'automations' | 'goals' | 'tasks' | 'memory' | 'insights' | 'kb' | 'skills' | 'files' | 'artifacts' | 'settings' | 'audit' | 'agent' | 'docs' | 'profile' +type Route = 'overview' | 'inbox' | 'chat' | 'sessions' | 'agents' | 'new-agent' | 'connectors' | 'team' | 'automations' | 'goals' | 'tasks' | 'memory' | 'insights' | 'kb' | 'skills' | 'files' | 'artifacts' | 'settings' | 'audit' | 'agent' | 'docs' | 'profile' // The full set of pages, used by the hash router to validate the URL on load. Keep in sync with Route. -const ROUTES: Route[] = ['overview', 'inbox', 'sessions', 'agents', 'new-agent', 'connectors', 'team', 'automations', 'goals', 'tasks', 'memory', 'insights', 'kb', 'skills', 'files', 'artifacts', 'settings', 'audit', 'agent', 'docs', 'profile'] +const ROUTES: Route[] = ['overview', 'inbox', 'chat', 'sessions', 'agents', 'new-agent', 'connectors', 'team', 'automations', 'goals', 'tasks', 'memory', 'insights', 'kb', 'skills', 'files', 'artifacts', 'settings', 'audit', 'agent', 'docs', 'profile'] type Selected = { tmux: string; title: string } | null /** Mirror of the server rule: owner approves anything, admin approves head-level only. */ @@ -1050,6 +1050,7 @@ function Console({ me }: { me: Member }) { {me.role === 'owner' && } + {pinnedNav.map((n) => ( ))} @@ -1076,6 +1077,7 @@ function Console({ me }: { me: Member }) { {me.role === 'owner' && } label="Overview" active={route === 'overview'} href={navHref('overview')} onClick={() => nav('overview')} />} } label="Inbox" active={route === 'inbox'} badge={pendingApprovals || undefined} href={navHref('inbox')} onClick={() => nav('inbox')} /> } label="Agents" active={route === 'agents' || route === 'agent'} href={navHref('agents')} onClick={() => nav('agents')} /> + } label="Chat" active={route === 'chat'} href={navHref('chat')} onClick={() => nav('chat')} /> {pinnedNav.map((n) => ( nav(n.route)} pinned onTogglePin={() => togglePin(n.key)} /> ))} @@ -1188,7 +1190,7 @@ function Console({ me }: { me: Member }) { ) : (

- {route === 'overview' ? 'Overview' : route === 'inbox' ? 'Inbox' : route === 'sessions' ? 'Sessions' : route === 'connectors' ? 'Connections' : route === 'team' ? 'Team' : route === 'automations' ? 'Automations' : route === 'goals' ? 'Goals' : route === 'tasks' ? 'Tasks' : route === 'memory' ? 'Memory' : route === 'insights' ? 'Insights' : route === 'kb' ? 'Knowledge Base' : route === 'skills' ? 'Skills' : route === 'files' ? 'Files' : route === 'artifacts' ? 'Library' : route === 'audit' ? 'Audit log' : route === 'settings' ? 'Company settings' : route === 'docs' ? 'Docs' : route === 'new-agent' ? 'New agent' : route === 'agent' ? `Agent · ${editAgent}` : route === 'profile' ? 'Profile' : 'Agents'} + {route === 'overview' ? 'Overview' : route === 'inbox' ? 'Inbox' : route === 'chat' ? 'Chat' : route === 'sessions' ? 'Sessions' : route === 'connectors' ? 'Connections' : route === 'team' ? 'Team' : route === 'automations' ? 'Automations' : route === 'goals' ? 'Goals' : route === 'tasks' ? 'Tasks' : route === 'memory' ? 'Memory' : route === 'insights' ? 'Insights' : route === 'kb' ? 'Knowledge Base' : route === 'skills' ? 'Skills' : route === 'files' ? 'Files' : route === 'artifacts' ? 'Library' : route === 'audit' ? 'Audit log' : route === 'settings' ? 'Company settings' : route === 'docs' ? 'Docs' : route === 'new-agent' ? 'New agent' : route === 'agent' ? `Agent · ${editAgent}` : route === 'profile' ? 'Profile' : 'Agents'}

)} @@ -1201,6 +1203,7 @@ function Console({ me }: { me: Member }) { {route === 'sessions' && nav('agents')} onStop={stopSession} onDelete={deleteSession} onRate={rateSession} onRename={renameSession} onBulkStop={stopSessions} onBulkDelete={deleteSessions} urlQuery={urlQuery} onFiltersChange={setUrlQuery} />} {route === 'overview' && me.role === 'owner' && } {route === 'inbox' && nav('tasks', id)} onOpenGoal={(id) => nav('goals', id)} />} + {route === 'chat' && nav('chat', id)} />} {route === 'connectors' && nav('connectors', t)} />} {route === 'team' && } {route === 'profile' && } @@ -2748,6 +2751,273 @@ function MsgHeading({ m, children }: { m: Msg; children?: ReactNode }) { ) } +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// ChatPage — a plain-language window onto a claude-code run for non-technical teammates (support/ +// sales/marketing). No terminal: message bubbles + friendly "activity" cards + inline approvals, all +// driven by the same governed session the terminal uses. Reads the transcript via /conversation and +// sends the human's turns via /reply (warm deliver, else transcript resume — the Slack-continuity path). +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +/** One friendly activity card ("Sent a Slack message" ✓). Terminal detail stays hidden by default. */ +function ActivityCard({ turn }: { turn: Extract }) { + const dot = + turn.status === 'error' ? 'bg-destructive' : turn.status === 'ok' ? 'bg-emerald-500' : 'bg-amber-400 animate-pulse' + return ( +
+ + {turn.label} + {turn.detail && {turn.detail}} +
+ ) +} + +function ChatBubble({ turn, agentIcon }: { turn: Extract; agentIcon?: string }) { + const mine = turn.kind === 'user' + return ( +
+
+ {mine ? : } +
+
+ {mine ? ( + {turn.text} + ) : ( +
{turn.text}
+ )} +
+
+ ) +} + +function ChatPage({ agents, sessions, messages, selected, onSelect }: { + agents: AgentInfo[] + sessions: Session[] + messages: Msg[] + selected: string + onSelect: (id: string) => void +}) { + // Chattable agents = claude-code runtime the member is allowed to run. Chat sessions = ones this + // surface (or the chat router) spawned, newest first. + const chatAgents = useMemo(() => agents.filter((a) => a.runtime === 'claude-code'), [agents]) + const chats = useMemo( + () => sessions.filter((s) => s.sourceKind === 'chat').sort((a, b) => b.updatedAt - a.updatedAt), + [sessions], + ) + const active = selected ? sessions.find((s) => s.id === selected) : undefined + + // New-chat composer state (shown when nothing is selected). + const [newAgent, setNewAgent] = useState('') + const [draft, setDraft] = useState('') + const [busy, setBusy] = useState(false) + const [err, setErr] = useState('') + + // Conversation timeline for the selected session (polled). + const [convo, setConvo] = useState([]) + const [found, setFound] = useState(false) + const scrollRef = useRef(null) + + useEffect(() => { + if (!selected) { setConvo([]); setFound(false); return } + let stop = false + const poll = async () => { + const r = await api.conversation(selected) + if (stop) return + if (!r.error) { setConvo(r.turns || []); setFound(!!r.found) } + } + poll() + const t = setInterval(poll, 2000) + return () => { stop = true; clearInterval(t) } + }, [selected]) + + // Keep pinned to the newest turn. + useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }) }, [convo.length, selected]) + + const pending = messages.filter((m) => m.sessionId === selected && (m.type === 'approval' || m.type === 'question') && (m.status ?? 'pending') === 'pending') + const agentOf = (id?: string) => chatAgents.find((a) => a.id === id) || agents.find((a) => a.id === id) + const activeAgent = agentOf(active?.agent) + const working = !!active && (active.alive ?? active.status === 'running') + + const startChat = async () => { + const agent = newAgent || chatAgents[0]?.id + const message = draft.trim() + if (!agent || !message || busy) return + setBusy(true); setErr('') + const r = await api.startChat(agent, message) + setBusy(false) + if (r.error || !r.id) { setErr(r.error || 'could not start the chat'); return } + setDraft('') + onSelect(r.id) + } + + const sendReply = async () => { + const message = draft.trim() + if (!message || !selected || busy) return + setBusy(true); setErr('') + // Optimistically show the human's turn immediately. + setConvo((c) => [...c, { kind: 'user', text: message, ts: Date.now() }]) + setDraft('') + const r = await api.reply(selected, message) + setBusy(false) + if (r.error) setErr(r.error) + } + + const onKey = (e: ReactKeyboardEvent, fn: () => void) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); fn() } + } + + return ( +
+ {/* Left rail: your chats + a new-chat button */} +
+ +
+ {chats.length === 0 &&

No chats yet. Start one to talk to an agent in plain language.

} + {chats.map((s) => { + const a = agentOf(s.agent) + const live = s.alive ?? s.status === 'running' + return ( + + ) + })} +
+
+ + {/* Right: either the new-chat composer or the conversation */} +
+ {!selected ? ( +
+
+

Start a conversation

+

Pick a teammate and tell them what you need. They work under the same approvals and guardrails as everyone else.

+
+
+ {chatAgents.map((a) => ( + + ))} +
+
+