diff --git a/Cargo.lock b/Cargo.lock index 3a4b1cf5..2ba71806 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -374,6 +374,7 @@ dependencies = [ name = "codetwo-server" version = "0.0.0" dependencies = [ + "async-trait", "axum", "chrono", "codetwo-core", @@ -388,6 +389,7 @@ dependencies = [ "tempfile", "tokio", "tokio-tungstenite 0.21.0", + "tower-http", "tracing", "uuid", ] @@ -876,6 +878,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -1364,6 +1372,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2784,10 +2802,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.13.1", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -2887,6 +2914,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/README.md b/README.md index 268e5384..466168f4 100644 --- a/README.md +++ b/README.md @@ -134,21 +134,27 @@ and notarization. From the repository root: ```sh -# Build the TUI, server, and their sibling Bun Tool Broker +# Build the TUI, server, shared Web UI, and Bun Tool Broker ./script/build/hosts.sh release # Terminal interface ./target/release/codetwo-tui -# Paired remote web client +# Paired compact remote client ./target/release/codetwo-server +# Full React Web UI (starts one Core and opens the pairing link) +./target/release/codetwo-server webui + # Self-contained turn demo using a stub ACP agent (requires Node) cargo run -p codetwo-core --example live_demo ``` -The remote server prints a one-time pairing URL and token. Keep it on a trusted LAN or Tailscale -tailnet; C2 does not provide a hosted relay. +Both server modes print a one-time pairing URL and token. `webui` serves the same React renderer as +the desktop app from the adjacent `web-ui` build directory and opens the local pairing URL; pass +`--no-open` to suppress that side effect, or `--ui-dir ` when the assets are packaged +elsewhere. Keep either mode on a trusted LAN or Tailscale tailnet; C2 does not provide a hosted +relay. ## Repository map diff --git a/apps/desktop/index.html b/apps/desktop/index.html index 18e4a892..c895d6ca 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -3,6 +3,7 @@ + C2 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4a15220c..30ffcfe7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "bun scripts/run-electrobun.ts dev --watch", "dev:renderer": "vite", + "dev:web": "vite --mode web", "build": "bun scripts/run-electrobun.ts build --env=dev", "build:nightly": "bun scripts/run-electrobun.ts build --env=stable --channel=nightly", "build:release": "bun scripts/run-electrobun.ts build --env=stable --channel=release", diff --git a/apps/desktop/src-host/src/remote.rs b/apps/desktop/src-host/src/remote.rs index b677c576..5b78a8c5 100644 --- a/apps/desktop/src-host/src/remote.rs +++ b/apps/desktop/src-host/src/remote.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use codetwo_core::{Engine, Event, Member, MemberId, Store, WorkspaceId, WorkspaceRole}; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; -use codetwo_plugins::{CanvasService, EngineService, EventBus, StoreService}; +use codetwo_plugins::{CanvasService, EngineService, EventBus, PluginManager, StoreService}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::sync::broadcast; @@ -190,6 +190,7 @@ struct RemoteRuntime { events: broadcast::Sender, canvas_gate: codetwo_core::CanvasFeatureGate, device_sync: Option>, + web_ui_commands: Arc, auth_path: PathBuf, lifecycle: Mutex, } @@ -277,7 +278,8 @@ impl Plugin for RemotePlugin { } fn inject(&self) -> Injection { - Injection::required(["engine", "store", "bus", "canvas"]).with_optional(["device-sync"]) + Injection::required(["engine", "store", "bus", "canvas", "plugin-manager"]) + .with_optional(["device-sync"]) } fn description(&self) -> Option<&str> { @@ -285,6 +287,10 @@ impl Plugin for RemotePlugin { } async fn apply(&self, ctx: Context, _config: Value) -> PluginResult { + let web_ui_commands = Arc::new(codetwo_server::KernelWebUiCommands::new( + ctx.get::() + .ok_or_else(|| PluginError::new("plugin manager is unavailable"))?, + )); let runtime = Arc::new(RemoteRuntime { engine: ctx .get::() @@ -306,6 +312,7 @@ impl Plugin for RemotePlugin { .ok_or_else(|| PluginError::new("canvas service is unavailable"))? .gate, device_sync: ctx.get::(), + web_ui_commands, auth_path: self.auth_path.clone(), lifecycle: Mutex::new(RemoteLifecycle::default()), }); @@ -345,7 +352,7 @@ impl Plugin for RemotePlugin { .device_sync .clone() .map(|device_sync| device_sync as Arc); - let bound = codetwo_server::bind_and_serve_with_services( + let bound = codetwo_server::bind_and_serve_with_web_ui( service.engine.clone(), service.events.clone(), addr, @@ -353,6 +360,8 @@ impl Plugin for RemotePlugin { service.store.clone(), service.canvas_gate, device_sync_http, + Some(service.web_ui_commands.clone()), + None, ) .await; let (local, task) = match bound { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0b57e556..0273e9f9 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -5968,6 +5968,11 @@ export default function App() { [componentEnabled, manualDockTab, toast], ); + const toggleSidePanel = useCallback(() => { + manualDockTab(dockTabRef.current !== null ? null : "home"); + setTimeout(() => window.dispatchEvent(new Event("resize")), 0); + }, [manualDockTab]); + const runProjectAction = useCallback((script: ProjectScript) => { if (script.kind === "prompt") { const doc: DocBlock[] = [{ type: "text", text: script.prompt }]; @@ -6875,6 +6880,15 @@ export default function App() { case "close_panel": manualDockTab(null); break; + case "split_pane_right": + splitPaneById(focusedPaneRef.current, "right"); + break; + case "split_pane_down": + splitPaneById(focusedPaneRef.current, "bottom"); + break; + case "toggle_side_panel": + toggleSidePanel(); + break; case "open_skill_picker": openSkillPickerRef.current?.(); break; @@ -6990,6 +7004,8 @@ export default function App() { openWorkingDirectory, toggleDock, manualDockTab, + splitPaneById, + toggleSidePanel, toggleDocMode, docMode, stepSession, @@ -8327,12 +8343,14 @@ export default function App() { }} groupLabel={t("pane.layoutActions")} viewLabel={t("pane.viewMenu")} + shortcuts={{ + splitRight: hint("split_pane_right"), + splitDown: hint("split_pane_down"), + sidePanel: hint("toggle_side_panel"), + }} panelLabel={t("pane.sidePanel")} panelActive={dockTab !== null} - onTogglePanel={() => { - manualDockTab(dockTab !== null ? null : "home"); - setTimeout(() => window.dispatchEvent(new Event("resize")), 0); - }} + onTogglePanel={toggleSidePanel} /> diff --git a/apps/desktop/src/bridge.ts b/apps/desktop/src/bridge.ts index 718e285e..ccc3b399 100644 --- a/apps/desktop/src/bridge.ts +++ b/apps/desktop/src/bridge.ts @@ -1,5 +1,4 @@ import { - desktopCall, desktopAppshotSettings, desktopCaptureAppshot, desktopGetAppshot, @@ -23,6 +22,7 @@ import { onDesktopAppshotCaptured, onDesktopAppshotFailed, } from "./container"; +import { coreAvailable, coreCall, listenCore } from "./coreTransport"; import type { AppshotCapture, AppshotDestination, @@ -1259,19 +1259,19 @@ function browserDockerCall(name: string, rawArgs: unknown): T { // ---- the plugin graph ------------------------------------------------------------------------- /** - * Call a command through the trusted desktop host — `call("git.status", { cwd })`. + * Call a command through the selected Core transport — `call("git.status", { cwd })`. * - * This transport carries both internal host commands and extension-contributed commands. It is - * broader than the public Extension API exposed to child processes. A runtime module that - * registers `foo.bar` is callable from here without adding another desktop RPC method. + * The desktop adapter carries the full trusted host surface. Paired Web mode keeps the same typed + * product projection but the remote host enforces a smaller allowlist. A runtime module that + * registers `foo.bar` remains callable without adding a command-specific renderer RPC method. */ export async function call( name: string, args?: unknown, projectPath: string | null = callProjectPath, ): Promise { - if (!inDesktop) return browserDockerCall(name, args); - return desktopCall(name, args ?? null, projectPath); + if (!coreAvailable) return browserDockerCall(name, args); + return coreCall(name, args ?? null, projectPath); } /** Lifecycle state of one plugin instance, as the kernel reports it. */ @@ -1617,7 +1617,7 @@ const FALLBACK_SKILLS: SkillInfo[] = [ ]; export async function listProviders(checkUpdates = false): Promise { - const providers = inDesktop + const providers = coreAvailable ? await call("providers.list", { check_updates: checkUpdates }) : fallbackProviders(); return providers.map(normalizeProviderInfo); @@ -1722,7 +1722,7 @@ export async function listSkills(cwd?: string): Promise { } export async function listSessions(): Promise { - return inDesktop ? call("sessions.list") : []; + return coreAvailable ? call("sessions.list") : []; } export interface ImportedSessionSummary { @@ -1875,7 +1875,7 @@ export async function newSession( initialReasoningEffort?: string | null, parallelTask?: { taskId: string; goal: string } | null, ): Promise { - if (inDesktop) { + if (coreAvailable) { if (parallelTask) { if (worktreeBase === null) { throw new Error("Parallel tasks require an isolated worktree"); @@ -1911,7 +1911,7 @@ export async function newSession( /** Stop and forget one app-lifetime side-chat session. Durable sessions are rejected. */ export async function closeTransientSession(session: string): Promise { - return inDesktop + return coreAvailable ? call("engine.close_transient_session", { session }) : true; } @@ -1967,12 +1967,12 @@ export async function discardOrphanWorktree( } export async function submitPrompt(session: string, doc: DocBlock[], requestId: string): Promise { - if (inDesktop) await call("engine.prompt", { session, doc, request_id: requestId }); + if (coreAvailable) await call("engine.prompt", { session, doc, request_id: requestId }); } /** Connect a durable session early so provider-native modes are available before its next turn. */ export async function prepareSession(session: string): Promise { - if (inDesktop) await call("engine.prepare_session", { session }); + if (coreAvailable) await call("engine.prepare_session", { session }); } export async function queuePrompt( @@ -1980,7 +1980,7 @@ export async function queuePrompt( doc: DocBlock[], requestId: string, ): Promise<{ position: number }> { - return inDesktop + return coreAvailable ? call<{ position: number }>("engine.queue", { session, doc, request_id: requestId }) : { position: 0 }; } @@ -2056,7 +2056,7 @@ export async function answerPermission( requestId: string, optionId: string | null, ): Promise { - if (inDesktop) { + if (coreAvailable) { return call("engine.answer_permission", { session, request_id: requestId, @@ -2071,7 +2071,7 @@ export async function answerElicitation( requestId: string, answer: ElicitationAnswer, ): Promise { - if (inDesktop) { + if (coreAvailable) { return call("engine.answer_elicitation", { session, request_id: requestId, @@ -2082,7 +2082,7 @@ export async function answerElicitation( } export async function setPermissionMode(session: string, mode: string): Promise { - if (inDesktop) await call("engine.set_permission_mode", { session, mode }); + if (coreAvailable) await call("engine.set_permission_mode", { session, mode }); } export async function setExecutionPolicy( @@ -2091,7 +2091,7 @@ export async function setExecutionPolicy( sandbox: Sandbox, requestId: string, ): Promise { - if (inDesktop) { + if (coreAvailable) { await call("engine.set_execution_policy", { session, mode, @@ -2518,7 +2518,7 @@ export async function onLspExit(cb: (key: string) => void): Promise<() => void> /** Newest text per session id, for the rail's preview line. */ export async function sessionPreviews(): Promise> { - if (!inDesktop) return {}; + if (!coreAvailable) return {}; const rows = await call<[string, string][]>("sessions.previews"); return Object.fromEntries(rows); } @@ -2541,7 +2541,7 @@ export async function searchSessions(query: string, limit = 12): Promise { - return inDesktop ? call("projects.list") : []; + return coreAvailable ? call("projects.list") : []; } /** @@ -2628,11 +2628,11 @@ export async function removeProject(path: string): Promise { /** Where a new session should start. Resolved by the core, never `"."` — see `default_cwd`. */ export async function defaultCwd(): Promise { - return inDesktop ? call("workspace.default_cwd") : "."; + return coreAvailable ? call("workspace.default_cwd") : "."; } export async function setModel(session: string, model: string): Promise { - if (inDesktop) await call("engine.set_model", { session, model }); + if (coreAvailable) await call("engine.set_model", { session, model }); } /** Atomically replace an idle Session's provider while retaining its transcript and workspace. */ @@ -2649,11 +2649,11 @@ export async function switchProvider( /** Set an agent-reported config option (model, reasoning effort, …) by its id. */ export async function setConfigOption(session: string, configId: string, value: string): Promise { - if (inDesktop) await call("engine.set_config_option", { session, config_id: configId, value }); + if (coreAvailable) await call("engine.set_config_option", { session, config_id: configId, value }); } export async function cancelTurn(session: string): Promise { - if (inDesktop) await call("engine.cancel", { session }); + if (coreAvailable) await call("engine.cancel", { session }); } /** @@ -2706,7 +2706,7 @@ export async function getTranscriptPage( before: number | null = null, limit = 20, ): Promise { - return inDesktop + return coreAvailable ? call("sessions.transcript", { session, before, limit }) : { entries: [], next_before: null, snapshot_through: null }; } @@ -2915,6 +2915,9 @@ export const DEFAULT_KEYMAP: KeymapEntry[] = [ ["toggle_browser", "Mod+B", "Toggle browser"], ["toggle_git", "Mod+Shift+B", "Toggle git panel"], ["close_panel", "Escape", "Close side panel"], + ["split_pane_right", "Mod+Alt+R", "Split pane right"], + ["split_pane_down", "Mod+Alt+D", "Split pane down"], + ["toggle_side_panel", "Mod+Alt+P", "Toggle side panel"], ["open_skill_picker", "Mod+/", "Open skill picker"], ["focus_editor", "Mod+E", "Focus editor"], ["toggle_doc_mode", "Mod+Shift+E", "Expand document to full height"], @@ -3995,7 +3998,7 @@ export async function compileDoc(doc: DocBlock[], cwd?: string | null): Promise< // ---- sandbox + project scripts (G7/G8) --------------------------------------------------------- export async function setSandbox(session: string, sandbox: Sandbox): Promise { - if (inDesktop) await call("engine.set_sandbox", { session, sandbox }); + if (coreAvailable) await call("engine.set_sandbox", { session, sandbox }); } export interface ProjectScript { @@ -4214,16 +4217,16 @@ export async function listRules(cwd: string): Promise { // ---- session management (G5) ----------------------------------------------------------------- export async function renameSession(session: string, title: string): Promise { - if (inDesktop) await call("sessions.rename", { session, title }); + if (coreAvailable) await call("sessions.rename", { session, title }); } export async function archiveSession(session: string, archived: boolean): Promise { - if (inDesktop) await call("sessions.set_archived", { session, value: archived }); + if (coreAvailable) await call("sessions.set_archived", { session, value: archived }); } export async function pinSession(session: string, pinned: boolean): Promise { - if (inDesktop) await call("sessions.set_pinned", { session, value: pinned }); + if (coreAvailable) await call("sessions.set_pinned", { session, value: pinned }); } export async function listArchivedSessions(): Promise { - return inDesktop ? call("sessions.archived") : []; + return coreAvailable ? call("sessions.archived") : []; } // ---- PR + commit message (G6) ------------------------------------------------------------------ @@ -4311,8 +4314,8 @@ export async function deleteSkill(id: string): Promise { } export async function onEngineEvent(cb: (ev: CoreEvent) => void): Promise<() => void> { - if (!inDesktop) return () => {}; - return listenDesktop("engine-event", cb); + if (!coreAvailable) return () => {}; + return listenCore("engine-event", cb); } export async function onPtyOutput(cb: (p: PtyOutput) => void): Promise<() => void> { diff --git a/apps/desktop/src/coreTransport.ts b/apps/desktop/src/coreTransport.ts new file mode 100644 index 00000000..fb43685c --- /dev/null +++ b/apps/desktop/src/coreTransport.ts @@ -0,0 +1,292 @@ +import { + desktopCall, + isElectrobun, + listenDesktop, +} from "./container"; + +export interface CoreTransport { + call(name: string, args: unknown, projectPath: string | null): Promise; + listen(name: string, listener: (payload: T) => void): () => void; +} + +interface StorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +interface LocationLike { + hash: string; + host: string; + pathname: string; + protocol: string; + search: string; +} + +interface SocketLike { + close(): void; + onclose: WebSocket["onclose"]; + onerror: WebSocket["onerror"]; + onmessage: WebSocket["onmessage"]; + onopen: WebSocket["onopen"]; +} + +export interface WebCoreTransportDependencies { + clearPairingFragment(): void; + createSocket(url: string): SocketLike; + fetch(input: string, init?: RequestInit): Promise; + location: LocationLike; + onError(error: unknown): void; + reconnectDelayMs: number; + storage: StorageLike; +} + +const BEARER_KEY = "codetwo.remote.bearer"; + +function pairingToken(hash: string): string | null { + if (!hash.startsWith("#")) return null; + return new URLSearchParams(hash.slice(1)).get("token"); +} + +async function responsePayload(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} + +function errorMessage(payload: unknown, fallback: string): string { + if (typeof payload === "string" && payload) return payload; + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + const error = (payload as { error?: unknown }).error; + if (typeof error === "string" && error) return error; + } + return fallback; +} + +/** + * Paired browser adapter for the renderer's Core interface. + * + * Calls use authenticated HTTP so independent React reads remain concurrent. The existing remote + * WebSocket remains the single engine-event stream and keeps bearer credentials out of socket URLs. + */ +export function createWebCoreTransport( + dependencies: WebCoreTransportDependencies, +): CoreTransport { + const listeners = new Map void>>(); + const bootstrapToken = pairingToken(dependencies.location.hash); + let bearer = bootstrapToken ? null : dependencies.storage.getItem(BEARER_KEY); + if (bootstrapToken) dependencies.storage.removeItem(BEARER_KEY); + let bearerRequest: Promise | null = null; + let socket: SocketLike | null = null; + let socketRequest: Promise | null = null; + let reconnectTimer: ReturnType | null = null; + + const hasListeners = () => { + for (const group of listeners.values()) if (group.size > 0) return true; + return false; + }; + + const clearBearer = () => { + bearer = null; + dependencies.storage.removeItem(BEARER_KEY); + }; + + const ensureBearer = (): Promise => { + if (bearer) return Promise.resolve(bearer); + if (bearerRequest) return bearerRequest; + bearerRequest = (async () => { + const token = pairingToken(dependencies.location.hash); + if (!token) { + throw new Error("C2 Web UI is not paired. Open a fresh browser pairing link."); + } + dependencies.clearPairingFragment(); + const response = await dependencies.fetch("/api/pair", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, device_name: "C2 Web UI" }), + }); + const payload = await responsePayload(response); + if (!response.ok) { + throw new Error(errorMessage(payload, "C2 Web UI pairing failed")); + } + const next = payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as { bearer?: unknown }).bearer + : null; + if (typeof next !== "string" || !next) { + throw new Error("C2 Web UI pairing returned no bearer credential"); + } + bearer = next; + dependencies.storage.setItem(BEARER_KEY, next); + return next; + })().finally(() => { + bearerRequest = null; + }); + return bearerRequest; + }; + + const scheduleReconnect = () => { + if (reconnectTimer !== null || !hasListeners()) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void ensureSocket().catch(reportConnectionError); + }, dependencies.reconnectDelayMs); + }; + + function reportConnectionError(error: unknown) { + // Keep retrying ordinary outages while a reusable bearer still exists. A rejected bearer or + // missing pairing link fails closed until the user opens a fresh one. + if (bearer || pairingToken(dependencies.location.hash)) scheduleReconnect(); + dependencies.onError(error); + } + + const ensureSocket = (): Promise => { + if (socket) return Promise.resolve(); + if (socketRequest) return socketRequest; + socketRequest = (async () => { + const authorization = await ensureBearer(); + const response = await dependencies.fetch("/api/ws-ticket", { + method: "POST", + headers: { Authorization: `Bearer ${authorization}` }, + }); + const payload = await responsePayload(response); + if (response.status === 401) clearBearer(); + if (!response.ok) { + throw new Error(errorMessage(payload, "C2 Web UI ticket request failed")); + } + const ticket = payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as { ticket?: unknown }).ticket + : null; + if (typeof ticket !== "string" || !ticket) { + throw new Error("C2 Web UI ticket request returned no ticket"); + } + const protocol = dependencies.location.protocol === "https:" ? "wss:" : "ws:"; + const next = dependencies.createSocket( + `${protocol}//${dependencies.location.host}/ws?ticket=${encodeURIComponent(ticket)}`, + ); + await new Promise((resolve, reject) => { + next.onopen = () => { + if (!hasListeners()) { + next.close(); + resolve(); + return; + } + socket = next; + resolve(); + }; + next.onerror = () => { + next.close(); + reject(new Error("C2 Web UI event connection failed")); + }; + next.onmessage = ({ data }) => { + try { + const message = typeof data === "string" ? JSON.parse(data) as unknown : data; + if (!message || typeof message !== "object" || Array.isArray(message)) return; + const envelope = message as { kind?: unknown; event?: unknown }; + if (envelope.kind !== "event") return; + for (const listener of listeners.get("engine-event") ?? []) { + listener(envelope.event); + } + } catch (error) { + dependencies.onError(error); + } + }; + next.onclose = () => { + if (socket === next) socket = null; + scheduleReconnect(); + }; + }); + })().finally(() => { + socketRequest = null; + }); + return socketRequest; + }; + + return { + async call(name: string, args: unknown, projectPath: string | null): Promise { + const authorization = await ensureBearer(); + const response = await dependencies.fetch("/api/web-ui/call", { + method: "POST", + headers: { + Authorization: `Bearer ${authorization}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name, args, project_path: projectPath }), + }); + const payload = await responsePayload(response); + if (response.status === 401) clearBearer(); + if (!response.ok) { + throw new Error(errorMessage(payload, `C2 Web UI command ${name} failed`)); + } + if (!payload || typeof payload !== "object" || Array.isArray(payload) || !("result" in payload)) { + throw new Error(`C2 Web UI command ${name} returned an invalid response`); + } + return (payload as { result: T }).result; + }, + + listen(name: string, listener: (payload: T) => void): () => void { + const wrapped = listener as (payload: unknown) => void; + const group = listeners.get(name) ?? new Set<(payload: unknown) => void>(); + group.add(wrapped); + listeners.set(name, group); + void ensureSocket().catch(reportConnectionError); + return () => { + group.delete(wrapped); + if (group.size === 0) listeners.delete(name); + if (hasListeners()) return; + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + socket?.close(); + socket = null; + }; + }, + }; +} + +const desktopCoreTransport: CoreTransport = { + call: desktopCall, + listen: listenDesktop, +}; + +function webModeRequested(): boolean { + if (typeof window === "undefined") return false; + return import.meta.env.MODE === "web" || new URLSearchParams(window.location.search).has("web-core"); +} + +function browserWebTransport(): CoreTransport | null { + if (!webModeRequested()) return null; + return createWebCoreTransport({ + clearPairingFragment: () => { + history.replaceState(null, "", `${location.pathname}${location.search}`); + }, + createSocket: (url) => new WebSocket(url), + fetch: (input, init) => fetch(input, init), + location, + onError: (error) => console.error("C2 Web UI transport error", error), + reconnectDelayMs: 1_500, + storage: localStorage, + }); +} + +const selectedTransport = isElectrobun ? desktopCoreTransport : browserWebTransport(); + +/** True when product commands have a real Core behind them, independent of the desktop shell. */ +export const coreAvailable = selectedTransport !== null; + +export function coreCall( + name: string, + args: unknown, + projectPath: string | null, +): Promise { + if (!selectedTransport) throw new Error("C2 Core is unavailable in this browser preview"); + return selectedTransport.call(name, args, projectPath); +} + +export function listenCore(name: string, listener: (payload: T) => void): () => void { + return selectedTransport?.listen(name, listener) ?? (() => {}); +} diff --git a/apps/desktop/src/i18n/strings.ts b/apps/desktop/src/i18n/strings.ts index 79c9af7e..b7799aec 100644 --- a/apps/desktop/src/i18n/strings.ts +++ b/apps/desktop/src/i18n/strings.ts @@ -1942,6 +1942,9 @@ export const en = { "action.toggle_browser": "Toggle browser", "action.toggle_git": "Toggle git panel", "action.close_panel": "Close side panel", + "action.split_pane_right": "Split pane right", + "action.split_pane_down": "Split pane down", + "action.toggle_side_panel": "Toggle side panel", "action.open_skill_picker": "Open skill picker", "action.focus_editor": "Focus editor", "action.toggle_doc_mode": "Expand document to full height", @@ -4555,6 +4558,9 @@ export const zhCN: Record = { "action.toggle_browser": "切换浏览器", "action.toggle_git": "切换 Git 面板", "action.close_panel": "关闭侧边面板", + "action.split_pane_right": "向右拆分窗格", + "action.split_pane_down": "向下拆分窗格", + "action.toggle_side_panel": "切换侧边面板", "action.open_skill_picker": "打开技能选择器", "action.focus_editor": "聚焦编辑器", "action.toggle_doc_mode": "展开文档为整页", diff --git a/apps/desktop/src/plugins/PluginManagerPage.tsx b/apps/desktop/src/plugins/PluginManagerPage.tsx index 26915d0b..d93fb3c1 100644 --- a/apps/desktop/src/plugins/PluginManagerPage.tsx +++ b/apps/desktop/src/plugins/PluginManagerPage.tsx @@ -1709,14 +1709,14 @@ export function PluginManagerPage({

{labels.title}

-
+
setTab(value as typeof tab)} className="ms-surface-inset min-w-0 gap-0"> {(["plugins", "mcps", "skills", "hooks", "marketplace"] as const).map((id) => ( {labels[id]}{" "} {tabCounts[id]} diff --git a/apps/desktop/src/session/PaneChrome.tsx b/apps/desktop/src/session/PaneChrome.tsx index 42f92b3e..3afaf936 100644 --- a/apps/desktop/src/session/PaneChrome.tsx +++ b/apps/desktop/src/session/PaneChrome.tsx @@ -7,6 +7,7 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; @@ -75,6 +76,7 @@ interface PaneLayoutToolbarProps extends PaneToolbarProps { panelLabel: string; groupLabel: string; viewLabel: string; + shortcuts: { splitRight: string; splitDown: string; sidePanel: string }; } /** Pane and panel controls grouped at the trailing edge of the focused session titlebar. */ @@ -84,6 +86,7 @@ export function PaneLayoutToolbar({ panelLabel, groupLabel, viewLabel, + shortcuts, onSplitRight, onSplitDown, onClose, @@ -120,10 +123,12 @@ export function PaneLayoutToolbar({ {labels.splitRight} + {shortcuts.splitRight} {labels.splitDown} + {shortcuts.splitDown} {canClose ? ( @@ -138,6 +143,7 @@ export function PaneLayoutToolbar({ > {panelLabel} + {shortcuts.sidePanel} diff --git a/apps/desktop/src/session/SideChatPanel.tsx b/apps/desktop/src/session/SideChatPanel.tsx index 57723179..bb64ac69 100644 --- a/apps/desktop/src/session/SideChatPanel.tsx +++ b/apps/desktop/src/session/SideChatPanel.tsx @@ -843,7 +843,7 @@ function TransientChatPanel({ ) : null}
0 && "bg-fill-rest/40", + )} aria-label={t("taskboard.taskSessions", { title: task.title })} > {sessions.length > 0 ? sessions.map((session) => { diff --git a/apps/desktop/src/taskboard/task-board.css b/apps/desktop/src/taskboard/task-board.css index 61bef19f..bc9f9605 100644 --- a/apps/desktop/src/taskboard/task-board.css +++ b/apps/desktop/src/taskboard/task-board.css @@ -36,8 +36,8 @@ .task-board-kanban { display: grid; - grid-template-columns: repeat(4, minmax(14rem, 1fr)); - min-width: calc(56rem + 1.5rem); + grid-template-columns: repeat(4, minmax(340px, 1fr)); + min-width: calc(1360px + 1.5rem); } .task-board-kanban-column { diff --git a/apps/desktop/tests/coreTransport.test.ts b/apps/desktop/tests/coreTransport.test.ts new file mode 100644 index 00000000..0fcd6a63 --- /dev/null +++ b/apps/desktop/tests/coreTransport.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test"; + +import { + createWebCoreTransport, + type WebCoreTransportDependencies, +} from "../src/coreTransport"; + +class MemoryStorage { + private readonly values = new Map(); + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } + + removeItem(key: string): void { + this.values.delete(key); + } +} + +class FakeSocket { + onclose: WebSocket["onclose"] = null; + onerror: WebSocket["onerror"] = null; + onmessage: WebSocket["onmessage"] = null; + onopen: WebSocket["onopen"] = null; + closed = false; + + close(): void { + this.closed = true; + this.onclose?.call(this as unknown as WebSocket, {} as CloseEvent); + } +} + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function waitUntil(predicate: () => boolean, timeoutMs = 500): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("condition did not become true"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +function dependencies( + fetcher: WebCoreTransportDependencies["fetch"], + storage = new MemoryStorage(), +) { + const sockets: Array<{ url: string; socket: FakeSocket }> = []; + let fragmentClears = 0; + const value: WebCoreTransportDependencies = { + clearPairingFragment: () => { + fragmentClears += 1; + }, + createSocket: (url) => { + const socket = new FakeSocket(); + sockets.push({ url, socket }); + queueMicrotask(() => socket.onopen?.call(socket as unknown as WebSocket, {} as Event)); + return socket; + }, + fetch: fetcher, + location: { + hash: "#token=one-time-token", + host: "127.0.0.1:1420", + pathname: "/", + protocol: "http:", + search: "", + }, + onError: (error) => { + throw error; + }, + reconnectDelayMs: 0, + storage, + }; + return { value, sockets, fragmentClears: () => fragmentClears }; +} + +describe("paired Web Core transport", () => { + test("pairs once and keeps independent command calls concurrent", async () => { + const requests: Array<{ input: string; init?: RequestInit }> = []; + const storage = new MemoryStorage(); + storage.setItem("codetwo.remote.bearer", "stale-bearer"); + let releasePairing!: () => void; + const pairingGate = new Promise((resolve) => { + releasePairing = resolve; + }); + const setup = dependencies(async (input, init) => { + requests.push({ input, init }); + if (input === "/api/pair") { + await pairingGate; + return jsonResponse({ bearer: "paired-bearer" }); + } + const body = JSON.parse(String(init?.body)) as { name: string }; + return jsonResponse({ result: body.name }); + }, storage); + const transport = createWebCoreTransport(setup.value); + + const first = transport.call("sessions.list", null, null); + const second = transport.call("projects.list", null, null); + await Promise.resolve(); + expect(requests.filter((request) => request.input === "/api/pair")).toHaveLength(1); + releasePairing(); + + await expect(Promise.all([first, second])).resolves.toEqual([ + "sessions.list", + "projects.list", + ]); + expect(setup.fragmentClears()).toBe(1); + expect(requests.filter((request) => request.input === "/api/web-ui/call")).toHaveLength(2); + for (const request of requests.filter((item) => item.input === "/api/web-ui/call")) { + expect(new Headers(request.init?.headers).get("Authorization")).toBe( + "Bearer paired-bearer", + ); + } + }); + + test("uses a fresh ticket whenever the shared engine event stream reconnects", async () => { + const storage = new MemoryStorage(); + storage.setItem("codetwo.remote.bearer", "saved-bearer"); + const requests: Array<{ input: string; init?: RequestInit }> = []; + let ticketRequests = 0; + const setup = dependencies(async (input, init) => { + requests.push({ input, init }); + if (input === "/api/ws-ticket") { + ticketRequests += 1; + if (ticketRequests === 2) return jsonResponse({ error: "Core is restarting" }, 503); + return jsonResponse({ ticket: `single-use-ticket-${ticketRequests}` }); + } + throw new Error(`unexpected request: ${input}`); + }, storage); + setup.value.location.hash = ""; + setup.value.onError = () => {}; + const transport = createWebCoreTransport(setup.value); + const events: unknown[] = []; + const unlisten = transport.listen("engine-event", (event) => events.push(event)); + + await waitUntil(() => setup.sockets.length === 1); + expect(setup.sockets).toHaveLength(1); + expect(setup.sockets[0]?.url).toBe( + "ws://127.0.0.1:1420/ws?ticket=single-use-ticket-1", + ); + expect(new Headers(requests[0]?.init?.headers).get("Authorization")).toBe( + "Bearer saved-bearer", + ); + + setup.sockets[0]?.socket.onmessage?.({ + data: JSON.stringify({ + kind: "event", + event: { event: "agent_text", session: "session-1", text: "hello" }, + }), + }); + expect(events).toEqual([ + { event: "agent_text", session: "session-1", text: "hello" }, + ]); + + setup.sockets[0]?.socket.onclose?.call( + setup.sockets[0]?.socket as unknown as WebSocket, + {} as CloseEvent, + ); + await waitUntil(() => setup.sockets.length === 2); + expect(setup.sockets).toHaveLength(2); + expect(setup.sockets[1]?.url).toBe( + "ws://127.0.0.1:1420/ws?ticket=single-use-ticket-3", + ); + expect(requests.filter((request) => request.input === "/api/ws-ticket")).toHaveLength(3); + + unlisten(); + expect(setup.sockets[1]?.socket.closed).toBe(true); + }); + + test("fails closed when no stored bearer or one-time pairing token exists", async () => { + const setup = dependencies(async () => { + throw new Error("fetch should not run"); + }); + setup.value.location.hash = ""; + const transport = createWebCoreTransport(setup.value); + + await expect(transport.call("sessions.list", null, null)).rejects.toThrow( + "not paired", + ); + }); +}); diff --git a/apps/desktop/tests/paneChrome.test.tsx b/apps/desktop/tests/paneChrome.test.tsx index 8ead7782..9de0c380 100644 --- a/apps/desktop/tests/paneChrome.test.tsx +++ b/apps/desktop/tests/paneChrome.test.tsx @@ -18,6 +18,7 @@ afterEach(() => { }); const LABELS = { splitRight: "Split right", splitDown: "Split down", close: "Close pane" }; +const SHORTCUTS = { splitRight: "⌘⌥R", splitDown: "⌘⌥D", sidePanel: "⌘⌥P" }; function click(element: Element) { element.dispatchEvent( @@ -80,6 +81,7 @@ describe("PaneChrome", () => { labels={LABELS} groupLabel="Pane and panel layout" viewLabel="View" + shortcuts={SHORTCUTS} panelLabel="Side panel" panelActive onTogglePanel={() => calls.push("panel")} @@ -104,6 +106,7 @@ describe("PaneChrome", () => { expect(dom.document.body.textContent).not.toContain("Close pane"); const panel = dom.document.body.querySelector('[role="menuitemcheckbox"]'); expect(panel?.textContent).toContain("Side panel"); + expect(panel?.querySelector("span")?.textContent).toBe("⌘⌥P"); expect(panel?.getAttribute("data-checked")).not.toBeNull(); if (!panel) throw new Error("Side panel menu item not found"); await press(panel); @@ -112,6 +115,10 @@ describe("PaneChrome", () => { const splitRight = Array.from(dom.document.body.querySelectorAll('[role="menuitem"]')) .find((item) => item.textContent?.includes("Split right")); if (!splitRight) throw new Error("Split right menu item not found"); + expect(splitRight.querySelector("span")?.textContent).toBe("⌘⌥R"); + const splitDown = Array.from(dom.document.body.querySelectorAll('[role="menuitem"]')) + .find((item) => item.textContent?.includes("Split down")); + expect(splitDown?.querySelector("span")?.textContent).toBe("⌘⌥D"); await press(splitRight); expect(calls).toEqual(["panel", "right"]); rendered.unmount(); @@ -129,6 +136,7 @@ describe("PaneChrome", () => { labels={LABELS} groupLabel="Pane and panel layout" viewLabel="View" + shortcuts={SHORTCUTS} panelLabel="Side panel" panelActive={false} onTogglePanel={() => calls.push("panel")} diff --git a/apps/desktop/tests/pluginBridgeContract.test.ts b/apps/desktop/tests/pluginBridgeContract.test.ts index 65edc6ac..845a1cd8 100644 --- a/apps/desktop/tests/pluginBridgeContract.test.ts +++ b/apps/desktop/tests/pluginBridgeContract.test.ts @@ -15,6 +15,7 @@ function rustFiles(directory: string): string[] { describe("plugin bridge contract", () => { test("keeps one typed renderer request and one versioned Plugin Kernel process boundary", () => { const bridge = readFileSync(resolve(desktop, "src/bridge.ts"), "utf8"); + const coreTransport = readFileSync(resolve(desktop, "src/coreTransport.ts"), "utf8"); const client = readFileSync(resolve(desktop, "src/electrobun/client.ts"), "utf8"); const main = readFileSync(resolve(desktop, "src/electrobun/index.ts"), "utf8"); const adapter = readFileSync(resolve(desktop, "src/electrobun/nativeHost.ts"), "utf8"); @@ -34,7 +35,9 @@ describe("plugin bridge contract", () => { "utf8", ); - expect(bridge).toContain("return desktopCall(name, args ?? null, projectPath)"); + expect(bridge).toContain("return coreCall(name, args ?? null, projectPath)"); + expect(coreTransport).toContain("call: desktopCall"); + expect(coreTransport).toContain("listen: listenDesktop"); expect(bridge).toContain("projectPath: string | null = callProjectPath"); expect(bridge).toContain( 'call("plugins.catalog", { scope: managedPluginScopeToWire(scope) }, null)', @@ -59,7 +62,7 @@ describe("plugin bridge contract", () => { expect(macBundlePatch).not.toContain('"--deep"'); expect(macPackageSigning).toContain('join(bundle, "Contents", "Resources", metadata)'); expect(macPackageSigning).toContain('"--force", "--deep", "--sign", "-"'); - expect(`${bridge}\n${client}\n${main}\n${host}`).not.toContain("@tauri-apps"); + expect(`${bridge}\n${coreTransport}\n${client}\n${main}\n${host}`).not.toContain("@tauri-apps"); expect(`${main}\n${adapter}`).not.toContain("PureBunHost"); for (const legacyHost of ["builtinPlugins.ts", "database.ts", "index.ts", "remote.ts"]) { expect(existsSync(resolve(desktop, "src/electrobun/host", legacyHost))).toBe(false); diff --git a/apps/desktop/tests/pluginManagerRendered.test.tsx b/apps/desktop/tests/pluginManagerRendered.test.tsx index 526680ca..8a608986 100644 --- a/apps/desktop/tests/pluginManagerRendered.test.tsx +++ b/apps/desktop/tests/pluginManagerRendered.test.tsx @@ -431,6 +431,8 @@ describe("PluginManagerPage", () => { expect(view.container.querySelectorAll(".plugin-manager-tab-count")).toHaveLength(5); expect(view.container.querySelectorAll("[data-plugin-manager-tab-label]")).toHaveLength(5); const listControls = view.container.querySelector("[data-plugin-manager-list-controls]"); + expect(listControls?.className).toContain("px-2"); + expect(listControls?.className).not.toContain("px-4"); expect(listControls?.querySelectorAll('[role="tab"]')).toHaveLength(5); expect(listControls?.querySelector("[data-plugin-manager-search]")).not.toBeNull(); expect(listControls?.querySelector("[data-plugin-manager-search-field]")).not.toBeNull(); @@ -438,8 +440,9 @@ describe("PluginManagerPage", () => { .toContain("ms-inline"); expect(desktopStyles).toContain("container: plugin-manager-list / inline-size"); expect(desktopStyles).toContain("@container plugin-manager-list (max-width: 24rem)"); - expect(desktopStyles).toContain(".plugin-manager-tab:not(:first-of-type)"); - expect(desktopStyles).toContain("padding-inline: var(--ds-space-optical)"); + expect(desktopStyles).toMatch(/\.plugin-manager-tabs\s*{[^}]*gap:\s*var\(--ds-space-inline\)/s); + expect(desktopStyles).toMatch(/\.plugin-manager-tabs \.plugin-manager-tab\s*{[^}]*padding-inline:\s*var\(--ds-space-inline\)/s); + expect(desktopStyles).not.toContain(".plugin-manager-tab:not(:first-of-type)"); expect(view.container.querySelector(".plugin-manager-detail-pane")).not.toBeNull(); expect(view.container.querySelector("[data-plugin-manager-page]")?.getAttribute("data-compact-detail")).toBe("true"); expect(view.container.querySelector("[data-plugin-manager-scroll]")?.classList.contains("w-full")).toBe(true); diff --git a/apps/desktop/tests/sessionRailRendered.test.tsx b/apps/desktop/tests/sessionRailRendered.test.tsx index 813eb8f8..9d601cbc 100644 --- a/apps/desktop/tests/sessionRailRendered.test.tsx +++ b/apps/desktop/tests/sessionRailRendered.test.tsx @@ -427,17 +427,19 @@ describe("SessionRail row layout", () => { view.unmount(); }); - test("keeps collapse in the title row and exposes search as a labeled launcher", () => { + test("keeps collapse aligned in the title row and exposes search as a labeled launcher", () => { activateDom(); const opened = []; const view = renderRail({ onOpenSearch: () => opened.push("search") }); const header = view.container.querySelector("[data-rail-header]"); + const collapse = header?.querySelector('button[aria-label="Collapse the sidebar"]'); const search = view.container.querySelector("[data-rail-search]"); expect(view.container.textContent).not.toContain("C2"); expect(search).toBeTruthy(); expect(header?.querySelector("[data-rail-search]")).toBeNull(); - expect(header?.querySelector('button[aria-label="Collapse the sidebar"]')).toBeTruthy(); + expect(collapse).toBeTruthy(); + expect(collapse?.classList.contains("mr-2")).toBe(true); expect(search?.textContent).toContain("Search chats"); expect(search?.querySelector("kbd")?.textContent).toBe("⌘K"); @@ -593,6 +595,8 @@ describe("SessionRail row layout", () => { expect(control?.querySelector('[data-rail-side-chat]')).toBeNull(); expect(quickChat?.getAttribute("aria-label")).toBe("Toggle Quick Chat"); expect(quickChat?.getAttribute("aria-pressed")).toBe("false"); + expect(quickChat?.className).toContain("mr-2"); + expect(quickChat?.className).not.toContain("mr-1"); click(primary); click(quickChat); diff --git a/apps/desktop/tests/sideChatPanelRendered.test.tsx b/apps/desktop/tests/sideChatPanelRendered.test.tsx index 67f9e1fb..5d3a4c0b 100644 --- a/apps/desktop/tests/sideChatPanelRendered.test.tsx +++ b/apps/desktop/tests/sideChatPanelRendered.test.tsx @@ -265,7 +265,7 @@ describe("SideChatPanel", () => { view.unmount(); }); - test("keeps the transient composer as the only focus-ring owner", async () => { + test("uses a quiet surface state instead of a blue focus ring for transient composers", async () => { activateDom(); for (const renderPanel of [quickPanel, panel]) { @@ -274,7 +274,8 @@ describe("SideChatPanel", () => { const composer = view.container.querySelector("[data-transient-chat-composer]"); const textarea = composer?.querySelector("textarea"); - expect(composer?.className).toContain("focus-within:focus-ring-inset"); + expect(composer?.className).toContain("focus-within:bg-fill-hover"); + expect(composer?.className).not.toContain("focus-within:focus-ring-inset"); expect(textarea?.className).not.toContain("focus-visible:focus-ring"); expect(textarea?.className).not.toContain("focus-visible:focus-ring-inset"); diff --git a/apps/desktop/tests/taskBoardRendered.test.tsx b/apps/desktop/tests/taskBoardRendered.test.tsx index 40e39e01..4f2f37a2 100644 --- a/apps/desktop/tests/taskBoardRendered.test.tsx +++ b/apps/desktop/tests/taskBoardRendered.test.tsx @@ -1,5 +1,6 @@ // @ts-nocheck import { afterEach, describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" import { act as reactAct } from "react" import { @@ -24,6 +25,10 @@ const { } = await import("../src/taskboard/taskBoard") const { TaskBoardPage } = await import("../src/taskboard/TaskBoardPage") const { TASKBOARD_VIEW_STORAGE_KEY } = await import("../src/taskboard/useTaskBoardView") +const taskBoardStyles = readFileSync( + new URL("../src/taskboard/task-board.css", import.meta.url), + "utf8", +) const mountedRoots = [] const previousLocalStorage = globalThis.localStorage @@ -175,6 +180,8 @@ describe("TaskBoardPage rendered", () => { const boardScroll = view.container.querySelector("[data-task-board-scroll]") expect(boardScroll?.className).toContain("overflow-x-auto") expect(boardScroll?.className).toContain("max-w-full") + expect(taskBoardStyles).toContain("repeat(4, minmax(340px, 1fr))") + expect(taskBoardStyles).toContain("min-width: calc(1360px + 1.5rem)") const card = view.container.querySelector("[data-task-card]") expect(card?.className).toContain("overflow-hidden") expect(card?.querySelector("[data-task-card-meta]")?.className).toContain("overflow-hidden") @@ -304,6 +311,8 @@ describe("TaskBoardPage rendered", () => { const view = await renderBoard({ onStartTask: (selected) => started.push(selected.id) }) await click(button(view.container, "展开任务:开始待办任务")) + expect(view.container.querySelector(".task-board-session-stack")?.className) + .not.toContain("bg-fill-rest/40") await click(button(view.container, "开始任务")) expect(started).toEqual(["TASK-2000"]) }) @@ -348,6 +357,8 @@ describe("TaskBoardPage rendered", () => { await click(button(view.container, "展开任务:优化任务管理")) await waitFor(() => expect(view.container.textContent).toContain("#102 · 未合并")) + expect(view.container.querySelector(".task-board-session-stack")?.className) + .toContain("bg-fill-rest/40") const rows = Array.from(view.container.querySelectorAll("[data-task-session]")) expect(rows.map((row) => row.getAttribute("data-task-session"))).toEqual([ diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index bf36a6b7..a14fa6de 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +const webCoreTarget = process.env.CODETWO_WEB_CORE_URL ?? "http://127.0.0.1:4599"; + // Electrobun loads this output through `views://`, so asset URLs must stay bundle-relative. export default defineConfig({ plugins: [react(), tailwindcss()], @@ -14,6 +16,10 @@ export default defineConfig({ server: { port: 1420, strictPort: true, + proxy: { + "/api": { target: webCoreTarget, changeOrigin: true }, + "/ws": { target: webCoreTarget, changeOrigin: true, ws: true }, + }, }, build: { target: "es2021", diff --git a/crates/core/src/keymap.rs b/crates/core/src/keymap.rs index 530a2509..57529042 100644 --- a/crates/core/src/keymap.rs +++ b/crates/core/src/keymap.rs @@ -33,6 +33,9 @@ pub enum Action { SearchWorkspace, OpenIssues, ClosePanel, + SplitPaneRight, + SplitPaneDown, + ToggleSidePanel, PrevSession, NextSession, CycleScene, @@ -40,7 +43,7 @@ pub enum Action { } impl Action { - pub const ALL: [Action; 25] = [ + pub const ALL: [Action; 28] = [ Action::Run, Action::NewSession, Action::Cancel, @@ -48,6 +51,9 @@ impl Action { Action::ToggleBrowser, Action::ToggleGit, Action::ClosePanel, + Action::SplitPaneRight, + Action::SplitPaneDown, + Action::ToggleSidePanel, Action::OpenSkillPicker, Action::FocusEditor, Action::ToggleDocMode, @@ -91,6 +97,9 @@ impl Action { Action::SearchWorkspace => "search_workspace", Action::OpenIssues => "open_issues", Action::ClosePanel => "close_panel", + Action::SplitPaneRight => "split_pane_right", + Action::SplitPaneDown => "split_pane_down", + Action::ToggleSidePanel => "toggle_side_panel", Action::PrevSession => "prev_session", Action::NextSession => "next_session", Action::CycleScene => "cycle_scene", @@ -121,6 +130,9 @@ impl Action { Action::SearchWorkspace => "Search workspace contents", Action::OpenIssues => "Open issues", Action::ClosePanel => "Close side panel", + Action::SplitPaneRight => "Split pane right", + Action::SplitPaneDown => "Split pane down", + Action::ToggleSidePanel => "Toggle side panel", Action::PrevSession => "Previous session", Action::NextSession => "Next session", Action::CycleScene => "Cycle scene", @@ -151,6 +163,9 @@ impl Action { Action::SearchWorkspace => "Mod+Shift+F", Action::OpenIssues => "Mod+Shift+I", Action::ClosePanel => "Escape", + Action::SplitPaneRight => "Mod+Alt+R", + Action::SplitPaneDown => "Mod+Alt+D", + Action::ToggleSidePanel => "Mod+Alt+P", Action::PrevSession => "Mod+Alt+ArrowUp", Action::NextSession => "Mod+Alt+ArrowDown", Action::CycleScene => "Shift+Tab", @@ -228,6 +243,9 @@ mod tests { assert_eq!(km.entries().len(), Action::ALL.len()); assert_eq!(km.key(Action::Run), "Mod+Enter"); assert_eq!(km.key(Action::OpenFinder), "Mod+O"); + assert_eq!(km.key(Action::SplitPaneRight), "Mod+Alt+R"); + assert_eq!(km.key(Action::SplitPaneDown), "Mod+Alt+D"); + assert_eq!(km.key(Action::ToggleSidePanel), "Mod+Alt+P"); } #[test] diff --git a/crates/plugins/src/app/mod.rs b/crates/plugins/src/app/mod.rs index 7f6612b6..73f1cde4 100644 --- a/crates/plugins/src/app/mod.rs +++ b/crates/plugins/src/app/mod.rs @@ -308,7 +308,7 @@ impl CoreApp { /// /// This includes internal commands and is deliberately broader than the public Extension API. pub async fn call(&self, name: &str, args: Value) -> Result { - self.app.ctx().call(name, args).await + self.plugin_manager.call(name, args).await } /// Invoke through one project's command realm, falling back to global commands when the @@ -319,20 +319,8 @@ impl CoreApp { name: &str, args: Value, ) -> Result { - let (project_path, _activity) = self - .plugin_manager - .lease_project_command(project_path) - .map_err(|error| KernelError::Config { - name: "plugin-manager".into(), - message: error.to_string(), - })?; - // Child-loader reconciliation is synchronous, while plugin application runs on the - // shared kernel driver. Settle it before dispatch so the first project call is real. - self.app.flush().await; - self.app - .ctx() - .with_command_realm(CommandRealm::project(project_path)) - .call(name, args) + self.plugin_manager + .call_in_project(project_path, name, args) .await } diff --git a/crates/plugins/src/app/plugin_manager.rs b/crates/plugins/src/app/plugin_manager.rs index 523bffa6..21bca066 100644 --- a/crates/plugins/src/app/plugin_manager.rs +++ b/crates/plugins/src/app/plugin_manager.rs @@ -305,6 +305,45 @@ impl PluginManager { Self::new_with_project_idle_ttl(loader, config, defaults, context, PROJECT_IDLE_TTL) } + fn root_context(&self) -> Result { + self.context.upgrade().ok_or_else(|| KernelError::Config { + name: "plugin-manager".into(), + message: PluginManagerError::RuntimeGone.to_string(), + }) + } + + /// Invoke a global command through the root Core context. + /// + /// Host transports use this seam so plugin-scoped contexts never become competing command + /// dispatch implementations. + pub async fn call(&self, name: &str, args: Value) -> Result { + self.root_context()?.call(name, args).await + } + + /// Invoke one project command with the same lazy graph, activity lease, flush, and fallback + /// behavior used by every host transport. + pub async fn call_in_project( + &self, + project_path: impl AsRef, + name: &str, + args: Value, + ) -> Result { + let (project_path, _activity) = + self.lease_project_command(project_path) + .map_err(|error| KernelError::Config { + name: "plugin-manager".into(), + message: error.to_string(), + })?; + // Child-loader reconciliation is synchronous, while plugin application settles on the + // shared driver. Flush before the first dispatch into a newly created project realm. + let context = self.root_context()?; + context.flush().await; + context + .with_command_realm(CommandRealm::project(project_path)) + .call(name, args) + .await + } + /// Construct a manager with a custom project idle timeout. /// /// This is primarily useful for deterministic host tests; production uses five minutes. diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 0d5ed780..5be378c3 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -25,9 +25,11 @@ codetwo-plugins.workspace = true tokio.workspace = true serde.workspace = true serde_json.workspace = true +async-trait.workspace = true uuid.workspace = true tracing.workspace = true axum = { version = "0.7", features = ["ws"] } +tower-http = { version = "0.6", features = ["fs"] } futures-util = "0.3" chrono = "0.4" libc = "0.2" diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 580cebcf..a34d3dbc 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -26,6 +26,7 @@ pub use auth::{AuthState, Device, DeviceInfo, Paired, DEFAULT_PAIRING_TTL, WS_TI use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr}; +use std::path::PathBuf; use std::sync::Arc; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; @@ -38,6 +39,7 @@ use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; use tokio::sync::{broadcast, mpsc}; +use tower_http::services::{ServeDir, ServeFile}; use codetwo_core::device_sync::DeviceSyncDocument; use codetwo_core::worktree::WorktreeBaseline; @@ -49,9 +51,11 @@ use codetwo_core::{ TaskBudget, TaskHandoffManager, TaskId, TaskStatus, TranscriptCursor, TranscriptEntry, DEFAULT_TRANSCRIPT_TURNS, }; +use codetwo_plugins::PluginManager; const MAX_HANDOFF_BODY_BYTES: usize = 384 * 1024 * 1024; const MAX_DEVICE_SYNC_BODY_BYTES: usize = 64 * 1024 * 1024; +const MAX_WEB_UI_CALL_BODY_BYTES: usize = 2 * 1024 * 1024; /// Transport-neutral device-sync surface supplied by a host plugin. /// @@ -67,6 +71,95 @@ pub trait DeviceSyncHttp: Send + Sync + 'static { ) -> Result; } +/// Host-owned command seam for the full React renderer running in a paired browser. +/// +/// The server authenticates and frames the request; the supplying host owns which commands are +/// exposed and dispatches them through the same Kernel context as its native renderer. +#[async_trait::async_trait] +pub trait WebUiCommandCaller: Send + Sync + 'static { + async fn call( + &self, + device_id: &str, + name: &str, + args: serde_json::Value, + project_path: Option, + ) -> Result; +} + +/// Shared host adapter for the paired full React renderer. +/// +/// Desktop Remote and the standalone server both use this adapter so browser capability policy +/// and root/project Kernel dispatch cannot drift between launch surfaces. +pub struct KernelWebUiCommands { + plugin_manager: Arc, +} + +impl KernelWebUiCommands { + pub fn new(plugin_manager: Arc) -> Self { + Self { plugin_manager } + } +} + +fn web_ui_command_allowed(name: &str) -> bool { + matches!( + name, + "providers.list" + | "projects.list" + | "workspace.default_cwd" + | "sessions.list" + | "sessions.archived" + | "sessions.previews" + | "sessions.transcript" + | "sessions.rename" + | "sessions.set_archived" + | "sessions.set_pinned" + | "engine.new_session" + | "engine.new_parallel_task" + | "engine.close_transient_session" + | "engine.prompt" + | "engine.prepare_session" + | "engine.queue" + | "engine.steer" + | "engine.answer_permission" + | "engine.answer_elicitation" + | "engine.set_permission_mode" + | "engine.set_execution_policy" + | "engine.set_sandbox" + | "engine.set_model" + | "engine.set_config_option" + | "engine.cancel" + ) +} + +#[async_trait::async_trait] +impl WebUiCommandCaller for KernelWebUiCommands { + async fn call( + &self, + _device_id: &str, + name: &str, + args: serde_json::Value, + project_path: Option, + ) -> Result { + if !web_ui_command_allowed(name) { + return Err(format!( + "command is unavailable in the browser renderer: {name}" + )); + } + + if let Some(project_path) = project_path { + self.plugin_manager + .call_in_project(project_path, name, args) + .await + .map_err(|error| error.to_string()) + } else { + self.plugin_manager + .call(name, args) + .await + .map_err(|error| error.to_string()) + } + } +} + #[derive(Debug, Clone, Serialize)] pub struct DeviceSyncIdentity { pub id: String, @@ -560,6 +653,7 @@ struct ServerState { terminals: Arc, handoff: Arc, device_sync: Option>, + web_ui_commands: Option>, } /// Forward the engine's single event receiver into a broadcast channel so multiple clients (and the @@ -619,6 +713,36 @@ pub async fn bind_and_serve_with_services( store: Arc, canvas_gate: CanvasFeatureGate, device_sync: Option>, +) -> std::io::Result<(SocketAddr, tokio::task::JoinHandle<()>)> { + bind_and_serve_with_web_ui( + engine, + events, + addr, + auth, + store, + canvas_gate, + device_sync, + None, + None, + ) + .await +} + +/// Bind the paired server with the host's optional full-renderer command adapter. +/// +/// The compact remote protocol remains available without this adapter. Supplying commands adds the +/// authenticated generic command route. Supplying a Web asset directory also replaces the compact +/// root with the existing React SPA while leaving protocol, terminal, and Canvas routes intact. +pub async fn bind_and_serve_with_web_ui( + engine: Arc, + events: broadcast::Sender, + addr: SocketAddr, + auth: Arc, + store: Arc, + canvas_gate: CanvasFeatureGate, + device_sync: Option>, + web_ui_commands: Option>, + web_ui_dir: Option, ) -> std::io::Result<(SocketAddr, tokio::task::JoinHandle<()>)> { let t3 = Arc::new( t3_compat::T3CompatState::new(engine.clone(), events.clone(), auth.clone()) @@ -638,6 +762,7 @@ pub async fn bind_and_serve_with_services( terminals: Arc::new(terminal::TerminalRegistry::default()), handoff, device_sync, + web_ui_commands, }); let handoff_routes = Router::new() .route("/api/codetwo/handoffs", post(accept_handoff)) @@ -656,9 +781,11 @@ pub async fn bind_and_serve_with_services( ) .with_state(state.clone()) .layer(DefaultBodyLimit::max(MAX_DEVICE_SYNC_BODY_BYTES)); + let web_ui_routes = Router::new() + .route("/api/web-ui/call", post(web_ui_call)) + .with_state(state.clone()) + .layer(DefaultBodyLimit::max(MAX_WEB_UI_CALL_BODY_BYTES)); let app = Router::new() - .route("/", get(index)) - .route("/pair", get(index)) .route("/terminal", get(index)) .route("/health", get(|| async { "ok" })) .route("/api/pair", post(pair)) @@ -732,7 +859,14 @@ pub async fn bind_and_serve_with_services( .merge(handoff_routes) .merge(device_sync_pair_route) .merge(device_sync_snapshot_routes) - .layer(axum::middleware::map_response(no_store_headers)); + .merge(web_ui_routes); + let app = if let Some(web_ui_dir) = web_ui_dir { + let index = web_ui_dir.join("index.html"); + app.fallback_service(ServeDir::new(web_ui_dir).not_found_service(ServeFile::new(index))) + } else { + app.route("/", get(index)).route("/pair", get(index)) + } + .layer(axum::middleware::map_response(no_store_headers)); let listener = TcpListener::bind(addr).await?; let local = listener.local_addr()?; @@ -742,6 +876,44 @@ pub async fn bind_and_serve_with_services( Ok((local, handle)) } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct WebUiCallBody { + name: String, + #[serde(default)] + args: serde_json::Value, + #[serde(default)] + project_path: Option, +} + +async fn web_ui_call( + State(st): State>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + let Some(commands) = &st.web_ui_commands else { + return (StatusCode::NOT_FOUND, "C2 Web UI commands are unavailable").into_response(); + }; + let device_id = match require_device(&st, &headers) { + Ok(device_id) => device_id, + Err(response) => return response, + }; + if body.name.is_empty() || body.name.len() > 160 { + return (StatusCode::BAD_REQUEST, "C2 Web UI command name is invalid").into_response(); + } + match commands + .call(&device_id, &body.name, body.args, body.project_path) + .await + { + Ok(result) => Json(serde_json::json!({ "result": result })).into_response(), + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": error })), + ) + .into_response(), + } +} + #[derive(Deserialize)] struct PairC2DeviceBody { token: String, @@ -2530,8 +2702,9 @@ const INDEX_HTML: &str = include_str!("client.html"); #[cfg(test)] mod tests { use super::{ - DeviceSyncHttp, DeviceSyncIdentity, DeviceSyncReplica, DeviceSyncWriteResult, - DeviceSyncWriteState, Outbound, PairingEndpoint, TranscriptCursor, TranscriptEntry, + web_ui_command_allowed, DeviceSyncHttp, DeviceSyncIdentity, DeviceSyncReplica, + DeviceSyncWriteResult, DeviceSyncWriteState, Outbound, PairingEndpoint, TranscriptCursor, + TranscriptEntry, }; use codetwo_core::device_sync::{device_sync_snapshot_version, DeviceSyncDocument}; use codetwo_core::provider::ProviderId; @@ -2551,6 +2724,16 @@ mod tests { } } + #[test] + fn browser_renderer_has_one_bounded_core_capability_set() { + assert!(web_ui_command_allowed("sessions.list")); + assert!(web_ui_command_allowed("engine.prompt")); + assert!(web_ui_command_allowed("workspace.default_cwd")); + assert!(!web_ui_command_allowed("plugins.set_trusted")); + assert!(!web_ui_command_allowed("remote.stop")); + assert!(!web_ui_command_allowed("workspace.delete")); + } + fn git(cwd: &std::path::Path, args: &[&str]) { let output = Command::new("git") .args(args) diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 1920390b..a9f7a04e 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,73 +1,381 @@ -//! `codetwo-server` — run the engine headless and expose it for remote control. +//! `codetwo-server` — run one C2 Core with either the compact remote or the full React Web UI. //! //! Env: `CODETWO_HOST` (default 0.0.0.0), `CODETWO_PORT` (default 4599), `CODETWO_PAIR_TTL` -//! (pairing-token lifetime in seconds, default 900). Shares `~/.codetwo/codetwo.db` with the -//! desktop app; paired devices persist in `~/.codetwo/remote-devices.json`. +//! (pairing-token lifetime in seconds, default 900), `CODETWO_DATA_DIR`, and +//! `CODETWO_WEB_UI_DIR`. use std::net::SocketAddr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::Arc; use std::time::Duration; -use codetwo_plugins::{AppConfig, CanvasService, CoreApp, EngineService, EventBus, StoreService}; -use codetwo_server::{bind_and_serve_with_canvas, print_pairing, AuthState, DEFAULT_PAIRING_TTL}; +use codetwo_plugins::{ + AppConfig, CanvasService, CoreApp, EngineService, EventBus, PluginManager, StoreService, +}; +use codetwo_server::{ + bind_and_serve_with_canvas, bind_and_serve_with_web_ui, pairing_endpoints, + pairing_url_for_endpoint, print_pairing, AuthState, KernelWebUiCommands, DEFAULT_PAIRING_TTL, +}; -fn data_dir() -> PathBuf { +const HELP: &str = r#"Usage: + codetwo-server + codetwo-server webui [--ui-dir ] [--data-dir ] [--no-open] + +Commands: + webui Serve the shared React UI and open its one-time pairing link. + +Options: + --ui-dir Vite Web build directory. Defaults to CODETWO_WEB_UI_DIR or + a web-ui directory next to the executable. + --data-dir + Standalone C2 data directory. Defaults to CODETWO_DATA_DIR or ~/.codetwo. + --no-open Print the pairing link without opening a browser. + -h, --help Show this help. + +Network environment: + CODETWO_HOST, CODETWO_PORT, CODETWO_PAIR_TTL +"#; + +#[derive(Debug, PartialEq, Eq)] +enum Surface { + Compact, + WebUi, +} + +#[derive(Debug, PartialEq, Eq)] +struct Cli { + surface: Surface, + ui_dir: Option, + data_dir: Option, + open_browser: bool, +} + +enum Parsed { + Run(Cli), + Help, +} + +fn parse_args(arguments: impl IntoIterator) -> Result { + let mut arguments = arguments.into_iter(); + let Some(first) = arguments.next() else { + return Ok(Parsed::Run(Cli { + surface: Surface::Compact, + ui_dir: None, + data_dir: None, + open_browser: false, + })); + }; + if first == "--help" || first == "-h" { + if arguments.next().is_some() { + return Err("--help does not accept additional arguments".into()); + } + return Ok(Parsed::Help); + } + if first != "webui" { + return Err(format!("unknown command: {first}\n\n{HELP}")); + } + + let mut cli = Cli { + surface: Surface::WebUi, + ui_dir: None, + data_dir: None, + open_browser: true, + }; + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--ui-dir" => { + let path = arguments + .next() + .ok_or_else(|| "--ui-dir requires a path".to_string())?; + cli.ui_dir = Some(PathBuf::from(path)); + } + "--data-dir" => { + let path = arguments + .next() + .ok_or_else(|| "--data-dir requires a path".to_string())?; + cli.data_dir = Some(PathBuf::from(path)); + } + "--no-open" => cli.open_browser = false, + "--help" | "-h" => return Ok(Parsed::Help), + _ => return Err(format!("unknown webui option: {argument}\n\n{HELP}")), + } + } + Ok(Parsed::Run(cli)) +} + +fn default_data_dir() -> PathBuf { let home = codetwo_core::provider::home_dir().unwrap_or_else(std::env::temp_dir); home.join(".codetwo") } -#[tokio::main] -async fn main() -> std::io::Result<()> { +fn resolve_data_dir(explicit: Option) -> PathBuf { + explicit + .or_else(|| std::env::var_os("CODETWO_DATA_DIR").map(PathBuf::from)) + .unwrap_or_else(default_data_dir) +} + +fn resolve_ui_dir( + explicit: Option, + configured: Option, + executable: &Path, +) -> Result { + let candidate = explicit + .or(configured) + .or_else(|| executable.parent().map(|parent| parent.join("web-ui"))) + .ok_or_else(|| "cannot resolve the C2 Web UI directory".to_string())?; + if !candidate.join("index.html").is_file() { + return Err(format!( + "C2 Web UI assets are missing at {}. Run ./script/build/hosts.sh release or pass --ui-dir .", + candidate.display() + )); + } + candidate.canonicalize().map_err(|error| { + format!( + "cannot resolve C2 Web UI assets at {}: {error}", + candidate.display() + ) + }) +} + +fn local_pairing_url(port: u16, pairing_token: &str) -> String { + let endpoints = pairing_endpoints(port); + let endpoint = endpoints + .iter() + .find(|endpoint| endpoint.id == "loopback") + .or_else(|| endpoints.first()) + .expect("pairing endpoints include loopback"); + pairing_url_for_endpoint(&endpoint.url, pairing_token) +} + +fn open_browser(url: &str) -> std::io::Result<()> { + #[cfg(target_os = "macos")] + let mut command = { + let mut command = Command::new("open"); + command.arg(url); + command + }; + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("cmd"); + command.args(["/C", "start", "", url]); + command + }; + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = { + let mut command = Command::new("xdg-open"); + command.arg(url); + command + }; + command.spawn().map(|_| ()) +} + +async fn run(cli: Cli) -> Result<(), String> { + let web_ui_dir = if cli.surface == Surface::WebUi { + let executable = std::env::current_exe() + .map_err(|error| format!("cannot resolve codetwo-server executable: {error}"))?; + Some(resolve_ui_dir( + cli.ui_dir, + std::env::var_os("CODETWO_WEB_UI_DIR").map(PathBuf::from), + &executable, + )?) + } else { + None + }; + let host = std::env::var("CODETWO_HOST").unwrap_or_else(|_| "0.0.0.0".into()); let port: u16 = std::env::var("CODETWO_PORT") .ok() - .and_then(|s| s.parse().ok()) + .and_then(|value| value.parse().ok()) .unwrap_or(4599); let pair_ttl = std::env::var("CODETWO_PAIR_TTL") .ok() - .and_then(|s| s.parse().ok()) + .and_then(|value| value.parse().ok()) .map(Duration::from_secs) .unwrap_or(DEFAULT_PAIRING_TTL); - let dir = data_dir(); - std::fs::create_dir_all(&dir)?; - let core = CoreApp::boot(AppConfig::new(&dir)) - .await - .map_err(std::io::Error::other)?; + let data_dir = resolve_data_dir(cli.data_dir); + std::fs::create_dir_all(&data_dir).map_err(|error| { + format!( + "cannot create data directory {}: {error}", + data_dir.display() + ) + })?; + let core = Arc::new( + CoreApp::boot(AppConfig::new(&data_dir)) + .await + .map_err(|error| error.to_string())?, + ); let engine = core .service::() - .ok_or_else(|| std::io::Error::other("engine plugin did not load"))? + .ok_or_else(|| "engine plugin did not load".to_string())? .0 .clone(); let store = core .service::() - .ok_or_else(|| std::io::Error::other("store plugin did not load"))? + .ok_or_else(|| "store plugin did not load".to_string())? .0 .clone(); let events = core .service::() - .ok_or_else(|| std::io::Error::other("bus plugin did not load"))? + .ok_or_else(|| "bus plugin did not load".to_string())? .0 .clone(); let canvas_gate = core .service::() - .ok_or_else(|| std::io::Error::other("canvas plugin did not load"))? + .ok_or_else(|| "canvas service did not load".to_string())? .gate; - let auth = Arc::new(AuthState::load(Some(dir.join("remote-devices.json")))); + let auth = Arc::new(AuthState::load(Some(data_dir.join("remote-devices.json")))); let pairing_token = auth.issue_pairing_token(pair_ttl); + let addr: SocketAddr = format!("{host}:{port}") + .parse() + .map_err(|error| format!("invalid C2 server address {host}:{port}: {error}"))?; - let addr: SocketAddr = format!("{host}:{port}").parse().expect("valid host:port"); - let (local, handle) = - bind_and_serve_with_canvas(engine, events, addr, auth.clone(), store, canvas_gate).await?; + let (local, handle) = if let Some(web_ui_dir) = web_ui_dir { + let plugin_manager = core + .service::() + .ok_or_else(|| "plugin manager did not load".to_string())?; + bind_and_serve_with_web_ui( + engine, + events, + addr, + auth.clone(), + store, + canvas_gate, + None, + Some(Arc::new(KernelWebUiCommands::new(plugin_manager))), + Some(web_ui_dir), + ) + .await + .map_err(|error| error.to_string())? + } else { + bind_and_serve_with_canvas(engine, events, addr, auth.clone(), store, canvas_gate) + .await + .map_err(|error| error.to_string())? + }; print_pairing(local.port(), &pairing_token); let paired = auth.list_devices().len(); if paired > 0 { println!(" {paired} previously paired device(s) can reconnect without a new link.\n"); } - println!(" listening on {local}\n"); + println!(" listening on {local}"); + println!(" data directory: {}\n", data_dir.display()); + + if cli.surface == Surface::WebUi && cli.open_browser { + let url = local_pairing_url(local.port(), &pairing_token); + if let Err(error) = open_browser(&url) { + eprintln!(" could not open the browser: {error}"); + eprintln!(" open this URL manually: {url}\n"); + } + } + + let _core = core; let _ = handle.await; Ok(()) } + +#[tokio::main] +async fn main() { + let parsed = match parse_args(std::env::args().skip(1)) { + Ok(parsed) => parsed, + Err(error) => { + eprintln!("codetwo-server: {error}"); + std::process::exit(2); + } + }; + match parsed { + Parsed::Help => print!("{HELP}"), + Parsed::Run(cli) => { + if let Err(error) = run(cli).await { + eprintln!("codetwo-server: {error}"); + std::process::exit(1); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{parse_args, resolve_ui_dir, Cli, Parsed, Surface}; + use std::path::PathBuf; + + fn parsed(arguments: &[&str]) -> Cli { + match parse_args(arguments.iter().map(|argument| argument.to_string())).unwrap() { + Parsed::Run(cli) => cli, + Parsed::Help => panic!("expected runnable CLI"), + } + } + + #[test] + fn no_arguments_preserve_the_compact_remote() { + assert_eq!( + parsed(&[]), + Cli { + surface: Surface::Compact, + ui_dir: None, + data_dir: None, + open_browser: false, + } + ); + } + + #[test] + fn webui_accepts_only_its_small_launch_interface() { + assert_eq!( + parsed(&[ + "webui", + "--ui-dir", + "/tmp/ui", + "--data-dir", + "/tmp/data", + "--no-open", + ]), + Cli { + surface: Surface::WebUi, + ui_dir: Some(PathBuf::from("/tmp/ui")), + data_dir: Some(PathBuf::from("/tmp/data")), + open_browser: false, + } + ); + assert!(parse_args(["webui".into(), "--ui-dir".into()]).is_err()); + assert!(parse_args(["unknown".into()]).is_err()); + } + + #[test] + fn webui_assets_resolve_explicit_then_configured_then_adjacent() { + let root = tempfile::tempdir().unwrap(); + let explicit = root.path().join("explicit"); + let configured = root.path().join("configured"); + let adjacent = root.path().join("web-ui"); + for directory in [&explicit, &configured, &adjacent] { + std::fs::create_dir_all(directory).unwrap(); + std::fs::write(directory.join("index.html"), "ok").unwrap(); + } + let executable = root.path().join("codetwo-server"); + + assert_eq!( + resolve_ui_dir( + Some(explicit.clone()), + Some(configured.clone()), + &executable + ) + .unwrap(), + explicit.canonicalize().unwrap() + ); + assert_eq!( + resolve_ui_dir(None, Some(configured.clone()), &executable).unwrap(), + configured.canonicalize().unwrap() + ); + assert_eq!( + resolve_ui_dir(None, None, &executable).unwrap(), + adjacent.canonicalize().unwrap() + ); + assert!( + resolve_ui_dir(Some(root.path().join("missing")), None, &executable) + .unwrap_err() + .contains("--ui-dir") + ); + } +} diff --git a/crates/server/tests/web_ui_commands.rs b/crates/server/tests/web_ui_commands.rs new file mode 100644 index 00000000..a62ce1f2 --- /dev/null +++ b/crates/server/tests/web_ui_commands.rs @@ -0,0 +1,229 @@ +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use codetwo_core::canvas::CanvasFeatureGate; +use codetwo_core::skill::{builtin_skills, SkillLibrary}; +use codetwo_core::{Engine, Store}; +use codetwo_server::{bind_and_serve_with_web_ui, fanout, AuthState, WebUiCommandCaller}; +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[derive(Debug, PartialEq)] +struct RecordedCall { + device_id: String, + name: String, + args: Value, + project_path: Option, +} + +#[derive(Default)] +struct RecordingCaller { + calls: Mutex>, +} + +#[async_trait::async_trait] +impl WebUiCommandCaller for RecordingCaller { + async fn call( + &self, + device_id: &str, + name: &str, + args: Value, + project_path: Option, + ) -> Result { + self.calls.lock().unwrap().push(RecordedCall { + device_id: device_id.to_string(), + name: name.to_string(), + args: args.clone(), + project_path: project_path.clone(), + }); + if name == "test.reject" { + return Err("host rejected the command".into()); + } + Ok(json!({ + "name": name, + "args": args, + "project_path": project_path, + })) + } +} + +async fn http(addr: SocketAddr, bearer: Option<&str>, body: &str) -> (u16, String) { + let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); + let auth = bearer + .map(|value| format!("Authorization: Bearer {value}\r\n")) + .unwrap_or_default(); + let request = format!( + "POST /api/web-ui/call HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\nContent-Type: application/json\r\n{auth}Content-Length: {}\r\n\r\n{body}", + body.len() + ); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut raw = Vec::new(); + stream.read_to_end(&mut raw).await.unwrap(); + let text = String::from_utf8_lossy(&raw).to_string(); + let status = text + .split_whitespace() + .nth(1) + .and_then(|value| value.parse().ok()) + .unwrap_or_default(); + let body = text + .split_once("\r\n\r\n") + .map(|(_, body)| body.to_string()) + .unwrap_or_default(); + (status, body) +} + +async fn server( + auth: Arc, + caller: Option>, +) -> (SocketAddr, tokio::task::JoinHandle<()>) { + server_with_assets(auth, caller, None).await +} + +async fn server_with_assets( + auth: Arc, + caller: Option>, + web_ui_dir: Option, +) -> (SocketAddr, tokio::task::JoinHandle<()>) { + let store = Arc::new(Store::open_in_memory().unwrap()); + let (engine, receiver) = Engine::with_store( + codetwo_core::provider::default_registry(), + SkillLibrary::new(builtin_skills()), + store.clone(), + ); + bind_and_serve_with_web_ui( + Arc::new(engine), + fanout(receiver), + "127.0.0.1:0".parse().unwrap(), + auth, + store, + CanvasFeatureGate::default(), + None, + caller, + web_ui_dir, + ) + .await + .unwrap() +} + +#[tokio::test] +async fn full_web_assets_replace_only_the_compact_spa_routes() { + let assets = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(assets.path().join("assets")).unwrap(); + std::fs::write( + assets.path().join("index.html"), + "Full Web UI fixture", + ) + .unwrap(); + std::fs::write( + assets.path().join("assets/app.js"), + "export default 'web-ui';", + ) + .unwrap(); + let (full_addr, full_handle) = server_with_assets( + Arc::new(AuthState::load(None)), + None, + Some(assets.path().to_path_buf()), + ) + .await; + + let root = reqwest::get(format!("http://{full_addr}/")) + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(root.contains("Full Web UI fixture")); + let deep_link = reqwest::get(format!("http://{full_addr}/pair")) + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(deep_link.contains("Full Web UI fixture")); + let script = reqwest::get(format!("http://{full_addr}/assets/app.js")) + .await + .unwrap(); + assert_eq!( + script.headers().get(reqwest::header::CONTENT_TYPE).unwrap(), + "text/javascript" + ); + assert!(script.text().await.unwrap().contains("web-ui")); + assert_eq!( + reqwest::get(format!("http://{full_addr}/health")) + .await + .unwrap() + .text() + .await + .unwrap(), + "ok" + ); + full_handle.abort(); + + let (compact_addr, compact_handle) = server(Arc::new(AuthState::load(None)), None).await; + let compact = reqwest::get(format!("http://{compact_addr}/")) + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(!compact.contains("Full Web UI fixture")); + assert!(compact.contains("C2")); + compact_handle.abort(); +} + +#[tokio::test] +async fn browser_commands_require_a_non_member_device_and_preserve_call_context() { + let auth = Arc::new(AuthState::load(None)); + let token = auth.issue_pairing_token(Duration::from_secs(60)); + let paired = auth.pair(&token, "Web UI").unwrap(); + let member_token = auth.issue_member_pairing_token("member-1", Duration::from_secs(60)); + let member = auth.pair(&member_token, "Team member").unwrap(); + let caller = Arc::new(RecordingCaller::default()); + let (addr, handle) = server(auth, Some(caller.clone())).await; + let body = json!({ + "name": "sessions.transcript", + "args": { "session": "session-1", "limit": 20 }, + "project_path": "/projects/alpha", + }) + .to_string(); + + assert_eq!(http(addr, None, &body).await.0, 401); + assert_eq!(http(addr, Some(&member.bearer), &body).await.0, 403); + + let (status, response) = http(addr, Some(&paired.bearer), &body).await; + assert_eq!(status, 200, "command failed: {response}"); + let response: Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["result"]["name"], "sessions.transcript"); + assert_eq!(response["result"]["args"]["session"], "session-1"); + assert_eq!(response["result"]["project_path"], "/projects/alpha"); + + let calls = caller.calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!( + calls[0], + RecordedCall { + device_id: paired.device_id, + name: "sessions.transcript".into(), + args: json!({ "session": "session-1", "limit": 20 }), + project_path: Some("/projects/alpha".into()), + } + ); + drop(calls); + + let rejected = json!({ "name": "test.reject", "args": null }).to_string(); + let (status, response) = http(addr, Some(&paired.bearer), &rejected).await; + assert_eq!(status, 400); + assert!(response.contains("host rejected the command")); + handle.abort(); +} + +#[tokio::test] +async fn browser_command_route_fails_closed_without_a_host_adapter() { + let auth = Arc::new(AuthState::load(None)); + let (addr, handle) = server(auth, None).await; + let (status, response) = http(addr, None, r#"{"name":"sessions.list"}"#).await; + assert_eq!(status, 404); + assert!(response.contains("commands are unavailable")); + handle.abort(); +} diff --git a/docs/sdlc/changes/2026-08-31-group-session-toolbar-actions/change.md b/docs/sdlc/changes/2026-08-31-group-session-toolbar-actions/change.md index 352d1a40..88309911 100644 --- a/docs/sdlc/changes/2026-08-31-group-session-toolbar-actions/change.md +++ b/docs/sdlc/changes/2026-08-31-group-session-toolbar-actions/change.md @@ -8,15 +8,15 @@ owner: codex approvers: [user] approved_at: 2026-08-31 created: 2026-08-31 -updated: 2026-08-31 -source: user-supplied session-toolbar screenshots and iterative visual feedback on 2026-08-31 +updated: 2026-09-02 +source: user-supplied session-toolbar screenshots and iterative visual feedback on 2026-08-31; View-menu browser comment requesting shortcuts on 2026-09-02 inputs: screenshot feedback, accepted three-group layout, existing titlebar toolbar contract -outputs: independent filled primary actions, icon-only secondary controls, consolidated View menu, rail divider removal, and rendered evidence -scope: apps/desktop/src/App.tsx, apps/desktop/src/environment/EnvironmentPopover.tsx, apps/desktop/src/i18n/strings.ts, apps/desktop/src/session/PaneChrome.tsx, apps/desktop/src/session/SessionHeaderActions.tsx, apps/desktop/src/styles.css, apps/desktop/tests/environmentPopoverRendered.test.tsx, apps/desktop/tests/paneChrome.test.tsx, apps/desktop/tests/sessionHeaderActionsRendered.test.tsx, apps/desktop/tests/windowChromeContract.test.ts, docs/sdlc/changes/2026-08-31-group-session-toolbar-actions.md, docs/sdlc/changes/2026-08-31-group-session-toolbar-actions +outputs: independent filled primary actions, icon-only secondary controls, consolidated View menu, configurable View-command shortcuts, rail divider removal, and rendered evidence +scope: apps/desktop/src/App.tsx, apps/desktop/src/bridge.ts, apps/desktop/src/environment/EnvironmentPopover.tsx, apps/desktop/src/i18n/strings.ts, apps/desktop/src/session/PaneChrome.tsx, apps/desktop/src/session/SessionHeaderActions.tsx, apps/desktop/src/settings/PersonalSettings.tsx, apps/desktop/src/styles.css, apps/desktop/tests/environmentPopoverRendered.test.tsx, apps/desktop/tests/paneChrome.test.tsx, apps/desktop/tests/sessionHeaderActionsRendered.test.tsx, apps/desktop/tests/windowChromeContract.test.ts, crates/core/src/keymap.rs, docs/sdlc/changes/2026-08-31-group-session-toolbar-actions.md, docs/sdlc/changes/2026-08-31-group-session-toolbar-actions next_trigger: pull request review and explicit merge approval verification_mode: owner verified_by: codex -verified_at: 2026-08-31 +verified_at: 2026-09-02 --- # Clarify the session toolbar hierarchy @@ -42,9 +42,11 @@ complete pull-down button apiece, without a separate trailing chevron. Present plugin, environment, and View as quiet icon-only toolbar controls beside the primary action set. Keep their accessible names and tooltips. View retains one menu for split, conditional close, -and side-panel commands. Keep 8px inside the context group and 16px between context, task, and -layout groups. Below the compact breakpoint, hide primary labels and remove resting fills so each -action becomes a 28px bare icon. +and side-panel commands. Show each persistent View command's live keymap binding at the trailing +edge of its menu row, and let those same bindings invoke the focused-pane or side-panel action. +Keep 8px inside the context group and 16px between context, task, and layout groups. Below the +compact breakpoint, hide primary labels and remove resting fills so each action becomes a 28px bare +icon. Remove only the horizontal hairline between the session rail title row and its search/content area. Keep the rail's vertical edge, the session header's content-dependent divider, and unrelated @@ -66,6 +68,10 @@ boundaries. remain intact. - [x] AC-7: Focused tests, renderer build, lifecycle checks, and diff hygiene pass without relevant runtime errors; the documentation check is run and any inherited base failure is recorded. +- [x] AC-8: Split right, Split down, and Side panel show their current customizable keymap bindings + in the View menu, including user overrides. +- [x] AC-9: The three displayed shortcuts execute against the focused pane or current side-panel + state, and rendered-browser interaction plus focused keymap tests pass. ## Decision and gates @@ -81,6 +87,8 @@ deployment, and production mutation remain separate pending Gates. 2. Keep plugin, environment, and View icon-only with existing accessible labels and menu behavior. 3. Protect hierarchy and interaction with focused tests, then validate standard, narrow, light, and dark renderer states plus repository Gates. +4. Extend the shared Core keymap with View commands, dispatch them through the existing renderer + shortcut handler, and render the live bindings with the existing menu-shortcut primitive. Rollback reverts the scoped titlebar, pane-chrome, responsive-style, and test changes. It does not affect stored sessions, pane layout data, or repository data. @@ -98,11 +106,33 @@ affect stored sessions, pane layout data, or repository data. - The implementation was rebased onto `origin/main` at `a224a752`. Conflict resolution preserved main's Feishu-page suppression, pet conversation work, and semantic radius while retaining the accepted toolbar hierarchy. +- The 2026-09-02 follow-up adds Split pane right, Split pane down, and Toggle side panel to the + shared Core keymap with `Mod+Alt+R`, `Mod+Alt+D`, and `Mod+Alt+P` defaults. Existing keymap loading + layers user overrides over those defaults, so the menu and settings page stay synchronized. +- `PaneLayoutToolbar` reuses the repository's `DropdownMenuShortcut` primitive. `App` routes the + three actions through the existing global key dispatcher and shares one side-panel callback + between the menu and keyboard path. ## Verification Verdict: verified. +- 2026-09-02 focused checks: `bun test ./tests/paneChrome.test.tsx` — 5 passed, 0 failed, + 35 expectations; `cargo test -p codetwo-core keymap` — 3 passed, 0 failed; `bunx tsc --noEmit` + passed; and exact `rustfmt --check --edition 2021 crates/core/src/keymap.rs` passed. +- `bun run build:renderer` passed ESLint, Stylelint, TypeScript, and the 6,604-module production + build. The existing large-chunk advisory remains non-failing. +- Browser QA at `http://127.0.0.1:4599/` rendered `⌘⌥R`, `⌘⌥D`, and `⌘⌥P` at the trailing edge of + the View menu. Real keyboard input produced a 50/50 right split, then split only the focused right + pane into 50/50 top and bottom panes. Side panel input toggled the Dock between 339px and 0px. +- The browser-preview transport remained intentionally unpaired, so its existing + `C2 Web UI is not paired` conversation-load error was visible; menu and local-layout interaction + remained available and no new UI runtime error appeared. +- `bun script/verify/docs.ts`, `bun script/verify/sdlc.ts`, + `bun script/verify/sdlc.ts --worktree`, and `git diff --check` passed. Whole-workspace + `cargo fmt --check` still reports pre-existing formatting drift in unchanged Rust files; the + changed keymap file passes exact rustfmt validation. + - Focused post-rebase command: `bun test apps/desktop/tests/tabsToolbarRendered.test.tsx apps/desktop/tests/sessionHeaderActionsRendered.test.tsx apps/desktop/tests/paneChrome.test.tsx @@ -147,6 +177,10 @@ Verdict: verified. - AC-7: PASS — `bun run build:renderer`, `bun script/verify/sdlc.ts --worktree`, and `git diff --check origin/main...HEAD` passed; `bun script/verify/docs.ts` is recorded separately because current `origin/main` has 16 unclassified website evidence images. +- AC-8: PASS — `bun test ./tests/paneChrome.test.tsx` covers the rendered View hints, and the + settings page lists the same three shared actions for user rebinding. +- AC-9: PASS — `cargo test -p codetwo-core keymap` covers the shared defaults; browser keypress + evidence proves focused right/down splitting and side-panel open/close behavior. Residual risk: truly compact windows necessarily return to multiple icons; accessible names and tooltips carry distinction there. Multiple third-party plugin actions can look similar at that @@ -156,6 +190,7 @@ width. Native Core behavior is outside this renderer-only visual change. Approval: implementation, final screenshot review, and PR creation were authorized by the user. Review surface: [PR #198](https://github.com/IchenDEV/codeTwo/pull/198). +Follow-up review surface: [Draft PR #219](https://github.com/IchenDEV/codeTwo/pull/219). Release target: none requested. Release identity: not applicable until released. Smoke evidence: renderer evidence is recorded above. diff --git a/docs/sdlc/changes/2026-08-31-prevent-list-header-tab-overflow/change.md b/docs/sdlc/changes/2026-08-31-prevent-list-header-tab-overflow/change.md index fbed611e..49f3dbd9 100644 --- a/docs/sdlc/changes/2026-08-31-prevent-list-header-tab-overflow/change.md +++ b/docs/sdlc/changes/2026-08-31-prevent-list-header-tab-overflow/change.md @@ -8,15 +8,15 @@ owner: codex approvers: user via the 2026-08-31 screenshot feedback and PR-and-merge request approved_at: 2026-08-31 created: 2026-08-31 -updated: 2026-08-31 -source: user-supplied clipping and alignment screenshots plus direct remediation requests on 2026-08-31 +updated: 2026-09-02 +source: user-supplied clipping and alignment screenshots plus direct remediation requests on 2026-08-31; Plugin Manager tab spacing and control-stack inset annotations on 2026-09-02 inputs: screenshots, live checkout, existing split-panel layout, layout specification, and design tokens -outputs: responsive aligned list controls, focused regression coverage, and rendered narrow-state evidence +outputs: responsive aligned list controls, consistent Plugin Manager tab spacing and compact control-stack inset, focused regression coverage, and rendered narrow-state evidence scope: apps/desktop, docs/sdlc/changes/2026-08-31-prevent-list-header-tab-overflow -next_trigger: the authorized pull request passes required checks and merges into origin/main +next_trigger: pull request review and explicit merge approval verification_mode: owner verified_by: codex -verified_at: 2026-08-31 +verified_at: 2026-09-02 --- # Prevent list-header tabs from clipping in narrow panes @@ -73,6 +73,10 @@ Docker layouts keep their existing page/chrome grids because they are not split- an explicit shell exception. - [x] AC-7: Pull requests separates its title/action row from its view/search controls without changing tab, filter, refresh, selection, or compact list/detail behavior. +- [x] AC-8: Every Plugin Manager category tab uses 4px inline padding and adjacent tabs use a 4px + gap at the annotated list-pane width, without clipping or horizontal overflow. +- [x] AC-9: The Plugin Manager category-and-search control stack uses 8px left and right padding at + the annotated viewport without changing list cards, detail layout, interaction, or overflow behavior. ## Decision and gates @@ -89,6 +93,13 @@ the same change to the peer Pull requests split-list surface and one shared 32px The user's 2026-08-31 request, “pr & merge,” separately authorizes PR creation and merge after the required repository checks pass; it does not authorize a product release or deployment. +The user's 2026-09-02 browser annotations accept reopening this change for one local spacing +correction: 4px inline padding on each Plugin Manager category tab and a larger 4px inter-tab gap. + +The user's later 2026-09-02 browser annotation accepts reopening this change for an additional +local correction: reduce only the Plugin Manager category-and-search control stack's inline inset +from 16px to 8px, preserving its existing responsive behavior and adjacent surfaces. + ## Plan 1. Separate each affected title/action row from its filter or category row while retaining existing @@ -101,6 +112,10 @@ required repository checks pass; it does not authorize a product release or depl 4px grid; correct their shared optical line with existing spacing tokens and repeat rendered QA. 5. Record one 32px split-list content line in the layout specification, apply it to the three peer workbenches, and keep unrelated full-canvas page shells outside this rule. +6. Replace the Plugin Manager compact row's asymmetric 8px/2px tab padding and 2px gap with the + existing 4px inline spacing token, preserving the 24rem count-hiding breakpoint. +7. Replace the Plugin Manager category-and-search stack's 16px inline padding with the existing + 8px spacing utility, then repeat focused and rendered checks at the annotated viewport. Rollback restores the two prior single-row headers and page-level plugin breakpoint rules; no data, configuration, or external state is changed. @@ -110,6 +125,9 @@ configuration, or external state is changed. [PR #191](https://github.com/IchenDEV/codeTwo/pull/191) carries the scoped implementation and its schema-2 lifecycle record. +[PR #219](https://github.com/IchenDEV/codeTwo/pull/219) carries the 2026-09-02 Web UI follow-up and +the local Plugin Manager spacing correction. + - Automations keeps its 48px title/action row and renders the existing accessible filter group plus search field in a dedicated list-control stack below it. The macOS safe-area class remains on the actual titlebar and the create action retains its accessible name and behavior. @@ -130,6 +148,12 @@ schema-2 lifecycle record. padding keep every label inside the control width without restoring a horizontal scroller. - Focused assertions protect the alignment-token classes, tab-label measurement hooks, compact end padding, title/control separation, and existing leading-action exception. +- The 2026-09-02 follow-up replaces Plugin Manager's asymmetric 8px/2px category padding and 2px + compact gap with the existing 4px inline token for every tab and every adjacent gap. The 24rem + container breakpoint continues to hide only numeric counts. +- The later 2026-09-02 follow-up replaces only the Plugin Manager category-and-search control + stack's 16px inline inset with the existing 8px spacing utility. List cards, the detail pane, + category-tab spacing, and responsive breakpoints remain unchanged. ## Verification @@ -171,6 +195,22 @@ Verdict: verified. and `bun script/verify/sdlc.ts` then passed. The first `--worktree` pass correctly rejected the transitional deletion of the pre-rebase flat Artifact until the migration was folded into the branch commit. +- The 2026-09-02 focused follow-up passed 18 tests and 127 expectations in + `pluginManagerRendered.test.tsx`, plus `bunx tsc --noEmit` and a fresh static Web UI build. + Browser measurement at the annotated 1247x1576 viewport found 4px left/right padding on all five + category tabs, four exact 4px adjacent gaps, and a tab list whose client and scroll widths both + measured 310px. All five categories remained clickable and ArrowRight moved focus from Hooks to + Marketplace. The only console errors were the existing unpaired-static-Web-UI transport errors. +- The later 2026-09-02 focused follow-up passed 18 tests and 129 expectations in + `pluginManagerRendered.test.tsx`. `bun run build:renderer` passed ESLint, Stylelint, TypeScript, + and the 6,604-module production Vite build; a fresh static Web UI build also passed with the same + module count and existing large-chunk advisory. Because the Browser plugin was not available in + this environment, local Playwright measured the rendered page at the annotated 1247x1576 + viewport: the control stack had exactly 8px left and right padding, the category row retained + matching 310px client/scroll widths, and the document retained matching 1247px client/scroll + widths with no framework overlay. Hooks selection and its `Search hooks…` placeholder worked, + then Features selection was restored. The only console errors were the existing unpaired static + Web UI transport errors. ### Acceptance evidence @@ -181,6 +221,12 @@ Verdict: verified. - AC-5: PASS — `in-app Browser DOM measurement at 1280x720` recorded exact 32px Automations title, All-label, and search-icon offsets plus the collapsed-shell exception. - AC-6: PASS — `in-app Browser DOM measurement at 1280x720` recorded exact 32px / 32px / 32px offsets on Automations, Features & plugins, and Pull requests. - AC-7: PASS — `githubPullRequestsRendered.test.tsx` and the rendered interaction check preserved view selection, search, filter, refresh, and compact structure after row separation. +- AC-8: PASS — `bun test ./tests/pluginManagerRendered.test.tsx` protected the 4px token contract; + in-app Browser DOM measurement at 1247x1576 confirmed 4px inline padding, 4px adjacent gaps, and + matched 310px client/scroll widths across the category row. +- AC-9: PASS — `bun test ./tests/pluginManagerRendered.test.tsx` protects the local 8px utility; + local Playwright at 1247x1576 confirmed exact 8px left/right computed padding, preserved category + interaction, matched category and document client/scroll widths, and no framework overlay. Residual risk: verification used the isolated renderer with fixture data rather than launching this worktree's native app because another worktree already owns the default desktop data directory. diff --git a/docs/sdlc/changes/2026-09-01-taskboard-view-switching/change.md b/docs/sdlc/changes/2026-09-01-taskboard-view-switching/change.md index 0e79fa7b..8654eec2 100644 --- a/docs/sdlc/changes/2026-09-01-taskboard-view-switching/change.md +++ b/docs/sdlc/changes/2026-09-01-taskboard-view-switching/change.md @@ -8,15 +8,15 @@ owner: codex approvers: user via the 2026-09-01 direct request to support different TaskBoard views approved_at: 2026-09-01 created: 2026-09-01 -updated: 2026-09-01 -source: direct user requests after PR #216 merged into main, including live feedback that the added sidebar title spacer made the hierarchy more confusing +updated: 2026-09-02 +source: direct user requests after PR #216 merged into main, including live feedback that the added sidebar title spacer made the hierarchy more confusing and the 2026-09-02 annotated requests for 340px minimum board lanes and a background-free empty Session row inputs: the merged Task-to-Session list workspace, projected Task lanes, shared filters, selection, and responsive Inspector behavior -outputs: list and board presentations over the same Task projection with a persisted personal view preference +outputs: list and board presentations over the same Task projection with a persisted personal view preference, readable 340px minimum board lanes, and an unfilled empty Session row scope: apps/desktop/src/i18n/strings.ts, apps/desktop/src/taskboard, apps/desktop/src/sidebar/SessionRail.tsx, apps/desktop/tests/taskBoardRendered.test.tsx, apps/desktop/tests/sessionRailRendered.test.tsx, docs/sdlc/changes/2026-09-01-taskboard-view-switching next_trigger: human review; merge, release, and deployment remain unauthorized verification_mode: owner verified_by: codex -verified_at: 2026-09-01 +verified_at: 2026-09-02 --- # Add TaskBoard view switching @@ -69,6 +69,10 @@ change Task status semantics, or alter the shared collaboration transport. - [x] AC-11: Remove the extra Session title spacer so a Project label and its child Session titles return to the established shared start line, while retaining the board overflow correction and all existing sidebar interactions. +- [x] AC-12: Every Board lane keeps a 340px minimum width while horizontal overflow remains contained + by the existing board scroller at the annotated viewport. +- [x] AC-13: An expanded Task with no Sessions renders its “No Sessions / Start task” row without a + fill background, while populated Session stacks retain their existing treatment. ## Decision and gates @@ -80,6 +84,13 @@ introduced. This is medium risk because it changes the primary TaskBoard presentation while leaving data and execution untouched. Merge, release, and deployment are not authorized. +The user's 2026-09-02 annotated request accepts reopening this change only to increase the existing +Board lane minimum from 14rem to 340px. The current four-lane grid and contained horizontal scroller +remain authoritative. + +The user's subsequent annotation accepts one local empty-state correction: remove the fill from an +expanded Task's no-Sessions row without changing populated Session rows or the Inspector empty state. + ## Plan 1. Add the persisted personal view state and the existing header switcher. @@ -88,6 +99,10 @@ execution untouched. Merge, release, and deployment are not authorized. actions, and narrow behavior with focused tests. 4. Run renderer and repository Gates, then exercise list/board switching in real light, dark, and narrow windows. +5. Increase the existing lane and four-track minimums to 340px, add focused contract coverage, and + repeat rendered overflow inspection at the annotated viewport. +6. Apply the existing Session-stack fill only when Sessions are present, then verify the empty row's + background and Start task interaction in the rendered list. Rollback removes the board renderer and switcher and leaves the existing list as the fallback. The local preference key is inert if the UI is reverted. @@ -114,13 +129,22 @@ spacer was removed: Project labels and child Session titles again use the establ line, while provider and workspace icons remain on metadata rows. No new view, preference, data path, drag-and-drop behavior, or dependency was introduced. +The 2026-09-02 follow-up changes the existing four-lane grid minimum from 14rem to 340px and updates +the explicit four-track width accordingly. The same board shell remains the sole horizontal scroller; +the Task projection, cards, Inspector, responsive breakpoints, and view preference are unchanged. + +The subsequent empty-state follow-up applies the existing Session-stack fill only when the expanded +Task has Sessions. An empty stack remains transparent while retaining its indentation, spacing, +No Sessions label, Start task action, and collapse behavior; populated Session stacks are unchanged. + ## Verification Verdict: verified The owner verified the initial view-switching slice, the compact-board follow-up, the -overflow/minimum-width correction, and the sidebar spacer rollback on 2026-09-01. Human review -remains the next Gate; merge, release, and deployment are not authorized. +overflow/minimum-width correction, and the sidebar spacer rollback on 2026-09-01, then verified the +340px lane-minimum and empty Session row follow-ups on 2026-09-02. Human review remains the next +Gate; merge, release, and deployment are not authorized. ### Acceptance evidence @@ -169,9 +193,22 @@ remains the next Gate; merge, release, and deployment are not authorized. assertions. Browser geometry remained four 224 px lanes with zero overflowing cards and document width equal to viewport width. The freshly rebuilt native window confirmed the extra title spacer is gone, and exactly one Core is listening on port 50000. +- AC-12: PASS — `bun test ./tests/taskBoardRendered.test.tsx` passed 23 tests and 111 expectations, + including the 340px grid contract. Playwright at 1247 x 1576 measured all four lanes at exactly + 340px, the board scroller at 671px client / 1416px scroll width, and a successful internal scroll + from 0px to 500px while document client and scroll widths both remained 1247px. Card selection, + page identity, nonblank content, and framework-overlay checks passed. `bun run build:renderer` and + `git diff --check` also passed; the only console errors were the known unpaired static Web UI + transport messages. +- AC-13: PASS — `bun test ./tests/taskBoardRendered.test.tsx` passed 23 tests and 113 expectations, + proving empty Session stacks omit the fill and populated stacks retain it. Playwright at 1247 x + 1576 measured both the empty stack and its row as transparent `rgba(0, 0, 0, 0)`, confirmed No + Sessions and Start task remain visible, exercised expand and collapse, and found no document + overflow or framework overlay. `bun run build:renderer` and `git diff --check` passed; the only + console errors were the known unpaired static Web UI transport messages. Residual risk: at very large Task counts, the board renders all filtered cards while the list keeps -its existing 40-row progressive window. The board deliberately prefers readable 14 rem lanes and +its existing 40-row progressive window. The board deliberately prefers readable 340px lanes and internal horizontal scrolling over compressing all four lanes into the viewport; drag-and-drop is out of scope, so Task status changes remain explicit menu actions. The standard 1152 px native window requires horizontal scrolling to reveal the whole fourth lane because the PR #216 Inspector diff --git a/docs/sdlc/changes/2026-09-02-add-webui-favicon/change.md b/docs/sdlc/changes/2026-09-02-add-webui-favicon/change.md new file mode 100644 index 00000000..905bdb2b --- /dev/null +++ b/docs/sdlc/changes/2026-09-02-add-webui-favicon/change.md @@ -0,0 +1,106 @@ +--- +id: change-2026-09-02-add-webui-favicon +kind: change +schema: 2 +status: verified +risk: low +owner: codex +approvers: user via the direct 2026-09-02 screenshot feedback +approved_at: 2026-09-02 +created: 2026-09-02 +updated: 2026-09-02 +source: direct screenshot feedback that the CLI Web UI browser tab needs a C2 icon +inputs: the shared Web UI HTML entry and the existing C2 application icon asset +outputs: the browser tab resolves the existing C2 icon instead of the generic globe +scope: apps/desktop/index.html, docs/sdlc/changes/2026-09-02-add-webui-favicon +next_trigger: human review and an explicit merge or release decision +verification_mode: owner +verified_by: codex +verified_at: 2026-09-02 +--- + +# Add the C2 icon to the Web UI browser tab + +## Intent + +The CLI Web UI browser tab currently falls back to the browser's generic globe because the shared +HTML entry does not declare an icon. The user asked for the tab to show a product icon. The desired +outcome is to reuse the repository's existing C2 application mark without creating a Web-only +brand asset or changing the application shell. + +## Spec + +The main HTML entry declares the existing SVG application icon as its favicon. Vite must include +the asset in its Web build and keep the generated URL valid under the CLI server's relative asset +base. The desktop pet entry, product title, runtime transport, and native application icon pipeline +are out of scope. + +### Acceptance criteria + +- [x] AC-1: The built and served CLI Web UI declares a reachable C2 favicon instead of relying on + the browser's generic fallback, verified against the generated HTML, HTTP response, and a + live Browser reload. +- [x] AC-2: The favicon reuses the existing C2 SVG asset and the desktop renderer still passes its + lint, type, and production-build checks. + +## Decision and gates + +The user's direct screenshot feedback accepts this low-risk visual correction. Ponytail selected +one HTML metadata declaration at the shared entry and the existing app icon; no duplicated favicon, +new dependency, Web-only component, or configuration surface is justified. + +Human review remains required before merge. Merge, release, deployment, and publication are not +authorized. + +## Plan + +1. Reference the existing C2 SVG icon from the shared main HTML entry. +2. Build the real Web bundle and verify the emitted icon URL is served successfully. +3. Reload the live CLI Web UI, inspect the icon declaration, and run repository lifecycle Gates. + +Rollback removes the favicon declaration. There is no data or protocol rollback. + +## Build + +The shared main HTML entry now declares `assets/icon.svg` as an SVG favicon. Vite resolves that +existing source into its normal hashed Web asset, so the CLI server and desktop bundle keep the +same relative-asset contract. No new icon file, component, dependency, or runtime branch was added. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `bunx vite build --mode web --outDir ../../target/debug/web-ui --emptyOutDir` + emitted `assets/icon-CjKlZ2fo.svg` and a relative icon link in `index.html`; `curl` against the + running `http://127.0.0.1:4599/` returned that SVG with HTTP 200 and `image/svg+xml`. After a live + in-app Browser reload, the complete `C2` document resolved the same absolute icon URL and MIME + type. +- AC-2: PASS — `bun run lint`, `bunx tsc --noEmit`, and the actual Vite Web build passed with 6,604 + transformed modules; source inspection confirms the HTML entry directly references the existing + `apps/desktop/assets/icon.svg`. + +The first lifecycle Gate pass rejected the AC-2 evidence because its command was wrapped onto a +continuation line. The mapping above places the command on the evidence line; no product code or +verification result changed. + +Residual risk: the safe Browser API verified the live document and icon request but cannot capture +the host application's tab chrome as pixels; Codex also blocks Computer Use from inspecting its own +window. Browser favicon caching may require one reload on an already-open tab. The favicon itself is +theme- and viewport-independent. + +## Review and release + +Approval: implementation approved by the user on 2026-09-02; merge and release are not approved. +Review: Draft PR [#219](https://github.com/IchenDEV/codeTwo/pull/219) contains this change. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: remove the favicon declaration from the shared HTML entry. +No release: merge, deployment, and release are not authorized. + +## Feedback + +This change is the direct follow-up to the user's browser-tab screenshot. No post-fix feedback +exists yet. diff --git a/docs/sdlc/changes/2026-09-02-align-sidebar-trailing-actions/change.md b/docs/sdlc/changes/2026-09-02-align-sidebar-trailing-actions/change.md new file mode 100644 index 00000000..ea6dc04a --- /dev/null +++ b/docs/sdlc/changes/2026-09-02-align-sidebar-trailing-actions/change.md @@ -0,0 +1,115 @@ +--- +id: change-2026-09-02-align-sidebar-trailing-actions +kind: change +schema: 2 +status: verified +risk: low +owner: codex +approvers: user via the direct 2026-09-02 screenshot feedback +approved_at: 2026-09-02 +created: 2026-09-02 +updated: 2026-09-02 +source: direct screenshot feedback that the Search shortcut, New task trailing action, and title-bar collapse action must align +inputs: the rendered SessionRail trailing-control geometry and existing spacing tokens +outputs: one shared trailing inset for the Search shortcut, Quick Chat action, and title-bar collapse action +scope: apps/desktop/src/sidebar/SessionRail.tsx, apps/desktop/tests/sessionRailRendered.test.tsx, docs/sdlc/changes/2026-09-02-align-sidebar-trailing-actions +next_trigger: human review and an explicit merge or release decision +verification_mode: owner +verified_by: codex +verified_at: 2026-09-02 +--- + +# Align sidebar trailing actions + +## Intent + +The first screenshot showed that the Search shortcut and the Quick Chat action at the end of the New +task row did not share the same right edge. After that 4px correction, the user's follow-up screenshot +shows the title-bar collapse action still sitting farther right. Live geometry confirms its icon +center is 8px to the right of the other two controls. The desired outcome is one quiet vertical +trailing baseline without changing row height, icon size, labels, shortcuts, or interactions. + +## Spec + +The Quick Chat button and title-bar collapse button use the same effective 16px right inset as the +Search shortcut. The existing sidebar spacing tokens, shared SessionRail component, action +semantics, and focus behavior remain unchanged. Collapsed-rail layout and unrelated navigation rows +are out of scope. + +### Acceptance criteria + +- [x] AC-1: The Search shortcut, Quick Chat action, and title-bar collapse action share one rendered + trailing centerline in the live Web UI, verified by Browser geometry and screenshots. +- [x] AC-2: The Quick Chat and collapse actions remain present, labeled, keyboard-focusable, and + clickable, verified by the focused rendered regression and live Browser interactions. + +## Decision and gates + +The user's direct screenshot feedback accepts this low-risk alignment correction. Ponytail selected +one existing spacing-token addition at the shared SessionRail seam plus one regression assertion; no +new wrapper, layout system, or Web-only variant is justified. + +Human review remains required before merge. Merge, release, deployment, and publication are not +authorized. + +## Plan + +1. Keep the corrected Quick Chat inset and move the title-bar collapse button onto the same axis. +2. Extend the focused SessionRail regression with the complete trailing-inset contract. +3. Rebuild the Web assets and compare the live right-edge geometry at desktop and narrow widths. + +Rollback restores the prior margin token and regression expectation. There is no data or protocol +rollback. + +## Build + +The shared SessionRail uses the existing `mr-2` spacing token on the Quick Chat button, moving +its right edge inward by 4px to match the Search shortcut's effective 16px trailing inset. The +focused rendered regression prohibits restoring the previous `mr-1` token. No wrapper, dimensions, +semantics, or interaction logic changed. + +The follow-up adds the same existing `mr-2` trailing token to the title-bar collapse button. Combined +with the title row's existing padding, this moves its 28px button and 16px icon 8px inward onto the +same axis as the Search shortcut and Quick Chat action. Dimensions, native button semantics, labels, +focus treatment, and event handling remain unchanged. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `bunx vite build --mode web --outDir ../../target/debug/web-ui --emptyOutDir` + passed with 6,604 transformed modules. After reloading the live CLI Web UI at 980x998, Browser + geometry measured Collapse and Quick Chat centers at 258px and the Search shortcut visual center + at 258.17px. At 900x700 with the overlay rail expanded, the same centers and zero horizontal + overflow were observed; screenshots show the single trailing axis. +- AC-2: PASS — `bun test apps/desktop/tests/sessionRailRendered.test.tsx` passed 30 tests with 287 + expectations, including the collapse `mr-2` contract. In the live Browser, clicking Collapse + exposed the visible `Expand the sidebar` button, and clicking Expand restored the labeled collapse + control and full rail. `bun run lint` and `bunx tsc --noEmit` passed. + +Residual risk: at 700px the responsive rail starts collapsed, so the three-control axis is not +simultaneously visible until the rail is expanded at a wider overlay breakpoint. The alignment is +enforced by fixed shared spacing tokens rather than viewport-dependent offsets. The current in-app +tab is unpaired and retains its pre-existing transport errors; they were present before the CSS +change and do not affect the local collapse/expand or layout evidence. + +## Review and release + +Approval: implementation approved by the user on 2026-09-02; merge and release are not approved. +Review: Draft PR [#219](https://github.com/IchenDEV/codeTwo/pull/219) contains this change. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: restore the previous Quick Chat and title-bar collapse margin tokens and regression +expectations. +No release: merge, deployment, and release are not authorized. + +## Feedback + +The first annotated screenshot led to the Quick Chat inset correction. The user's follow-up +screenshot shows the title-bar collapse action still offset from the corrected Search and Quick Chat +axis. Before this iteration, live Browser geometry measured centers at 266px for Collapse, 258.17px +for the Search shortcut, and 258px for Quick Chat. The follow-up now measures the two icon centers at +258px and the shortcut at 258.17px. diff --git a/docs/sdlc/changes/2026-09-02-browser-core-transport/change.md b/docs/sdlc/changes/2026-09-02-browser-core-transport/change.md new file mode 100644 index 00000000..ec24d60b --- /dev/null +++ b/docs/sdlc/changes/2026-09-02-browser-core-transport/change.md @@ -0,0 +1,182 @@ +--- +id: change-2026-09-02-browser-core-transport +kind: change +schema: 2 +status: verified +risk: medium +owner: codex +approvers: user via the direct 2026-09-02 implementation request +approved_at: 2026-09-02 +created: 2026-09-02 +updated: 2026-09-02 +source: direct user request to begin a browser Web UI mode while reusing existing modules and preventing future product-surface divergence +inputs: existing React renderer, Electrobun Core command transport, paired Axum remote server, and shared CoreApp command graph +outputs: first shared browser-to-Core transport slice for the existing React renderer +scope: Cargo.lock, apps/desktop/package.json, apps/desktop/src/bridge.ts, apps/desktop/src/coreTransport.ts, apps/desktop/tests/coreTransport.test.ts, apps/desktop/tests/pluginBridgeContract.test.ts, apps/desktop/vite.config.ts, apps/desktop/src-host/src/remote.rs, crates/plugins/src/app/mod.rs, crates/plugins/src/app/plugin_manager.rs, crates/server/Cargo.toml, crates/server/src/lib.rs, crates/server/tests/web_ui_commands.rs, docs/sdlc/changes/2026-09-02-browser-core-transport +next_trigger: human review and an explicit merge or release decision +verification_mode: owner +verified_by: codex +verified_at: 2026-09-02 +--- + +# Reuse the desktop React renderer as a browser Web UI + +## Intent + +C2 already has one Rust Core shared by Desktop, TUI, and the paired remote server, but the complete +React renderer can reach product commands only through Electrobun. Plain-browser rendering therefore +falls back to fixtures and no-op event subscriptions, while the separate remote HTML client repeats +chat interaction logic. The user directly requested implementation of a browser mode that reuses the +existing modules and does not create another product surface that can drift during later iteration. + +The desired first outcome is one shared React product tree with a small Core transport interface. +Electrobun and paired Web access are adapters at that seam; native window, dialog, updater, Appshot, +pet, and embedded-WebView capabilities remain owned by the desktop container. This request approves +implementation, but not a pull request, merge, release, deployment, or production mutation. + +## Spec + +The renderer gains a transport module whose complete product-facing interface is a generic command +call plus named event subscription. The existing Electrobun adapter remains the default in the +desktop webview. An explicit Web development mode uses the existing C2 pairing bearer, short-lived +single-use WebSocket ticket, paired remote event stream, and a new authenticated generic command +route. Product commands continue to be implemented and typed once in `bridge.ts`; the Web transport +does not add command-specific HTTP endpoints. + +The desktop-host remote plugin supplies the generic command caller from its live Kernel context, so +the browser attaches to the already running Core and never opens the same SQLite database in a +second process. The first Web capability set is intentionally bounded to provider discovery, +project/session reads, transcript reads, session creation and management, prompts, execution policy, +permission/elicitation answers, and cancellation. Unpaired requests, member-scoped devices, missing +host support, and commands outside that capability set fail closed. Project-scoped dispatch retains +the existing project-realm lease and fallback semantics. + +The first slice is a development Web UI reached through Vite's same-origin proxy. It does not yet +package the React assets inside the Rust server, replace the compact mobile remote page, add a hosted +relay, or claim parity for desktop-container features. Those steps require separate acceptance after +the shared transport has rendered and behaved correctly. + +### Acceptance criteria + +- [x] AC-1: Desktop and Web adapters satisfy one small renderer Core transport interface (`call` + and `listen`), while `bridge.ts` remains the single product-command projection and plain + fixture preview behavior stays available outside explicit Web mode. +- [x] AC-2: A paired personal browser can call the bounded Web UI command set through the live + desktop Kernel, including correct project-realm dispatch; unauthenticated, member-scoped, + unavailable, and out-of-scope calls fail closed with actionable responses. +- [x] AC-3: Explicit Web mode can load providers, projects, active/archived Sessions, previews, and + transcripts; create and prepare a Session; submit or cancel a prompt; answer permissions or + elicitation; change execution policy; and receive the shared engine event stream without a + second Core process. +- [x] AC-4: Pairing bootstrap is single-flight, bearer credentials remain outside URLs and + WebSockets, calls remain concurrent, and reconnect uses fresh single-use WebSocket tickets. +- [x] AC-5: Focused TypeScript and Rust protocol tests, renderer type/build checks, real browser + loading and interaction evidence, and repository documentation/SDLC Gates pass. + +## Decision and gates + +The user accepted the browser direction and directly requested implementation on 2026-09-02. +Ponytail selected reuse of the existing React tree, CoreApp command seam, C2 bearer/ticket flow, and +remote engine event stream. Codebase-design review places one real seam between the renderer and its +two transport adapters; it does not introduce command-specific Web modules or another business +runtime. + +This is medium risk because it adds an authenticated route to trusted local product commands. The +initial capability set is explicit and member devices are rejected. Human review remains required +before merge. Packaging, remote-wide capability expansion, release, and production remain closed +Gates. + +## Plan + +1. Add and test the renderer Core transport interface with Electrobun and paired-Web adapters. +2. Route only the first browser Session vertical slice through Core availability while preserving + native desktop checks and unrelated browser fixtures. +3. Add an authenticated generic Web UI command route and a desktop-host caller that reuses Kernel + global/project dispatch with an explicit capability set. +4. Add a Web Vite mode and same-origin proxy without changing the normal desktop build. +5. Run focused protocol/renderer checks, exercise the paired browser flow against one live Core, + then run the repository lifecycle Gates. + +Rollback removes the Web adapter, command route, and Vite Web mode and restores the selected bridge +guards to Electrobun-only checks. No persisted schema or user data is migrated. + +## Build + +`coreTransport.ts` now owns the renderer's two-operation Core seam and selects either the existing +Electrobun transport or the paired-Web adapter. `bridge.ts` remains the only typed product-command +projection; only the Session vertical slice tests Core availability rather than desktop-container +availability. `dev:web` reuses the normal Vite renderer and proxies authenticated HTTP and +WebSocket traffic to the existing paired Core listener. + +The server adds one generic authenticated call route. Its desktop-host adapter applies an explicit +allowlist and delegates both root and project-scoped calls to `PluginManager`; `CoreApp` delegates +to that same seam. This centralizes lazy project graph creation, activity leasing, flushing, realm +selection, and global fallback instead of copying them into a Web implementation. + +The first browser restart probe exposed one real failure: after a WebSocket closed, a transient +ticket-request failure stopped the old adapter's retry loop. The correction reschedules while a +reusable bearer or pairing token exists. Both the focused regression test and an outage longer than +multiple retry intervals then recovered without reloading the page. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `bun test tests/coreTransport.test.ts`, `bunx tsc --noEmit`, and source inspection + verified one `CoreTransport` interface, existing Electrobun delegation, explicit Web-mode + selection, and unchanged fixture fallback outside Web mode. +- AC-2: PASS — `cargo test -p codetwo-server --test web_ui_commands`, + `cargo test -p codetwo-desktop-host remote::tests`, and + `cargo test -p codetwo-plugins --test project_plugin_graph` verified paired-personal auth, + member rejection, missing-adapter rejection, preserved project call context, explicit host + capability policy, and the shared lazy project-realm dispatch path. +- AC-3: PASS — a real `vite --mode web` renderer paired with one release desktop host on an + isolated data directory loaded the existing React tree, providers, project and persisted + Sessions, opened a transcript, created a durable Session from Core, received its live + `session_created` event, and renamed it through the browser UI. Source/allowlist inspection + verified the remaining prompt, prepare, cancel, permission, elicitation, policy, model, sandbox, + archive, and pin calls use the same generic route rather than separate Web handlers. +- AC-4: PASS — `bun test tests/coreTransport.test.ts` verified explicit-token precedence over a + stale bearer, single-flight pairing, concurrent command calls, bearer-free socket URLs, a fresh + single-use ticket per connection, and retry after an intermediate ticket request failed. In the + live browser, stopping Core for 6.5 seconds caused repeated ticket failures; after restart the + same unreloaded tab received a newly created Session event. +- AC-5: PASS — `bun test` passed the full desktop suite with 864 tests and 5,122 expectations; the focused Core + transport and bridge contract suite passed 6 tests with 53 expectations; task-board mutation + testing scored 100%; and `bun run build:renderer` passed lint, TypeScript, and the 6,604-module + Vite production build. Focused Rust tests, changed-file `rustfmt --check`, `git diff --check`, and + the repository documentation/lifecycle/worktree Gates also passed. + +Draft PR #219 `Desktop design system / validate` failed after 700 passing tests because the reconnect +test expected the second fake socket exactly 5ms after closing the first. The same test passed in +focused runs, identifying test scheduling under full-suite load rather than a product reconnect +failure. + +The next full local desktop run passed that reconnect point and exposed the adjacent source +contract after 863 passing tests: it still required `bridge.ts` to call `desktopCall` directly. +The contract now verifies the intended single chain through `coreTransport.ts`, including its +desktop delegation, without weakening the one-boundary assertion. + +Residual risk: this is intentionally a development Web mode. React assets are not yet embedded in +the Rust server, the compact phone remote remains separate, and desktop-container-only features +such as native windows, dialogs, updater, Appshots, pets, and embedded WebViews remain unavailable +in a browser. The live smoke test did not submit a prompt to an external provider; prompt transport +was checked at the shared command projection, allowlist, and authenticated generic route. The test +host used an isolated data directory and port because same-bundle desktop multi-instance identity +is a separate unresolved development limitation. + +## Review and release + +Approval: implementation approved by the user on 2026-09-02; merge and release are not approved. +Review: Draft PR [#219](https://github.com/IchenDEV/codeTwo/pull/219) contains this change. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: remove the scoped Web transport and route; no data rollback is required. +No release: merge, deployment, and release are not authorized. + +## Feedback + +No post-implementation feedback exists yet. diff --git a/docs/sdlc/changes/2026-09-02-cli-webui-entry/change.md b/docs/sdlc/changes/2026-09-02-cli-webui-entry/change.md new file mode 100644 index 00000000..29723dd2 --- /dev/null +++ b/docs/sdlc/changes/2026-09-02-cli-webui-entry/change.md @@ -0,0 +1,155 @@ +--- +id: change-2026-09-02-cli-webui-entry +kind: change +schema: 2 +status: verified +risk: medium +owner: codex +approvers: user via the direct 2026-09-02 CLI implementation request +approved_at: 2026-09-02 +created: 2026-09-02 +updated: 2026-09-02 +source: direct user request to add a CLI entry that opens the shared browser Web UI +inputs: existing codetwo-server binary, shared React Web build, paired server authentication, and shared Kernel Web command adapter +outputs: one codetwo-server webui mode that starts a single Core and serves the shared React renderer +scope: Cargo.lock, README.md, apps/desktop/src-host/src/remote.rs, crates/server/Cargo.toml, crates/server/src/lib.rs, crates/server/src/main.rs, crates/server/tests/web_ui_commands.rs, script/build/hosts.sh, docs/sdlc/changes/2026-09-02-cli-webui-entry +next_trigger: human review and an explicit merge or release decision +verification_mode: owner +verified_by: codex +verified_at: 2026-09-02 +--- + +# Open the shared Web UI from the server CLI + +## Intent + +The shared React renderer can now attach to a paired Core, but its only launch entry is the +repository-only `bun run dev:web` script. The user requested a real CLI entry. It must not create a +new business runtime, duplicate browser command policy, or require a second Core process. + +The smallest product-shaped outcome is a `webui` mode on the existing `codetwo-server` binary. +That binary already owns standalone Core startup, pairing, networking, and host packaging. The new +mode adds shared React static assets and browser launch orchestration while keeping the existing +compact remote mode backward compatible. + +## Spec + +`codetwo-server webui` boots one `CoreApp`, exposes the same authenticated generic Web UI command +route as the desktop remote plugin, serves the normal Vite Web build from the same origin, prints a +one-time pairing URL, and opens that URL in the platform browser unless `--no-open` is supplied. +The browser consumes the existing `CoreTransport` and React tree; the CLI adds no command-specific +HTTP endpoints or renderer fork. + +The CLI resolves Web assets from an explicit `--ui-dir`, then `CODETWO_WEB_UI_DIR`, then a +`web-ui` directory adjacent to the executable. The host build script produces that adjacent +directory from the existing Vite Web build. Missing or invalid assets fail before Core boot with an +actionable message. `--data-dir` provides an explicit standalone data location for safe development +and testing; the existing default remains unchanged. + +The existing no-argument `codetwo-server` behavior continues to serve the compact remote client. +It must not require Web UI assets and must not gain the broader renderer command route. + +### Acceptance criteria + +- [x] AC-1: `codetwo-server webui` boots one shared Core and serves the existing React Web build, + authenticated command route, and engine event stream from one origin. +- [x] AC-2: The no-argument compact remote server remains backward compatible and does not require + or serve full-renderer assets. +- [x] AC-3: Asset and data-directory resolution is deterministic; missing assets and invalid CLI + arguments fail before Core startup with actionable output; the host build packages assets + adjacent to the executable. +- [x] AC-4: Desktop remote and standalone CLI reuse one Web command allowlist/dispatcher; browser + opening is suppressible and never puts bearer credentials in the URL. +- [x] AC-5: Focused CLI, HTTP, renderer, Rust, browser, documentation, and lifecycle checks pass. + +## Decision and gates + +The user's direct request accepts this medium-risk implementation. Ponytail selected extension of +the existing server binary and build script rather than a new `codetwo` CLI framework, embedded +47-MiB generated asset tree, or a Vite child-process dependency. Codebase-design review keeps CLI +orchestration shallow and places authentication, static serving, command policy, and Core dispatch +behind existing server and PluginManager interfaces. + +Human review remains required before merge. Publishing binaries, opening a public listener, +release, deployment, and production mutation remain closed Gates. + +## Plan + +1. Move the Web command allowlist/dispatcher into the shared server module and reuse it from both + desktop remote and standalone server hosts. +2. Let the server optionally mount a validated Vite asset directory as its SPA fallback while + preserving the compact client when no directory is supplied. +3. Add the `webui`, `--ui-dir`, `--data-dir`, and `--no-open` CLI interface and package the existing + Vite Web output next to the host binary. +4. Verify argument failures, static assets, paired calls, default compatibility, browser launch + suppression, a real browser flow, and repository Gates. + +Rollback removes the `webui` CLI mode, optional static fallback, and host-build asset output. It +does not migrate persisted data or change the default compact remote server. + +## Build + +`codetwo-server` now accepts a focused `webui` mode with `--ui-dir`, `--data-dir`, and +`--no-open`. It validates the React asset directory before creating data or booting Core, then +starts the existing `CoreApp`, paired HTTP/WebSocket server, shared `KernelWebUiCommands`, and Vite +SPA from one process and origin. The no-argument branch still calls the prior compact server entry. + +`KernelWebUiCommands` and its bounded capability policy moved from the desktop-only Remote plugin +into `codetwo-server`; desktop Remote and standalone CLI instantiate that same adapter. Static +files use tower-http's traversal-safe directory service and fall back to the existing React index +for client-side routes. No command-specific Web handler or second product runtime was added. + +The host build now emits the existing Vite Web build as `web-ui` next to the Rust binaries. This +keeps generated assets out of Git and avoids invoking Bun or Vite when the installed CLI runs. +There were no material deviations from the accepted plan. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `./script/build/hosts.sh debug` produced `target/debug/codetwo-server` and its + adjacent 850-file `web-ui` build. Running `CODETWO_HOST=127.0.0.1 CODETWO_PORT=4602 + ./target/debug/codetwo-server webui --data-dir --no-open` booted one Core and served + pairing, React assets, authenticated Core calls, and events from the same listener. +- AC-2: PASS — `cargo test -p codetwo-server --test web_ui_commands` verified that Web assets + replace the root and `/pair` SPA routes only when supplied, while the no-assets server still + returns the embedded compact C2 client and retains `/health` independently. +- AC-3: PASS — `cargo test -p codetwo-server --bin codetwo-server`, `bash -n + script/build/hosts.sh`, `./target/debug/codetwo-server --help`, and a missing-assets CLI probe + verified precedence, adjacent packaging, actionable errors, and that invalid assets fail before + the requested data directory is created. +- AC-4: PASS — `cargo test -p codetwo-server --lib + browser_renderer_has_one_bounded_core_capability_set`, `cargo test -p codetwo-desktop-host + remote::tests`, and source inspection verified one shared adapter and allowlist. The live + `--no-open` run opened no system browser; its printed URL contained only the one-time pairing + token in the fragment, never a durable bearer. +- AC-5: PASS — `cargo test -p codetwo-server` passed 50 unit/integration tests; the desktop-host + Remote tests, three Bun transport tests, full renderer lint and TypeScript checks, actual Vite + Web build, changed-file rustfmt, `git diff --check`, and repository documentation/lifecycle + worktree Gates passed. In the in-app browser, the packaged CLI page resolved to `/pair` with its + token fragment cleared, rendered the C2 React UI, reported no console warnings/errors, and + changed from Collapse to Expand after the sidebar interaction. + +Residual risk: Web UI assets remain a generated adjacent directory rather than bytes embedded in +the Rust executable. Moving only `codetwo-server` without its sibling `web-ui` directory makes the +new mode fail closed with a repair command; the compact mode still works. The current renderer +bundle is about 47 MiB and retains its existing large-chunk warnings. The live smoke used +`--no-open` to avoid surprising the user's default browser; platform browser command construction +is a small standard-library branch, while actual automatic opening remains platform-dependent. + +## Review and release + +Approval: implementation approved by the user on 2026-09-02; merge and release are not approved. +Review: Draft PR [#219](https://github.com/IchenDEV/codeTwo/pull/219) contains this change. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: remove the CLI mode and static asset fallback; no data rollback is required. +No release: merge, deployment, and release are not authorized. + +## Feedback + +The user requested the CLI entry immediately after confirming that only a development script +existed. No post-implementation feedback exists yet. diff --git a/docs/sdlc/changes/2026-09-02-remove-transient-composer-focus-outline/change.md b/docs/sdlc/changes/2026-09-02-remove-transient-composer-focus-outline/change.md new file mode 100644 index 00000000..7d277043 --- /dev/null +++ b/docs/sdlc/changes/2026-09-02-remove-transient-composer-focus-outline/change.md @@ -0,0 +1,108 @@ +--- +id: change-2026-09-02-remove-transient-composer-focus-outline +kind: change +schema: 2 +status: verified +risk: low +owner: codex +approvers: user via the direct 2026-09-02 Browser comment +approved_at: 2026-09-02 +created: 2026-09-02 +updated: 2026-09-02 +source: direct Browser comment that the focused transient composer must not show a blue frame +inputs: rendered Quick Chat focus state and the shared transient composer component +outputs: one shared neutral focus treatment for Quick Chat and Side Chat composers +scope: apps/desktop/src/session/SideChatPanel.tsx, apps/desktop/tests/sideChatPanelRendered.test.tsx, docs/sdlc/changes/2026-09-02-remove-transient-composer-focus-outline +next_trigger: human review and an explicit merge or release decision +verification_mode: owner +verified_by: codex +verified_at: 2026-09-02 +--- + +# Remove the transient composer blue focus outline + +## Intent + +The focused Quick Chat composer currently paints a blue inset outline around the whole input card. +The user asked to remove that frame. Quick Chat and Side Chat share the same transient composer, so +the correction must remain at that shared seam and must not introduce a Web-only or placement-only +variant. + +The desired outcome is a quiet, non-blue focus state that keeps the textarea operable and visible. +The main task Composer, layout, controls, transport, and conversation behavior are out of scope. + +## Spec + +When focus is anywhere inside a transient composer, its existing card uses the design system's +neutral hover-fill token instead of the blue inset focus-ring utility. The textarea retains its +caret and keyboard behavior, and nested buttons retain their own focus-visible treatment. The same +class contract applies to Quick Chat and Side Chat. + +### Acceptance criteria + +- [x] AC-1: Focused Quick Chat and Side Chat composer cards show no blue outline and retain a + visible neutral surface state, verified by a focused rendered test and live Browser inspection. +- [x] AC-2: The shared transient composer remains keyboard-operable and the main task Composer's + focus contract is unchanged, verified by targeted source and rendered tests. + +## Decision and gates + +The user's direct Browser comment accepts this low-risk visual correction. Ponytail selected one +shared class replacement and one existing regression update; no new component, option, or Web UI +branch is justified. + +Human review remains required before merge. Merge, release, deployment, and publication are not +authorized. + +## Plan + +1. Replace the transient card's blue `focus-within` outline with the existing neutral focus fill. +2. Update the existing Quick Chat and Side Chat rendered contract to prohibit the old ring. +3. Run the focused test, renderer checks, live dark/light and narrow Browser focus passes, and + repository documentation/lifecycle Gates. + +Rollback restores the prior shared class and test expectation. There is no data or protocol +rollback. + +## Build + +The shared transient composer now replaces its blue inset focus ring with the existing neutral +hover-fill token and color transition. The existing rendered contract covers both Quick Chat and +Side Chat and prohibits the removed ring. The main task Composer was not changed. There were no +material deviations from the accepted plan. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `bun test apps/desktop/tests/sideChatPanelRendered.test.tsx` passed 21 tests with + 90 expectations, including both transient surfaces and the new no-blue-ring contract. In the + live paired CLI Web UI, focused Quick Chat computed `outline-style: none` at 1658x1159 dark and + 700x700 light; the narrow pass had zero horizontal overflow and rendered the neutral focus fill. +- AC-2: PASS — the live textarea remained `document.activeElement` and accepted an ArrowLeft + keyboard action. `bun test apps/desktop/tests/composerGeometryContract.test.ts` passed 6 tests + with 37 expectations and retained the main Composer's existing focus contract. ESLint, + Stylelint, TypeScript, and the actual Web Vite build passed with 6,604 transformed modules; the + stable final Browser interaction produced no new console warnings or errors. + +Residual risk: Side Chat was covered by the same shared component's rendered regression rather +than opened separately in the final live Browser pass. The visual change is a design-token class +replacement with no state or transport behavior, and both relevant themes plus the narrow layout +were rendered live. + +## Review and release + +Approval: implementation approved by the user on 2026-09-02; merge and release are not approved. +Review: Draft PR [#219](https://github.com/IchenDEV/codeTwo/pull/219) contains this change. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable until released. +Rollback: restore the previous transient composer focus class and regression expectation. +No release: merge, deployment, and release are not authorized. + +## Feedback + +This change is the direct follow-up to the user's rendered Browser comment. No post-fix feedback +exists yet. diff --git a/script/build/hosts.sh b/script/build/hosts.sh index c10f2256..0e6c7dc8 100755 --- a/script/build/hosts.sh +++ b/script/build/hosts.sh @@ -26,6 +26,11 @@ fi bun build --compile "$ROOT_DIR/apps/desktop/src/electrobun/toolBrokerRpc.ts" \ --outfile "$TARGET_DIR/$PROFILE/$broker_name" +( + cd "$ROOT_DIR/apps/desktop" + bunx vite build --mode web --outDir "$TARGET_DIR/$PROFILE/web-ui" --emptyOutDir +) + cargo_args=(build -p codetwo-tui -p codetwo-server) if [[ "$PROFILE" == "release" ]]; then cargo_args+=(--release) @@ -35,4 +40,4 @@ fi cargo "${cargo_args[@]}" ) -echo "Rust hosts and $broker_name are ready in $TARGET_DIR/$PROFILE" +echo "Rust hosts, Web UI, and $broker_name are ready in $TARGET_DIR/$PROFILE"