diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs new file mode 100644 index 00000000..24f32811 --- /dev/null +++ b/crates/workspace-server/src/companion.rs @@ -0,0 +1,625 @@ +use std::sync::{Arc, Mutex}; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use worker_runtime::catalog::{CapabilityRequest, ProfileSelector}; + +use crate::hosts::{ + DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest, + WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, + WorkerSummary, +}; + +const COMPANION_RUNTIME_ID: &str = "embedded-worker-runtime"; +const MAX_MESSAGE_CHARS: usize = 8_000; +const PROVIDERLESS_RESPONSE: &str = + include_str!("../../../resources/prompts/worker/web_companion_providerless.md"); + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CompanionState { + Ready, + Busy, + Error, + Timeout, + Cancelled, + Accepted, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CompanionStatusResponse { + pub state: CompanionState, + #[serde(skip_serializing_if = "Option::is_none")] + pub worker: Option, + pub transport: CompanionTransportSummary, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CompanionTransportSummary { + pub kind: String, + pub completion: String, + pub limitation: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct CompanionMessageRequest { + pub content: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] +pub struct CompanionCancelRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CompanionMessageResponse { + pub state: CompanionState, + #[serde(skip_serializing_if = "Option::is_none")] + pub worker: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_item: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub assistant_item: Option, + pub transcript: CompanionTranscriptProjection, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CompanionTranscriptProjection { + pub state: CompanionState, + pub start: usize, + pub limit: usize, + pub total_items: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_start: Option, + pub items: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CompanionTranscriptItem { + pub sequence: u64, + pub role: String, + pub content: String, + pub created_at: String, + pub source: String, + pub status: String, +} + +#[derive(Debug, Default)] +struct CompanionTranscript { + items: Vec, + next_sequence: u64, +} + +#[derive(Debug)] +struct CompanionWorkerState { + state: CompanionState, + worker: Option, + diagnostics: Vec, +} + +pub struct CompanionConsole { + runtime: Arc, + worker: Mutex, + transcript: Mutex, +} + +impl CompanionConsole { + pub fn new(runtime: Arc) -> Self { + let initial = spawn_companion_worker(&runtime); + Self { + runtime, + worker: Mutex::new(initial), + transcript: Mutex::new(CompanionTranscript::default()), + } + } + + pub fn status(&self) -> CompanionStatusResponse { + let worker = match self.worker.lock() { + Ok(worker) => worker, + Err(_) => { + return CompanionStatusResponse { + state: CompanionState::Error, + worker: None, + transport: providerless_transport(), + diagnostics: vec![diagnostic( + "companion_state_unavailable", + DiagnosticSeverity::Error, + "Companion state is unavailable", + )], + }; + } + }; + CompanionStatusResponse { + state: worker.state, + worker: worker.worker.clone(), + transport: providerless_transport(), + diagnostics: worker.diagnostics.clone(), + } + } + + pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection { + let transcript = match self.transcript.lock() { + Ok(transcript) => transcript, + Err(_) => { + return CompanionTranscriptProjection { + state: CompanionState::Error, + start, + limit, + total_items: 0, + next_start: None, + items: Vec::new(), + diagnostics: vec![diagnostic( + "companion_transcript_unavailable", + DiagnosticSeverity::Error, + "Companion transcript is unavailable", + )], + }; + } + }; + project_transcript(&transcript, CompanionState::Ready, start, limit, Vec::new()) + } + + pub fn send_message(&self, request: CompanionMessageRequest) -> CompanionMessageResponse { + let content = request.content.trim().to_string(); + if content.is_empty() { + return self.rejected_message_response(diagnostic( + "companion_message_empty", + DiagnosticSeverity::Warning, + "Companion message content is empty", + )); + } + if content.chars().count() > MAX_MESSAGE_CHARS { + return self.rejected_message_response(diagnostic( + "companion_message_too_large", + DiagnosticSeverity::Warning, + format!("Companion message exceeds the {MAX_MESSAGE_CHARS} character limit"), + )); + } + + let mut transcript = match self.transcript.try_lock() { + Ok(transcript) => transcript, + Err(std::sync::TryLockError::WouldBlock) => { + return self.busy_message_response(); + } + Err(std::sync::TryLockError::Poisoned(_)) => { + return self.error_message_response(diagnostic( + "companion_transcript_unavailable", + DiagnosticSeverity::Error, + "Companion transcript is unavailable", + )); + } + }; + + let (worker, mut diagnostics) = match self.current_worker() { + Ok((Some(worker), diagnostics)) => (worker, diagnostics), + Ok((None, diagnostics)) => { + return response_from_locked_transcript( + &transcript, + CompanionState::Error, + None, + None, + None, + 0, + 200, + diagnostics, + ); + } + Err(diagnostic) => { + return response_from_locked_transcript( + &transcript, + CompanionState::Error, + None, + None, + None, + 0, + 200, + vec![diagnostic], + ); + } + }; + + let user_item = transcript.push("user", content.clone(), "browser_request", "accepted"); + match self.runtime.send_input( + &worker.runtime_id, + &worker.worker_id, + WorkerInputRequest { + kind: WorkerInputKind::User, + content, + }, + ) { + Ok(result) if result.state == WorkerOperationState::Accepted => { + diagnostics.extend(result.diagnostics); + } + Ok(result) => { + diagnostics.extend(result.diagnostics); + diagnostics.push(diagnostic( + "companion_runtime_input_rejected", + DiagnosticSeverity::Error, + "Embedded Companion Worker rejected the browser message", + )); + return response_from_locked_transcript( + &transcript, + CompanionState::Error, + Some(worker), + Some(user_item), + None, + 0, + 200, + diagnostics, + ); + } + Err(error) => { + diagnostics.push(diagnostic( + "companion_runtime_input_failed", + DiagnosticSeverity::Error, + format!("Embedded Companion Worker input failed: {error:?}"), + )); + return response_from_locked_transcript( + &transcript, + CompanionState::Error, + Some(worker), + Some(user_item), + None, + 0, + 200, + diagnostics, + ); + } + } + + diagnostics.push(diagnostic( + "companion_providerless_boundary", + DiagnosticSeverity::Info, + "Real LLM completion is not connected in this MVP; response is the backend provider-less boundary text", + )); + let assistant_item = transcript.push( + "assistant", + providerless_response_text(), + "backend_providerless_boundary", + "complete", + ); + response_from_locked_transcript( + &transcript, + CompanionState::Accepted, + Some(worker), + Some(user_item), + Some(assistant_item), + 0, + 200, + diagnostics, + ) + } + + pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse { + let diagnostics = vec![diagnostic( + "companion_cancel_no_active_run", + DiagnosticSeverity::Info, + "Provider-less Companion Console has no active generation to cancel", + )]; + match self.transcript.lock() { + Ok(transcript) => response_from_locked_transcript( + &transcript, + CompanionState::Cancelled, + self.status().worker, + None, + None, + 0, + 200, + diagnostics, + ), + Err(_) => CompanionMessageResponse { + state: CompanionState::Error, + worker: self.status().worker, + user_item: None, + assistant_item: None, + transcript: CompanionTranscriptProjection { + state: CompanionState::Error, + start: 0, + limit: 200, + total_items: 0, + next_start: None, + items: Vec::new(), + diagnostics: vec![diagnostic( + "companion_transcript_unavailable", + DiagnosticSeverity::Error, + "Companion transcript is unavailable", + )], + }, + diagnostics, + }, + } + } + + fn current_worker( + &self, + ) -> Result<(Option, Vec), RuntimeDiagnostic> { + let worker = self.worker.lock().map_err(|_| { + diagnostic( + "companion_state_unavailable", + DiagnosticSeverity::Error, + "Companion state is unavailable", + ) + })?; + Ok((worker.worker.clone(), worker.diagnostics.clone())) + } + + fn rejected_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse { + match self.transcript.lock() { + Ok(transcript) => response_from_locked_transcript( + &transcript, + CompanionState::Rejected, + self.status().worker, + None, + None, + 0, + 200, + vec![diagnostic], + ), + Err(_) => CompanionMessageResponse { + state: CompanionState::Rejected, + worker: self.status().worker, + user_item: None, + assistant_item: None, + transcript: CompanionTranscriptProjection { + state: CompanionState::Error, + start: 0, + limit: 200, + total_items: 0, + next_start: None, + items: Vec::new(), + diagnostics: vec![diagnostic.clone()], + }, + diagnostics: vec![diagnostic], + }, + } + } + + fn busy_message_response(&self) -> CompanionMessageResponse { + let diagnostic = diagnostic( + "companion_busy", + DiagnosticSeverity::Warning, + "Companion Console is already processing a message", + ); + match self.transcript.lock() { + Ok(transcript) => response_from_locked_transcript( + &transcript, + CompanionState::Busy, + self.status().worker, + None, + None, + 0, + 200, + vec![diagnostic], + ), + Err(_) => CompanionMessageResponse { + state: CompanionState::Busy, + worker: self.status().worker, + user_item: None, + assistant_item: None, + transcript: CompanionTranscriptProjection { + state: CompanionState::Busy, + start: 0, + limit: 200, + total_items: 0, + next_start: None, + items: Vec::new(), + diagnostics: vec![diagnostic.clone()], + }, + diagnostics: vec![diagnostic], + }, + } + } + + fn error_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse { + CompanionMessageResponse { + state: CompanionState::Error, + worker: self.status().worker, + user_item: None, + assistant_item: None, + transcript: CompanionTranscriptProjection { + state: CompanionState::Error, + start: 0, + limit: 200, + total_items: 0, + next_start: None, + items: Vec::new(), + diagnostics: vec![diagnostic.clone()], + }, + diagnostics: vec![diagnostic], + } + } +} + +impl CompanionTranscript { + fn push( + &mut self, + role: impl Into, + content: impl Into, + source: impl Into, + status: impl Into, + ) -> CompanionTranscriptItem { + self.next_sequence = self.next_sequence.saturating_add(1); + let item = CompanionTranscriptItem { + sequence: self.next_sequence, + role: role.into(), + content: content.into(), + created_at: Utc::now().to_rfc3339(), + source: source.into(), + status: status.into(), + }; + self.items.push(item.clone()); + item + } +} + +fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState { + let request = WorkerSpawnRequest { + intent: WorkerSpawnIntent::WorkspaceCompanion, + requested_worker_name: Some("workspace-companion".to_string()), + acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { + expected_segments: 0, + }, + profile: Some(ProfileSelector::RuntimeDefault), + config_bundle: None, + requested_capabilities: vec![CapabilityRequest::named("conversation")], + }; + match runtime.spawn_worker(COMPANION_RUNTIME_ID, request) { + Ok(result) if result.state == WorkerOperationState::Accepted => CompanionWorkerState { + state: CompanionState::Ready, + worker: result.worker, + diagnostics: result.diagnostics, + }, + Ok(result) => CompanionWorkerState { + state: CompanionState::Error, + worker: result.worker, + diagnostics: result.diagnostics, + }, + Err(error) => CompanionWorkerState { + state: CompanionState::Error, + worker: None, + diagnostics: vec![diagnostic( + "companion_worker_spawn_failed", + DiagnosticSeverity::Error, + format!("Companion Worker spawn failed: {error:?}"), + )], + }, + } +} + +fn response_from_locked_transcript( + transcript: &CompanionTranscript, + state: CompanionState, + worker: Option, + user_item: Option, + assistant_item: Option, + start: usize, + limit: usize, + diagnostics: Vec, +) -> CompanionMessageResponse { + CompanionMessageResponse { + state, + worker, + user_item, + assistant_item, + transcript: project_transcript(transcript, state, start, limit, diagnostics.clone()), + diagnostics, + } +} + +fn project_transcript( + transcript: &CompanionTranscript, + state: CompanionState, + start: usize, + limit: usize, + diagnostics: Vec, +) -> CompanionTranscriptProjection { + let limit = limit.min(200); + let total_items = transcript.items.len(); + let end = start.saturating_add(limit).min(total_items); + let items = if start < total_items { + transcript.items[start..end].to_vec() + } else { + Vec::new() + }; + CompanionTranscriptProjection { + state, + start, + limit, + total_items, + next_start: (end < total_items).then_some(end), + items, + diagnostics, + } +} + +fn providerless_response_text() -> String { + PROVIDERLESS_RESPONSE.trim().to_string() +} + +fn providerless_transport() -> CompanionTransportSummary { + CompanionTransportSummary { + kind: "providerless_backend_internal".to_string(), + completion: "synchronous_request_response".to_string(), + limitation: "No provider-backed LLM generation is wired in this MVP; browser messages are recorded by a backend-internal tools-less Companion Worker and receive a resource-defined boundary response.".to_string(), + } +} + +fn diagnostic( + code: impl Into, + severity: DiagnosticSeverity, + message: impl Into, +) -> RuntimeDiagnostic { + RuntimeDiagnostic { + code: code.into(), + severity, + message: message.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hosts::{EmbeddedWorkerRuntime, LocalWorkerRuntime, RuntimeRegistry}; + + #[test] + fn companion_spawns_visible_worker_and_records_providerless_turn() { + let registry = RuntimeRegistry::for_workspace( + LocalWorkerRuntime::new("local:test", "/workspace/project", None), + EmbeddedWorkerRuntime::new_memory("local:test"), + ); + let registry = Arc::new(registry); + let companion = CompanionConsole::new(registry.clone()); + + let status = companion.status(); + assert_eq!(status.state, CompanionState::Ready); + let worker = status.worker.clone().expect("companion worker"); + assert_eq!(worker.runtime_id, COMPANION_RUNTIME_ID); + assert_eq!(worker.role.as_deref(), Some("workspace_companion")); + assert!(!worker.capabilities.can_stop); + + let workers = registry.list_workers(10); + assert!( + workers + .items + .iter() + .any(|item| item.worker_id == worker.worker_id) + ); + + let response = companion.send_message(CompanionMessageRequest { + content: "hello".to_string(), + }); + assert_eq!(response.state, CompanionState::Accepted); + assert_eq!(response.transcript.items.len(), 2); + assert_eq!(response.transcript.items[0].role, "user"); + assert_eq!(response.transcript.items[1].role, "assistant"); + assert!( + response.transcript.items[1] + .content + .contains("provider-less") + ); + + let runtime_transcript = registry + .transcript(COMPANION_RUNTIME_ID, &worker.worker_id, 0, 10) + .unwrap(); + assert_eq!(runtime_transcript.items.len(), 1); + assert_eq!(runtime_transcript.items[0].role, "user"); + + let browser_payload = serde_json::to_string(&(status, response)).unwrap(); + for forbidden in [ + "/workspace/project", + "metadata.json", + ".jsonl", + "/run/user/", + ] { + assert!( + !browser_payload.contains(forbidden), + "companion projection leaked forbidden term {forbidden}: {browser_payload}" + ); + } + } +} diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index ef7305e9..0692a223 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -4,6 +4,7 @@ //! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files //! remain the canonical project records and are read through bounded bridge APIs. +pub mod companion; pub mod hosts; pub mod identity; pub mod observation; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 421c1e91..22feb94f 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -12,6 +12,10 @@ use futures::StreamExt; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; +use crate::companion::{ + CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, + CompanionStatusResponse, CompanionTranscriptProjection, +}; use crate::hosts::{ ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime, HostSummary, LocalWorkerRuntime, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, @@ -80,6 +84,7 @@ pub struct WorkspaceApi { store: Arc, records: LocalProjectRecordReader, runtime: Arc, + companion: Arc, observation_proxy: BackendObservationProxy, } @@ -107,12 +112,14 @@ impl WorkspaceApi { .register(RemoteWorkerRuntime::new(remote_config).map_err(|err| err.into_error())?); } let runtime = Arc::new(runtime); + let companion = Arc::new(CompanionConsole::new(runtime.clone())); let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone()); Ok(Self { records: LocalProjectRecordReader::new(config.workspace_root.clone()), config, store, runtime, + companion, observation_proxy, }) } @@ -154,6 +161,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { .route("/api/hosts", get(list_hosts)) .route("/api/runtimes", get(list_runtimes)) .route("/api/workers", get(list_workers)) + .route("/api/companion/status", get(get_companion_status)) + .route("/api/companion/transcript", get(get_companion_transcript)) + .route("/api/companion/messages", post(post_companion_message)) + .route("/api/companion/cancel", post(post_companion_cancel)) .route( "/api/runtimes/{runtime_id}/workers", post(create_runtime_worker), @@ -221,6 +232,7 @@ pub struct ExtensionPoints { pub store: String, pub event_stream: ExtensionPointState, pub host_worker_bridge: ExtensionPointState, + pub companion_console: ExtensionPointState, } #[derive(Debug, Serialize, Deserialize)] @@ -329,6 +341,10 @@ async fn get_workspace(State(api): State) -> ApiResult, +) -> ApiResult> { + Ok(Json(api.companion.status())) +} + +async fn get_companion_transcript( + State(api): State, + Query(query): Query, +) -> ApiResult> { + let limit = query.limit.unwrap_or(api.config.max_records).min(200); + let start = query.start.unwrap_or(0); + Ok(Json(api.companion.transcript(start, limit))) +} + +async fn post_companion_message( + State(api): State, + Json(request): Json, +) -> ApiResult> { + Ok(Json(api.companion.send_message(request))) +} + +async fn post_companion_cancel( + State(api): State, + Json(request): Json, +) -> ApiResult> { + Ok(Json(api.companion.cancel(request))) +} + async fn get_runtime_worker( State(api): State, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, @@ -1095,12 +1140,51 @@ mod tests { assert_eq!(runtimes["items"][0]["host_ids"][0], host_id); let workers = get_json(app.clone(), "/api/workers").await; - assert!(workers["items"].as_array().unwrap().is_empty()); + let worker_items = workers["items"].as_array().unwrap(); + let companion_worker = worker_items + .iter() + .find(|worker| worker["role"] == "workspace_companion") + .expect("companion worker is visible through runtime worker API"); + assert_eq!(companion_worker["runtime_id"], "embedded-worker-runtime"); + assert_eq!(companion_worker["capabilities"]["can_stop"], false); assert_eq!( workers["diagnostics"][0]["code"], "local_pod_registry_unreadable" ); + let companion_status = get_json(app.clone(), "/api/companion/status").await; + assert_eq!(companion_status["state"], "ready"); + assert_eq!(companion_status["worker"]["role"], "workspace_companion"); + assert_eq!( + companion_status["transport"]["kind"], + "providerless_backend_internal" + ); + assert!(!companion_status.to_string().contains("/workspace/demo")); + + let companion_message = post_json( + app.clone(), + "/api/companion/messages", + json!({ "content": "hello companion" }), + ) + .await; + assert_eq!(companion_message["state"], "accepted"); + assert_eq!(companion_message["transcript"]["items"][0]["role"], "user"); + assert_eq!( + companion_message["transcript"]["items"][1]["role"], + "assistant" + ); + assert!( + companion_message["transcript"]["items"][1]["content"] + .as_str() + .unwrap() + .contains("provider-less") + ); + assert!(!companion_message.to_string().contains("/workspace/demo")); + + let companion_transcript = get_json(app.clone(), "/api/companion/transcript").await; + assert_eq!(companion_transcript["total_items"], 2); + assert_eq!(companion_transcript["items"][1]["role"], "assistant"); + let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await; assert!(host_workers["items"].as_array().unwrap().is_empty()); diff --git a/resources/prompts/worker/web_companion_providerless.md b/resources/prompts/worker/web_companion_providerless.md new file mode 100644 index 00000000..e70a139b --- /dev/null +++ b/resources/prompts/worker/web_companion_providerless.md @@ -0,0 +1,3 @@ +You are connected to the Yoi Workspace Web Console MVP provider-less boundary. + +I received your browser message through the backend-internal Companion Worker, but this MVP does not yet run a provider-backed LLM completion from the workspace server. The transcript/status/send path is active, tools-less, and scoped to conversation projection only. A later integration can replace this resource-defined boundary response with real Worker engine output without giving the browser runtime credentials, sockets, session paths, or filesystem authority. diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css index b3b1eec6..45724bf7 100644 --- a/web/workspace/src/app.css +++ b/web/workspace/src/app.css @@ -440,3 +440,240 @@ white-space: pre-wrap; } } + +.console-shell { + gap: 1rem; +} + +.console-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.console-status { + display: grid; + justify-items: end; + gap: 0.25rem; + min-width: 10rem; + padding: 0.75rem 0.9rem; + border-radius: 16px; + background: #ecfeff; + border: 1px solid #a5f3fc; + color: #0f766e; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.78rem; +} + +.console-status small { + text-transform: none; + letter-spacing: 0; + color: #475569; + font-weight: 600; +} + +.console-status[data-state='busy'] { + background: #fff7ed; + border-color: #fed7aa; + color: #c2410c; +} + +.console-status[data-state='error'], +.console-status[data-state='timeout'] { + background: #fef2f2; + border-color: #fecaca; + color: #b91c1c; +} + +.console-status[data-state='cancelled'], +.console-status[data-state='rejected'] { + background: #f8fafc; + border-color: #cbd5e1; + color: #475569; +} + +.console-transport { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.75rem; +} + +.console-transport div { + display: grid; + gap: 0.15rem; +} + +.console-transport dt { + margin: 0; + color: #64748b; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.console-transport dd { + margin: 0; + color: #0f172a; + font-weight: 700; +} + +.console-transport p { + grid-column: 1 / -1; + margin: 0; + color: #475569; + line-height: 1.5; +} + +.console-diagnostics { + display: grid; + gap: 0.4rem; +} + +.diagnostic { + margin: 0; + padding: 0.55rem 0.7rem; + border-radius: 10px; + background: #eff6ff; + color: #1d4ed8; + font-size: 0.86rem; +} + +.diagnostic.warning, +.diagnostic.warn { + background: #fff7ed; + color: #c2410c; +} + +.diagnostic.error { + background: #fef2f2; + color: #b91c1c; +} + +.transcript-card, +.composer-card { + display: grid; + gap: 1rem; +} + +.transcript-list { + display: grid; + gap: 0.85rem; + list-style: none; + margin: 0; + padding: 0; +} + +.transcript-item { + display: grid; + gap: 0.45rem; + padding: 0.85rem 1rem; + border-radius: 16px; + border: 1px solid #dbeafe; + background: #f8fafc; +} + +.transcript-item.user { + margin-left: min(8vw, 4rem); + background: #eff6ff; + border-color: #bfdbfe; +} + +.transcript-item.assistant { + margin-right: min(8vw, 4rem); + background: #f0fdf4; + border-color: #bbf7d0; +} + +.message-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.55rem; + color: #64748b; + font-size: 0.78rem; +} + +.message-meta strong { + color: #0f172a; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.message-meta time { + margin-left: auto; +} + +.transcript-item p { + margin: 0; + white-space: pre-wrap; + line-height: 1.55; + color: #1f2937; +} + +.empty-state { + margin: 0; + padding: 1rem; + border-radius: 14px; + background: #f8fafc; + color: #64748b; +} + +.composer-card label { + font-weight: 800; + color: #0f172a; +} + +.composer-card textarea { + width: 100%; + min-height: 8rem; + resize: vertical; + border: 1px solid #cbd5e1; + border-radius: 14px; + padding: 0.85rem 1rem; + font: inherit; + color: #0f172a; + background: #ffffff; +} + +.composer-card textarea:disabled { + background: #f8fafc; + color: #64748b; +} + +.composer-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.7rem; +} + +.composer-actions span { + margin-right: auto; + color: #64748b; + font-size: 0.85rem; +} + +.composer-actions button { + border: 0; + border-radius: 999px; + padding: 0.65rem 1rem; + background: #2563eb; + color: #ffffff; + font-weight: 800; + cursor: pointer; +} + +.composer-actions button.secondary { + background: #e2e8f0; + color: #334155; +} + +.composer-actions button:disabled { + cursor: not-allowed; + opacity: 0.55; +} diff --git a/web/workspace/src/lib/workspace-sidebar/CompanionNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/CompanionNavSection.svelte new file mode 100644 index 00000000..a9ddc043 --- /dev/null +++ b/web/workspace/src/lib/workspace-sidebar/CompanionNavSection.svelte @@ -0,0 +1,20 @@ + + + diff --git a/web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte index c65111f7..ee26c310 100644 --- a/web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte +++ b/web/workspace/src/lib/workspace-sidebar/WorkspaceSidebar.svelte @@ -1,4 +1,5 @@ + + + Companion Console · Yoi Workspace + + + +
+ + +
+
+
+

Backend-internal Companion

+

Companion Console

+

+ Browser traffic stays behind Workspace API projections. No Worker socket, session path, + runtime credential, or local session file is exposed to the frontend. +

+
+
+ {operationState} + {#if status?.worker} + {status.worker.label} + {:else} + worker pending + {/if} +
+
+ + {#if status?.transport} +
+
+
Transport
+
{status.transport.kind}
+
+
+
Completion
+
{status.transport.completion}
+
+

{status.transport.limitation}

+
+ {/if} + + {#if error || timeoutNotice || diagnostics.length > 0} +
+ {#if timeoutNotice} +

{timeoutNotice}

+ {/if} + {#if error} +

{error}

+ {/if} + {#each diagnostics as diagnostic} +

{diagnostic.code}: {diagnostic.message}

+ {/each} +
+ {/if} + +
+
+

Transcript

+ {transcript?.total_items ?? 0} items +
+ {#if messages.length === 0} +

No Companion messages yet. Send a message to exercise the backend boundary.

+ {:else} +
    + {#each messages as message (message.sequence)} +
  1. +
    + {message.role} + {message.status} + +
    +

    {message.content}

    +
  2. + {/each} +
+ {/if} +
+ +
+ + +
+ {draft.trim().length}/8000 + + + +
+
+
+