From 911278f0eef38cdbf735c8a47a676e3730f68523 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 9 Jul 2026 18:19:54 +0800 Subject: [PATCH] Improve React desktop startup and UI parity --- desktop/src-tauri/src/sidecar.rs | 28 +- internal/command/web.go | 19 +- internal/web/server.go | 49 +- pnpm-lock.yaml | 8 + pnpm-workspace.yaml | 1 + web-react/index.html | 71 +- web-react/package.json | 1 + web-react/src/App.tsx | 204 ++++- web-react/src/app/store.ts | 365 +++++++- web-react/src/app/wsBridge.ts | 24 +- web-react/src/components/AuthGate.tsx | 16 +- web-react/src/components/AutomationsView.tsx | 13 +- web-react/src/components/BranchPicker.tsx | 250 ++++++ web-react/src/components/ChannelsView.tsx | 100 ++- web-react/src/components/ChatInput.tsx | 323 ++++--- web-react/src/components/ChatView.tsx | 54 +- web-react/src/components/CommandPalette.tsx | 252 ++++-- web-react/src/components/ProviderIcon.tsx | 33 + .../src/components/RemoteConnectWizard.tsx | 421 +++++++++ web-react/src/components/SettingsDialog.tsx | 460 ++++++---- web-react/src/components/SetupView.tsx | 202 ++++- web-react/src/components/Sidebar.tsx | 755 +++++++++++----- web-react/src/components/TopBar.tsx | 29 +- web-react/src/components/WorkspacePicker.tsx | 300 +++++++ web-react/src/i18n/index.ts | 402 +++++++++ web-react/src/i18n/locales/en.ts | 844 ++++++++++++++++++ web-react/src/i18n/locales/ja.ts | 765 ++++++++++++++++ web-react/src/i18n/locales/ko.ts | 765 ++++++++++++++++ web-react/src/i18n/locales/zh-Hans.ts | 825 +++++++++++++++++ web-react/src/i18n/locales/zh-Hant.ts | 766 ++++++++++++++++ web-react/src/lib/providerIcons.ts | 45 + web-react/src/lib/useDesktop.ts | 15 + web-react/src/lib/ws.ts | 1 + web-react/src/main.tsx | 5 + web-react/src/styles.css | 161 ++++ web/index.html | 71 +- 36 files changed, 7918 insertions(+), 725 deletions(-) create mode 100644 web-react/src/components/BranchPicker.tsx create mode 100644 web-react/src/components/ProviderIcon.tsx create mode 100644 web-react/src/components/RemoteConnectWizard.tsx create mode 100644 web-react/src/components/WorkspacePicker.tsx create mode 100644 web-react/src/i18n/index.ts create mode 100644 web-react/src/i18n/locales/en.ts create mode 100644 web-react/src/i18n/locales/ja.ts create mode 100644 web-react/src/i18n/locales/ko.ts create mode 100644 web-react/src/i18n/locales/zh-Hans.ts create mode 100644 web-react/src/i18n/locales/zh-Hant.ts create mode 100644 web-react/src/lib/providerIcons.ts diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs index f8dc64d1..5fd2bcf3 100644 --- a/desktop/src-tauri/src/sidecar.rs +++ b/desktop/src-tauri/src/sidecar.rs @@ -6,7 +6,7 @@ //! spawns `jcode web --headless` on it, waits until /api/health answers, stores //! the port in the managed `SidecarPort` state (so the frontend can resolve an //! absolute `http://127.0.0.1:` API base via the `get_sidecar_port` IPC -//! command), then reveals the window. +//! command), then keeps health-polling so startup failures can be surfaced. use std::collections::VecDeque; use std::io::Write as _; @@ -159,6 +159,15 @@ pub fn start(app: &AppHandle) -> Result<(), Box> { } } + // Show the frontend shell as soon as the sidecar has been spawned. The page + // itself waits for /api/health before issuing API calls, but keeping the + // native window hidden until then makes slow sidecar boot look like a blank + // or stuck app. + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + // `ready` flips true once the sidecar's /api/health answers. Until then, the // sidecar exiting is a fatal *startup* failure that we surface to the user — // previously such a crash left the splash spinning forever, which is exactly @@ -218,7 +227,7 @@ pub fn start(app: &AppHandle) -> Result<(), Box> { } }); - // Health-poll the port on a background thread, then reveal the window. We + // Health-poll the port on a background thread. We // verify the /api/health response (not just a bare TCP connect) so that if // another process grabbed the port in the moment between pick_free_port and // the sidecar binding it, the frontend won't be pointed at a foreign server. @@ -237,7 +246,6 @@ pub fn start(app: &AppHandle) -> Result<(), Box> { if health_ok(&addr, port) { poll_ready.store(true, Ordering::SeqCst); if let Some(w) = app.get_webview_window("main") { - let _ = w.show(); let _ = w.set_focus(); } return; @@ -305,9 +313,8 @@ fn health_ok(addr: &SocketAddr, port: u16) -> bool { let _ = stream.set_read_timeout(Some(Duration::from_millis(600))); let _ = stream.set_write_timeout(Some(Duration::from_millis(600))); - let req = format!( - "GET /api/health HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" - ); + let req = + format!("GET /api/health HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"); if stream.write_all(req.as_bytes()).is_err() { return false; } @@ -328,5 +335,12 @@ fn health_ok(addr: &SocketAddr, port: u16) -> bool { } let resp = String::from_utf8_lossy(&buf); - resp.starts_with("HTTP/1.") && resp.contains(" 200 ") && resp.contains("\"status\"") + let status_ok = resp + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .map(|code| code == "200") + .unwrap_or(false); + + status_ok && resp.contains("\"status\"") } diff --git a/internal/command/web.go b/internal/command/web.go index 3cc44aba..09c7e647 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -255,16 +255,12 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err registry := internalmodel.NewModelRegistryWithConfig(cfg) - // Load MCP tools. mcpToolsPtr is swapped atomically by reloadMCPTools so a new - // task (built concurrently by buildWebTask) always reads a consistent slice - // header without a data race on hot-reload. + // MCP tools are loaded asynchronously after the web server starts listening. + // A slow remote MCP server must not block /api/health and make desktop launch + // look hung. mcpToolsPtr is swapped atomically by reloadMCPTools so a new task + // (built concurrently by buildWebTask) always reads a consistent slice header + // without a data race on hot-reload. var mcpToolsPtr atomic.Pointer[[]tool.BaseTool] - var initialMCPStatuses []tools.MCPStatus - if len(cfg.MCPServers) > 0 { - mt, statuses := tools.LoadMCPTools(ctx, cfg.MCPServers) - mcpToolsPtr.Store(&mt) - initialMCPStatuses = statuses - } reloadMCPTools := func(servers map[string]*config.MCPServer) ([]tools.MCPStatus, error) { nt, statuses := tools.LoadMCPTools(ctx, servers) mcpToolsPtr.Store(&nt) @@ -780,7 +776,6 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err SkillLoader: skillLoader, FlowLoader: flowLoader, ReloadMCP: reloadMCPTools, - InitialMCPStatuses: initialMCPStatuses, WechatClient: wechatClient, WebHandler: bootEC.Handler, EventHandler: bootEC.EventHandler, @@ -803,6 +798,10 @@ func runWebServer(port int, host string, openBrowser bool, authToken string) err go sched.Run(ctx) } + if len(cfg.MCPServers) > 0 { + srv.ReloadMCPInBackground() + } + // Set up inbound WeChat message handler now that srv exists. Always register // regardless of WebEnabled — the user can enable via the UI. Inbound messages // target the active task (no task_id channel). diff --git a/internal/web/server.go b/internal/web/server.go index 3888e209..29ea805f 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -2009,12 +2009,59 @@ func serverFromReq(req *mcpServerReq) (*config.MCPServer, error) { return srv, nil } +func cloneMCPServers(in map[string]*config.MCPServer) map[string]*config.MCPServer { + if len(in) == 0 { + return nil + } + out := make(map[string]*config.MCPServer, len(in)) + for name, srv := range in { + if srv == nil { + out[name] = nil + continue + } + cp := *srv + cp.Args = append([]string(nil), srv.Args...) + cp.Env = append([]string(nil), srv.Env...) + if srv.Headers != nil { + cp.Headers = make(map[string]string, len(srv.Headers)) + for k, v := range srv.Headers { + cp.Headers[k] = v + } + } + if srv.OAuth != nil { + oa := *srv.OAuth + oa.Scopes = append([]string(nil), srv.OAuth.Scopes...) + cp.OAuth = &oa + } + out[name] = &cp + } + return out +} + +// ReloadMCPInBackground connects configured MCP servers without blocking web +// startup. Slow or unreachable MCP servers should update settings/tool state +// when they finish, never delay /api/health or the desktop window. +func (s *Server) ReloadMCPInBackground() { + if s.reloadMCP == nil { + return + } + go func() { + config.Logger().Printf("[web] loading MCP tools in background") + if err := s.reloadMCPAndRebuild(); err != nil { + config.Logger().Printf("[web] background MCP reload failed: %v", err) + } else { + config.Logger().Printf("[web] background MCP reload finished") + } + s.wsBroker.Broadcast(WSEvent{Type: "mcp_changed", Data: map[string]string{"source": "startup"}}) + }() +} + // reloadMCPAndRebuild reconnects MCP servers from the current config and // rebuilds the live agent so new tools take effect without a restart. func (s *Server) reloadMCPAndRebuild() error { if s.reloadMCP != nil { s.mu.RLock() - servers := s.cfg.MCPServers + servers := cloneMCPServers(s.cfg.MCPServers) s.mu.RUnlock() statuses, err := s.reloadMCP(servers) if err != nil { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 864e87e1..99c25065 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@18.3.1) + '@lobehub/icons-static-svg': + specifier: ^1.91.0 + version: 1.91.0 '@reduxjs/toolkit': specifier: ^2.8.2 version: 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@18.3.1)(redux@5.0.1))(react@18.3.1) @@ -431,6 +434,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lobehub/icons-static-svg@1.91.0': + resolution: {integrity: sha512-ZDflEq0uUvAkH4WK4h3qNvvY09ts4OqUb5azD7A0xKfcuYhffGwB1Q/As2RguZYq4Gh4v925CJ8iodiClzc4zw==} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -1609,6 +1615,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lobehub/icons-static-svg@1.91.0': {} + '@parcel/watcher-android-arm64@2.5.1': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a0538404..0e492fee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,7 @@ packages: - 'packages/*' - 'web-react' allowBuilds: + '@parcel/watcher': true esbuild: true # Native build scripts to allow. esbuild (Vite's bundler) and @parcel/watcher # (dev-server file watching) are trusted toolchain deps — whitelisting them diff --git a/web-react/index.html b/web-react/index.html index ee24355a..d5c9380a 100644 --- a/web-react/index.html +++ b/web-react/index.html @@ -5,6 +5,67 @@ jcode + -
+
+
+
+ +
Starting local server...
+ +
+
+
diff --git a/web-react/package.json b/web-react/package.json index 07058b0d..8d7c55c2 100644 --- a/web-react/package.json +++ b/web-react/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@heroicons/react": "^2.2.0", + "@lobehub/icons-static-svg": "^1.91.0", "@reduxjs/toolkit": "^2.8.2", "@tauri-apps/api": "^2.9.0", "@tauri-apps/plugin-dialog": "^2.0.0", diff --git a/web-react/src/App.tsx b/web-react/src/App.tsx index 09a8754d..309eca7d 100644 --- a/web-react/src/App.tsx +++ b/web-react/src/App.tsx @@ -12,6 +12,13 @@ */ import { useCallback, useEffect, useRef, useState } from 'react' +import { + ArrowLeftIcon, + CheckCircleIcon, + ExclamationCircleIcon, + PlayIcon, + StopIcon, +} from '@heroicons/react/24/outline' import { RuntimeProvider, ToolRegistryProvider, @@ -25,13 +32,14 @@ import { modelActions, sessionActions, uiActions, - loadSessions, - loadTasks, - loadSlashCommands, + chatActions, loadSession, + loadWorkspaceState, + replaySession, } from './app/store' import { bridgeWS } from './app/wsBridge' import { useChatRuntime } from './app/runtime' +import type { AutomationRun } from './lib/automation' import { Sidebar } from './components/Sidebar' import { ChatView } from './components/ChatView' import { AutomationsView } from './components/AutomationsView' @@ -43,6 +51,7 @@ import { SettingsDialog } from './components/SettingsDialog' import { TopBar } from './components/TopBar' import { RightPanel } from './components/RightPanel' import { TerminalPanel } from './components/TerminalPanel' +import { RemoteConnectWizard } from './components/RemoteConnectWizard' export default function App() { const dispatch = useAppDispatch() @@ -65,20 +74,15 @@ export default function App() { dispatch(modelActions.setServerVersion(h.version)) dispatch(modelActions.setImageSupport(!!h.image_support)) dispatch(sessionActions.setProjectPath(h.pwd)) + dispatch(sessionActions.setCurrentSession(h.session_id || '')) + dispatch(chatActions.setRunning(!!h.running)) if (h.auth_required) dispatch(uiActions.setNeedsAuth(true)) if (h.needs_setup) dispatch(uiActions.setNeedsSetup(true)) - // Load the current session's history into the timeline (replay). The - // boot session_id may be a fresh empty session (no JSONL yet, 404) — in - // that case fall back to the most recent listed session. loadSession - // swallows the 404 internally and returns without setting a timeline, - // so we detect an empty timeline and retry with the most recent session. - if (!h.needs_setup) { - await dispatch(loadSession(h.session_id)) - const state = store_getState() - if (state.chat.timeline.length === 0) { - const sessions = await api.sessions() - if (sessions.length > 0) await dispatch(loadSession(sessions[0].uuid)) - } + // Load the workspace state, then restore the current session only if it + // has persisted history. A fresh empty session should stay on welcome. + if (!h.auth_required && !h.needs_setup) { + await dispatch(loadWorkspaceState()) + if (h.session_id) await dispatch(loadSession(h.session_id)) } } catch (err) { if (!cancelled) { @@ -110,13 +114,6 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch]) - // Load sidebar data + slash commands after boot. - useEffect(() => { - void dispatch(loadSessions()) - void dispatch(loadTasks()) - void dispatch(loadSlashCommands()) - }, [dispatch]) - // Global keyboard shortcuts: ⌘K (command palette), ⌘N (new chat), Esc (close // overlays). Mirrors the Vue App.vue shortcut wiring. useEffect(() => { @@ -165,6 +162,7 @@ function store_getState() { type PanelType = 'terminal' | 'files' | 'changes' | 'plan' function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'channels' | 'automation-run' }) { + const dispatch = useAppDispatch() const runtime = useChatRuntime() const registry = useRef(createDefaultToolRegistry()).current const paletteOpen = useAppSelector((s) => s.ui.paletteOpen) @@ -177,6 +175,19 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'channels' const [rightPanelTab, setRightPanelTab] = useState<'files' | 'changes' | 'plan'>('files') const [bottomPanel, setBottomPanel] = useState<'none' | 'terminal'>('none') const [bottomPanelHeight, setBottomPanelHeight] = useState(260) + const [activeRun, setActiveRun] = useState(null) + const [remoteWizardOpen, setRemoteWizardOpen] = useState(false) + + const openRun = useCallback((run: AutomationRun) => { + setActiveRun(run) + dispatch(uiActions.setView('automation-run')) + void dispatch(replaySession(run.session_id)) + }, [dispatch]) + + const closeRun = useCallback(() => { + setActiveRun(null) + dispatch(uiActions.setView('automations')) + }, [dispatch]) const togglePanel = useCallback((panel: PanelType) => { if (panel === 'terminal') { @@ -211,6 +222,14 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'channels' return () => window.removeEventListener('keydown', onKey) }, [togglePanel]) + useEffect(() => { + function onOpenRemote() { + setRemoteWizardOpen(true) + } + window.addEventListener('jcode:open-remote-connect', onOpenRemote) + return () => window.removeEventListener('jcode:open-remote-connect', onOpenRemote) + }, []) + // Bottom-panel resize handle. const startResize = useCallback((e: React.MouseEvent) => { e.preventDefault() @@ -245,45 +264,134 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'channels' /> )} - +
+ -
- {activeView === 'chat' && } - {activeView === 'automations' && } - {activeView === 'channels' && } - {activeView === 'automation-run' && } +
+ {activeView === 'chat' && } + {activeView === 'automations' && } + {activeView === 'channels' && } + {activeView === 'automation-run' && ( + + )} - {/* Bottom panel (terminal) */} - {bottomPanel === 'terminal' && ( -
-
-
+ {/* Bottom panel (terminal) */} + {bottomPanel === 'terminal' && ( +
+
+
+
+ setBottomPanel('none')} />
- setBottomPanel('none')} /> -
- )} -
+ )} +
- {/* Right panel (files/changes/plan) */} - {rightPanelOpen && ( - setRightPanelOpen(false)} - onSwitchTab={setRightPanelTab} - /> - )} + {/* Right panel (files/changes/plan) */} + {rightPanelOpen && ( + setRightPanelOpen(false)} + onSwitchTab={setRightPanelTab} + /> + )} +
{paletteOpen && } + setRemoteWizardOpen(false)} /> ) } +function AutomationRunReplay({ run, onBack }: { run: AutomationRun | null; onBack: () => void }) { + const isRunning = run ? (run.terminal_status || run.status) === 'running' || (!run.terminal_status && run.status === 'running') : false + const status = run ? statusKind(run) : 'running' + const StatusIcon = status === 'success' ? CheckCircleIcon : status === 'error' ? ExclamationCircleIcon : PlayIcon + const statusLabel = status === 'success' ? 'Completed' : status === 'error' ? 'Failed' : 'Running' + + async function stopRun() { + if (!run) return + await api.stop(run.session_id).catch(() => {}) + } + + return ( +
+
+ +
+

+ {run?.title || 'Automation run'} +

+ + + {statusLabel} + + {isRunning && ( + + )} +
+ {run && ( +
+ trigger + {run.trigger_kind} + {run.project && ( + <> + · + project + {run.project} + + )} + {run.start_time && ( + <> + · + {new Date(run.start_time).toLocaleString()} + + )} +
+ )} + {run?.error_reason && status === 'error' && ( +
+ {run.error_reason} +
+ )} +
+ +
+ ) +} + +function statusKind(run: AutomationRun): 'success' | 'error' | 'running' { + const s = run.terminal_status || run.status + if (s === 'success') return 'success' + if (s === 'error') return 'error' + return 'running' +} + +function statusClass(status: 'success' | 'error' | 'running'): string { + if (status === 'success') return 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' + if (status === 'error') return 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' + return 'bg-[var(--accent-wash)] text-[var(--color-primary)]' +} + function ErrorScreen({ message }: { message: string }) { return (
diff --git a/web-react/src/app/store.ts b/web-react/src/app/store.ts index d3d2bd07..a4b7add0 100644 --- a/web-react/src/app/store.ts +++ b/web-react/src/app/store.ts @@ -16,7 +16,7 @@ import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit' import type { ThreadItem, Message, ToolCall, Approval, TokenSnapshot, Goal, TodoItem, QueuedMessage, AskUserQuestion } from 'jcode-ui-core' import { api } from '../lib/api' import { extractToolDisplayInfo } from '../lib/toolInfo' -import type { AgentMode, ProviderInfo, SessionItem, TaskItem, SlashCommandInfo, SessionEntry } from '../lib/types' +import { normalizeMode, type AgentMode, type ProviderInfo, type SessionItem, type TaskItem, type SlashCommandInfo, type SessionEntry, type ModelRef } from '../lib/types' // ─── seq counter (stable DOM identity across streaming updates) ─── let _seq = 0 @@ -34,6 +34,7 @@ interface ChatState { isRunning: boolean tokenSnapshot: TokenSnapshot | null goal: Goal | null + goalArmed: boolean todos: TodoItem[] queued: QueuedMessage[] slashCommands: SlashCommandInfo[] @@ -44,6 +45,7 @@ const initialChat: ChatState = { isRunning: false, tokenSnapshot: null, goal: null, + goalArmed: false, todos: [], queued: [], slashCommands: [], @@ -62,6 +64,7 @@ const chatSlice = createSlice({ s.isRunning = false s.tokenSnapshot = null s.goal = null + s.goalArmed = false s.todos = [] s.queued = [] streamingText = '' @@ -142,6 +145,9 @@ const chatSlice = createSlice({ setGoal(s, a: { payload: Goal | null }) { s.goal = a.payload }, + setGoalArmed(s, a: { payload: boolean }) { + s.goalArmed = a.payload + }, setTodos(s, a: { payload: TodoItem[] }) { s.todos = a.payload }, @@ -187,19 +193,69 @@ const chatSlice = createSlice({ setTimeline(s, a: { payload: ThreadItem[] }) { s.timeline = a.payload }, + truncateTimelineFrom(s, a: { payload: string }) { + const idx = s.timeline.findIndex((i) => i.kind === 'message' && i.data.id === a.payload) + if (idx >= 0) s.timeline = s.timeline.slice(0, idx) + streamingText = '' + streamingMsgId = '' + }, addApprovalRequest(s, a: { payload: Approval }) { s.timeline.push({ kind: 'approval', data: a.payload, seq: nextSeq() }) }, - attachAskUser(s, a: { payload: { toolName: string; askUserId: string; questions: AskUserQuestion[] } }) { + attachAskUser(s, a: { payload: { toolName: string; askUserId: string; questions: AskUserQuestion[]; taskId?: string } }) { // Arm the matching tool with ask_user state (the tool was added by tool_call). for (let i = s.timeline.length - 1; i >= 0; i--) { const item = s.timeline[i] - if (item.kind === 'tool' && item.data.name === a.payload.toolName && item.data.status === 'running' && !item.data.askUserId) { + if (item.kind !== 'tool' || item.data.name !== a.payload.toolName) continue + if (item.data.askUserId === a.payload.askUserId) return + if (!item.data.askUserId && (item.data.status === 'running' || (item.data.status === 'done' && !item.data.output))) { + item.data.status = 'running' item.data.askUserId = a.payload.askUserId item.data.askUserQuestions = a.payload.questions - break + ;(item.data as ToolCall & { askUserTaskId?: string }).askUserTaskId = a.payload.taskId + return } } + const args = JSON.stringify({ questions: a.payload.questions }) + const tc: ToolCall = { + id: genId('ask'), + name: a.payload.toolName, + args, + status: 'running', + timestamp: Date.now(), + displayInfo: extractToolDisplayInfo(a.payload.toolName, args), + askUserId: a.payload.askUserId, + askUserQuestions: a.payload.questions, + } + ;(tc as ToolCall & { askUserTaskId?: string }).askUserTaskId = a.payload.taskId + s.timeline.push({ kind: 'tool', data: tc, seq: nextSeq() }) + }, + addSubagentProgress(s, a: { payload: { event: string; toolName: string; detail: string } }) { + for (let i = s.timeline.length - 1; i >= 0; i--) { + const item = s.timeline[i] + if (item.kind !== 'tool' || item.data.name !== 'subagent' || item.data.status !== 'running') continue + item.data.children ??= [] + if (a.payload.event === 'tool_call') { + item.data.children.push({ + id: genId('sub_tc'), + name: a.payload.toolName, + args: a.payload.detail, + status: 'running', + timestamp: Date.now(), + displayInfo: extractToolDisplayInfo(a.payload.toolName, a.payload.detail), + }) + } else if (a.payload.event === 'tool_result') { + for (let j = item.data.children.length - 1; j >= 0; j--) { + const child = item.data.children[j] + if (child.name === a.payload.toolName && child.status === 'running') { + child.output = a.payload.detail + child.status = 'done' + break + } + } + } + break + } }, setApprovalResolving(s, a: { payload: { id: string; resolving: boolean } }) { const item = s.timeline.find((i) => i.kind === 'approval' && i.data.id === a.payload.id) @@ -272,10 +328,12 @@ interface ModelState { mode: AgentMode providers: ProviderInfo[] favoriteModels: string[] - recentModels: { provider: string; model: string }[] + recentModels: ModelRef[] + effortOverrides: Record autoApprove: boolean imageSupport: boolean serverVersion: string + maxIterations: number } const initialModel: ModelState = { @@ -285,9 +343,11 @@ const initialModel: ModelState = { providers: [], favoriteModels: [], recentModels: [], + effortOverrides: {}, autoApprove: false, imageSupport: false, serverVersion: '', + maxIterations: 0, } const modelSlice = createSlice({ @@ -306,6 +366,24 @@ const modelSlice = createSlice({ setProviders(s, a: { payload: ProviderInfo[] }) { s.providers = a.payload }, + setModelState(s, a: { payload: { recent: ModelRef[]; favorite: ModelRef[]; effortOverrides?: Record } }) { + s.recentModels = a.payload.recent + s.favoriteModels = a.payload.favorite.map((r) => `${r.provider}/${r.model}`) + s.effortOverrides = a.payload.effortOverrides ?? {} + }, + setFavorite(s, a: { payload: { provider: string; model: string; favorite: boolean } }) { + const key = `${a.payload.provider}/${a.payload.model}` + if (a.payload.favorite) { + if (!s.favoriteModels.includes(key)) s.favoriteModels.push(key) + } else { + s.favoriteModels = s.favoriteModels.filter((x) => x !== key) + } + }, + setEffortOverride(s, a: { payload: { provider: string; model: string; effort: string } }) { + const key = `${a.payload.provider}/${a.payload.model}` + if (a.payload.effort) s.effortOverrides[key] = a.payload.effort + else delete s.effortOverrides[key] + }, setAutoApprove(s, a: { payload: boolean }) { s.autoApprove = a.payload }, @@ -315,6 +393,9 @@ const modelSlice = createSlice({ setServerVersion(s, a: { payload: string }) { s.serverVersion = a.payload }, + setMaxIterations(s, a: { payload: number }) { + s.maxIterations = a.payload + }, }, }) @@ -332,6 +413,10 @@ interface UiState { needsSetup: boolean connectionError: string theme: string + channelAvailable: boolean + channelEnabled: boolean + bleAvailable: boolean + bleEnabled: boolean } const initialUi: UiState = { @@ -342,6 +427,10 @@ const initialUi: UiState = { needsSetup: false, connectionError: '', theme: 'system', + channelAvailable: false, + channelEnabled: false, + bleAvailable: false, + bleEnabled: false, } const uiSlice = createSlice({ @@ -369,6 +458,14 @@ const uiSlice = createSlice({ setTheme(s, a: { payload: string }) { s.theme = a.payload }, + setChannelState(s, a: { payload: { available: boolean; enabled: boolean } }) { + s.channelAvailable = a.payload.available + s.channelEnabled = a.payload.enabled + }, + setBLEState(s, a: { payload: { available: boolean; enabled: boolean } }) { + s.bleAvailable = a.payload.available + s.bleEnabled = a.payload.enabled + }, }, }) @@ -387,12 +484,37 @@ export const sendMessage = createAsyncThunk( async (payload: { text: string; images?: import('jcode-ui-core').ChatImage[]; mode?: AgentMode }, { dispatch, getState }) => { const state = getState() as RootState const sessionId = state.session.currentSessionId || undefined + const trimmed = payload.text.trim() + if (state.chat.goalArmed && trimmed && !trimmed.startsWith('/')) { + dispatch(chatActions.setGoalArmed(false)) + dispatch(chatActions.addMessage({ role: 'user', content: payload.text })) + const goal = await api.setGoal(trimmed, true) + dispatch(chatActions.setGoal(goal)) + dispatch(chatActions.setRunning(true)) + return + } // /goal slash interception (matches Vue store.sendMessage). - if (payload.text.startsWith('/goal ')) { - const objective = payload.text.slice(6).trim() + if (trimmed === '/goal' || trimmed.startsWith('/goal ')) { + const objective = trimmed.slice('/goal'.length).trim() + if (objective === '' || objective === 'status') { + const goal = await api.goal() + dispatch(chatActions.setGoal(goal)) + dispatch(chatActions.addMessage({ + role: 'system', + content: goal ? `Goal is ${goal.status}: ${goal.objective}` : 'No active goal.', + })) + return + } + if (objective === 'clear') { + await api.clearGoal() + dispatch(chatActions.setGoal(null)) + dispatch(chatActions.addMessage({ role: 'system', content: 'Goal cleared.' })) + return + } dispatch(chatActions.addMessage({ role: 'user', content: payload.text })) - const goal = await api.setGoal(objective) + const goal = await api.setGoal(objective, true) dispatch(chatActions.setGoal(goal)) + dispatch(chatActions.setRunning(true)) return } dispatch(chatActions.addMessage({ role: 'user', content: payload.text, images: payload.images })) @@ -409,10 +531,13 @@ export const stopAgent = createAsyncThunk('chat/stop', async (_, { getState }) = export const resolveApproval = createAsyncThunk( 'approval/resolve', - async (payload: { id: string; approved: boolean; approveAll?: boolean }, { dispatch }) => { + async (payload: { id: string; approved: boolean; approveAll?: boolean }, { dispatch, getState }) => { dispatch(chatActions.setApprovalResolving({ id: payload.id, resolving: true })) + const state = getState() as RootState + const item = state.chat.timeline.find((i) => i.kind === 'approval' && i.data.id === payload.id) + const taskId = item?.kind === 'approval' ? (item.data as Approval & { task_id?: string }).task_id : undefined try { - await api.approval(payload.id, payload.approved, payload.approveAll ?? false) + await api.approval(payload.id, payload.approved, payload.approveAll ?? false, taskId) dispatch(chatActions.resolveApprovalItem({ id: payload.id, approved: payload.approved })) } catch { dispatch(chatActions.setApprovalResolving({ id: payload.id, resolving: false })) @@ -422,9 +547,17 @@ export const resolveApproval = createAsyncThunk( export const submitAskUser = createAsyncThunk( 'askUser/submit', - async (payload: { id: string; answers: import('jcode-ui-core').AskUserAnswer[] }, { dispatch }) => { + async (payload: { id: string; answers: import('jcode-ui-core').AskUserAnswer[] }, { dispatch, getState }) => { + const state = getState() as RootState + let taskId: string | undefined + for (const item of state.chat.timeline) { + if (item.kind === 'tool' && item.data.askUserId === payload.id) { + taskId = (item.data as ToolCall & { askUserTaskId?: string }).askUserTaskId + break + } + } try { - await api.askUser(payload.id, payload.answers) + await api.askUser(payload.id, payload.answers, taskId) } catch { // surface in timeline as a system message dispatch(chatActions.addMessage({ role: 'system', content: 'Failed to submit answer', level: 'error' })) @@ -434,9 +567,26 @@ export const submitAskUser = createAsyncThunk( export const editMessage = createAsyncThunk( 'chat/edit', - async (payload: { id: string; text: string }, { dispatch }) => { - // Trim the timeline up to (and including) the edited message, then resend. - dispatch(chatActions.clearChat()) + async (payload: { id: string; text: string }, { dispatch, getState }) => { + const state = getState() as RootState + const msgIdx = state.chat.timeline.findIndex((i) => i.kind === 'message' && i.data.id === payload.id && i.data.role === 'user') + if (msgIdx < 0) return + const beforeUserMessage = state.chat.timeline + .slice(0, msgIdx) + .filter((i) => i.kind === 'message' && i.data.role === 'user') + .length + try { + const res = await api.truncateHistory(beforeUserMessage) + if (res.session_id) dispatch(sessionActions.setCurrentSession(res.session_id)) + } catch (e) { + dispatch(chatActions.addMessage({ + role: 'system', + content: e instanceof Error ? e.message : 'Failed to truncate history', + level: 'error', + })) + return + } + dispatch(chatActions.truncateTimelineFrom(payload.id)) await dispatch(sendMessage({ text: payload.text })) }, ) @@ -456,6 +606,123 @@ export const loadSlashCommands = createAsyncThunk('chat/loadSlash', async (_, { dispatch(chatActions.setSlashCommands(cmds)) }) +export const loadModels = createAsyncThunk('model/loadModels', async (_, { dispatch }) => { + const data = await api.models() + dispatch(modelActions.setProviders(data.providers || [])) + dispatch(modelActions.setProvider(data.current.provider)) + dispatch(modelActions.setModel(data.current.model)) + const provider = data.providers.find((p) => p.id === data.current.provider) + const model = provider?.models.find((m) => m.id === data.current.model) + dispatch(modelActions.setImageSupport(!!model?.image_support)) +}) + +export const loadModelState = createAsyncThunk('model/loadState', async (_, { dispatch }) => { + const data = await api.modelState() + dispatch(modelActions.setModelState({ + recent: data.recent || [], + favorite: data.favorite || [], + effortOverrides: data.effort_overrides || {}, + })) +}) + +export const loadApprovalMode = createAsyncThunk('model/loadApprovalMode', async (_, { dispatch, getState }) => { + const data = await api.approvalMode() + dispatch(modelActions.setAutoApprove(data.auto_approve)) + const state = getState() as RootState + if (data.auto_approve) dispatch(modelActions.setMode('full_access')) + else if (state.model.mode === 'full_access') dispatch(modelActions.setMode('approval')) +}) + +export const loadChannelState = createAsyncThunk('ui/loadChannelState', async (_, { dispatch }) => { + const data = await api.channelStatus() + dispatch(uiActions.setChannelState({ available: data.available, enabled: data.state === 'enabled' })) +}) + +export const loadBLEState = createAsyncThunk('ui/loadBLEState', async (_, { dispatch }) => { + const data = await api.channelBLEStatus() + dispatch(uiActions.setBLEState({ available: data.available, enabled: data.enabled })) +}) + +export const loadConfig = createAsyncThunk('model/loadConfig', async (_, { dispatch }) => { + const cfg = await api.config() + dispatch(modelActions.setMaxIterations(cfg.max_iterations)) +}) + +export const loadStatus = createAsyncThunk('app/loadStatus', async (_, { dispatch }) => { + const status = await api.status() + dispatch(chatActions.setRunning(!!status.running)) + dispatch(sessionActions.setProjectPath(status.pwd)) + dispatch(modelActions.setProvider(status.provider)) + dispatch(modelActions.setModel(status.model)) + dispatch(modelActions.setMode(normalizeMode(status.mode))) + if (status.token) dispatch(chatActions.setTokenSnapshot(status.token)) +}) + +export const loadGoal = createAsyncThunk('chat/loadGoal', async (_, { dispatch }) => { + const goal = await api.goal() + dispatch(chatActions.setGoal(goal)) +}) + +export const loadTodos = createAsyncThunk('chat/loadTodos', async (_, { dispatch }) => { + const todos = await api.todos() + dispatch(chatActions.setTodos(todos)) +}) + +export const reconcilePendingInteractions = createAsyncThunk('chat/reconcilePending', async (_, { dispatch, getState }) => { + const [askResult, approvalResult] = await Promise.allSettled([ + api.askPending(), + api.approvalPending(), + ]) + if (askResult.status === 'fulfilled') { + for (const req of askResult.value) { + dispatch(chatActions.attachAskUser({ + toolName: 'ask_user', + askUserId: req.id, + questions: req.questions, + taskId: req.task_id, + })) + } + } + if (approvalResult.status === 'fulfilled') { + const state = getState() as RootState + const existing = new Set( + state.chat.timeline + .filter((i) => i.kind === 'approval') + .map((i) => i.kind === 'approval' ? i.data.id : ''), + ) + for (const req of approvalResult.value) { + if (existing.has(req.id)) continue + const approval: Approval = { + id: req.id, + tool_name: req.tool_name, + tool_args: req.tool_args, + is_external: req.is_external, + } + ;(approval as Approval & { task_id?: string }).task_id = req.task_id + dispatch(chatActions.addApprovalRequest(approval)) + existing.add(req.id) + } + } +}) + +export const loadWorkspaceState = createAsyncThunk('app/loadWorkspaceState', async (_, { dispatch }) => { + await Promise.allSettled([ + dispatch(loadStatus()), + dispatch(loadConfig()), + dispatch(loadModels()), + dispatch(loadModelState()), + dispatch(loadSessions()), + dispatch(loadTasks()), + dispatch(loadSlashCommands()), + dispatch(loadApprovalMode()), + dispatch(loadChannelState()), + dispatch(loadBLEState()), + dispatch(loadGoal()), + dispatch(loadTodos()), + dispatch(reconcilePendingInteractions()), + ]) +}) + /** * Load (replay) a session's history into the timeline. Ported from the Vue * store's loadSession: fetches the JSONL entries, tells the backend to resume @@ -536,6 +803,7 @@ export const loadSession = createAsyncThunk( const resumedId = resp.session_id || uuid const running = !!state.session.tasks.find((t) => t.uuid === resumedId)?.running dispatch(chatActions.setRunning(running)) + await dispatch(reconcilePendingInteractions()) // Refresh goal + todos (the backend restored them; no WS push on switch). try { @@ -553,6 +821,73 @@ export const loadSession = createAsyncThunk( }, ) +export const replaySession = createAsyncThunk( + 'session/replay', + async (uuid: string, { dispatch }) => { + let entries: SessionEntry[] + try { + entries = await api.session(uuid) + } catch (e) { + dispatch(chatActions.clearChat()) + dispatch(chatActions.addMessage({ + role: 'system', + content: e instanceof Error ? e.message : 'Failed to load session replay', + level: 'error', + })) + return + } + + dispatch(chatActions.clearChat()) + const timeline: ThreadItem[] = [] + const pendingToolCalls = new Map() + for (const e of entries) { + if (e.type === 'user' && e.content) { + timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('msg'), role: 'user', content: e.content, timestamp: ts(e.timestamp) } }) + } else if (e.type === 'assistant' && e.content) { + timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('asst'), role: 'assistant', content: e.content, timestamp: ts(e.timestamp) } }) + } else if (e.type === 'tool_call' && e.name) { + const tc: ToolCall = { + id: genId('tc'), + toolCallID: e.tool_call_id, + name: e.name, + args: e.args || '', + status: 'running', + timestamp: ts(e.timestamp), + displayInfo: extractToolDisplayInfo(e.name, e.args || ''), + } + timeline.push({ kind: 'tool', seq: nextSeq(), data: tc }) + if (e.tool_call_id) pendingToolCalls.set(e.tool_call_id, tc) + } else if (e.type === 'tool_result') { + let resolved = false + if (e.tool_call_id) { + const tc = pendingToolCalls.get(e.tool_call_id) + if (tc) { + tc.output = e.output || '' + tc.error = e.error || '' + tc.status = e.error ? 'error' : 'done' + pendingToolCalls.delete(e.tool_call_id) + resolved = true + } + } + if (!resolved && e.name) { + for (let i = timeline.length - 1; i >= 0; i--) { + const item = timeline[i] + if (item.kind === 'tool' && item.data.name === e.name && item.data.status === 'running') { + item.data.output = e.output || '' + item.data.error = e.error || '' + item.data.status = e.error ? 'error' : 'done' + break + } + } + } + } + } + for (const tc of pendingToolCalls.values()) tc.status = 'done' + dispatch(chatActions.setTimeline(timeline)) + dispatch(chatActions.setRunning(false)) + }, +) + function ts(t?: string): number { return t ? new Date(t).getTime() : Date.now() } diff --git a/web-react/src/app/wsBridge.ts b/web-react/src/app/wsBridge.ts index 78b6b686..327dd010 100644 --- a/web-react/src/app/wsBridge.ts +++ b/web-react/src/app/wsBridge.ts @@ -16,7 +16,8 @@ import { sendMessage, } from './store' import { api } from '../lib/api' -import type { Goal } from 'jcode-ui-core' +import type { Approval, Goal } from 'jcode-ui-core' +import { normalizeMode } from '../lib/types' /** Create the handler set for a given store getter + dispatch. The handlers read * fresh state (active task id) so they don't capture stale closures. */ @@ -69,7 +70,8 @@ export function createWSHandlers( tool_name: d.tool_name, tool_args: d.tool_args, is_external: d.is_external, - }), + task_id: d.task_id, + } as Approval & { task_id?: string }), ), onAskUserRequest: (d) => dispatch( @@ -77,13 +79,29 @@ export function createWSHandlers( toolName: 'ask_user', askUserId: d.id, questions: d.questions, + taskId: d.task_id, }), ), onModelChanged: (d) => { dispatch(modelActions.setProvider(d.provider)) dispatch(modelActions.setModel(d.model)) }, - onModeChanged: (d) => dispatch(modelActions.setMode(d as never)), + onModeChanged: (d) => { + const mode = normalizeMode(d.mode) + dispatch(modelActions.setMode(mode)) + dispatch(modelActions.setAutoApprove(mode === 'full_access')) + }, + onApprovalModeChanged: (d) => { + dispatch(modelActions.setAutoApprove(d.auto_approve)) + if (d.auto_approve) dispatch(modelActions.setMode('full_access')) + else if (getState().model.mode === 'full_access') dispatch(modelActions.setMode('approval')) + }, + onSubagentProgress: (d) => + dispatch(chatActions.addSubagentProgress({ + event: d.event, + toolName: d.tool_name, + detail: d.detail, + })), onUserMessage: (d) => { dispatch(chatActions.addMessage({ role: 'user', content: d.content, source: d.source })) dispatch(chatActions.setRunning(true)) diff --git a/web-react/src/components/AuthGate.tsx b/web-react/src/components/AuthGate.tsx index b93835a2..2347623d 100644 --- a/web-react/src/components/AuthGate.tsx +++ b/web-react/src/components/AuthGate.tsx @@ -2,9 +2,10 @@ import { useState } from 'react' import { api } from '../lib/api' +import { normalizeMode } from '../lib/types' import { setAuthToken, clearAuthToken } from '../lib/authToken' import { useAppDispatch } from '../app/hooks' -import { uiActions } from '../app/store' +import { chatActions, loadWorkspaceState, modelActions, sessionActions, uiActions } from '../app/store' export function AuthGate() { const dispatch = useAppDispatch() @@ -20,6 +21,16 @@ export function AuthGate() { const resp = await api.authVerify(token) if (resp.ok) { setAuthToken(token) + const h = await api.health() + dispatch(modelActions.setProvider(h.provider)) + dispatch(modelActions.setModel(h.model)) + dispatch(modelActions.setMode(normalizeMode(h.mode))) + dispatch(modelActions.setServerVersion(h.version)) + dispatch(modelActions.setImageSupport(!!h.image_support)) + dispatch(sessionActions.setProjectPath(h.pwd)) + dispatch(sessionActions.setCurrentSession(h.session_id || '')) + dispatch(chatActions.setRunning(!!h.running)) + await dispatch(loadWorkspaceState()) dispatch(uiActions.setNeedsAuth(false)) } else { setError('Invalid token') @@ -32,7 +43,8 @@ export function AuthGate() { } return ( -
+
+