diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 485ff09d2..7bfe7213c 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -240,6 +240,16 @@ pub enum Method { #[serde(skip_deserializing, default)] source: AuthenticatedInputSource, }, + /// Agent-controlled input: accept only at an Idle controller boundary, never + /// enqueue behind a run. Unlike human Submit, non-Idle admission is rejected. + #[cfg_attr(feature = "typescript", ts(skip))] + #[cfg_attr(feature = "json-schema", schemars(skip))] + SubmitIfIdle { + submission_request_id: String, + input: Vec, + #[serde(skip_deserializing, default)] + source: AuthenticatedInputSource, + }, /// Human-readable text injected into the target Worker's LLM context as a /// non-blocking system message. Accepted notifications are delivered at the /// next between-turn boundary, or start a coalesced notification run while diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index ab2a611af..757ab0ee0 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -402,6 +402,8 @@ pub struct WorkingDirectoryStatus { #[serde(rename_all = "snake_case")] pub enum WorkerInputKind { User, + /// Agent input accepted only while Idle; never enters the human Submit queue. + UserIfIdle, Notify, Compact, ListRewindTargets, diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 8b564cb7a..9c1a4f7fa 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -1556,6 +1556,19 @@ fn authorize_runtime_protocol_method( submission_request_id, input, }, + protocol::Method::SubmitIfIdle { + submission_request_id, + input, + .. + } => protocol::Method::SubmitIfIdle { + source: transport_source.cloned().unwrap_or_else(|| { + protocol::AuthenticatedInputSource::Backend { + operation_id: submission_request_id.clone(), + } + }), + submission_request_id, + input, + }, protocol::Method::NotifyTracked { notification_request_id, message, @@ -3477,14 +3490,31 @@ mod tests { conflicting_request.create_fingerprint = "different-fingerprint".to_string(); let generated = client.create_worker(generated_request).await.unwrap(); assert_eq!(generated.worker.worker_id.to_string(), generated_worker_id); - let generated_input: runtime_api::WorkerInput = serde_json::from_value( - serde_json::to_value(WorkerInput::user("generated client input")).unwrap(), - ) - .unwrap(); - client - .send_worker_input(generated_worker_id.clone(), generated_input) - .await - .unwrap(); + for kind in [ + crate::interaction::WorkerInputKind::User, + crate::interaction::WorkerInputKind::UserIfIdle, + ] { + let mut input = WorkerInput::user("generated client input"); + input.kind = kind.clone(); + input.submission_request_id = Some(format!("generated-{kind:?}")); + // The remote adapter performs this same conversion before sending. + // Both the generated API contract and the HTTP receiver must retain + // Idle-only admission rather than silently downgrading it to User. + let generated_input: runtime_api::WorkerInput = + serde_json::from_value(serde_json::to_value(&input).unwrap()).unwrap(); + assert_eq!( + serde_json::to_value(&generated_input).unwrap(), + serde_json::to_value(&input).unwrap() + ); + let response = client + .send_worker_input(generated_worker_id.clone(), generated_input) + .await + .unwrap(); + assert_eq!( + Some(response.ack.submission.unwrap().submission_request_id), + input.submission_request_id + ); + } client .stop_worker( generated_worker_id.clone(), diff --git a/crates/worker-runtime/src/interaction.rs b/crates/worker-runtime/src/interaction.rs index e3324e998..5fd3371f1 100644 --- a/crates/worker-runtime/src/interaction.rs +++ b/crates/worker-runtime/src/interaction.rs @@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "snake_case")] pub enum WorkerInputKind { User, + /// Agent input that must never enter the human Submit queue. + UserIfIdle, Notify, Compact, ListRewindTargets, diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index d39c0cbc9..f16d5f603 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -2090,7 +2090,10 @@ impl Runtime { mut input: WorkerInput, ) -> Result { validate_worker_input(&input)?; - if matches!(input.kind, WorkerInputKind::User | WorkerInputKind::Notify) { + if matches!( + input.kind, + WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify + ) { let request_id = input .submission_request_id .clone() @@ -2098,7 +2101,11 @@ impl Runtime { .unwrap_or_else(|| Uuid::now_v7().to_string()); input.submission_request_id = Some(request_id); } - let expected_submission_id = (input.kind == WorkerInputKind::User).then(|| { + let expected_submission_id = matches!( + input.kind, + WorkerInputKind::User | WorkerInputKind::UserIfIdle + ) + .then(|| { input .submission_request_id .clone() @@ -2110,6 +2117,17 @@ impl Runtime { .clone() .expect("Notify request id must be assigned before dispatch") }); + if input.kind == WorkerInputKind::UserIfIdle { + let state = self.lock()?; + let worker = state.worker(worker_ref)?; + // Stopped/restoring Workers have no controller to enforce admission. + // Live busy state is checked atomically by the controller, not here. + if worker.status == WorkerStatus::Stopped || worker.has_pending_lifecycle_operation() { + return Err(RuntimeError::InvalidRequest( + "WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress.".to_string(), + )); + } + } self.ensure_worker_execution(worker_ref)?; let (backend, handle) = { let state = self.lock()?; @@ -5319,7 +5337,7 @@ fn input_protocol_event(input: &WorkerInput) -> Option { match input.kind { // Submit is projected only after the Worker commits UserInput. Queued // payloads must never become model- or client-visible history early. - WorkerInputKind::User | WorkerInputKind::Notify => None, + WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify => None, WorkerInputKind::Compact | WorkerInputKind::ListRewindTargets | WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem { diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 5c1673a16..c1ffeca2d 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -3007,7 +3007,7 @@ where } let (method, submission_request_id) = match input.kind { - WorkerInputKind::User => { + WorkerInputKind::User | WorkerInputKind::UserIfIdle => { let Some(submission_id) = input .submission_request_id .filter(|submission_id| !submission_id.trim().is_empty()) @@ -3017,18 +3017,26 @@ where "Runtime user input is missing its internal submission id", ); }; - ( + let segments = input + .segments + .unwrap_or_else(|| vec![Segment::text(input.content.trim().to_string())]); + let source = protocol::AuthenticatedInputSource::Backend { + operation_id: submission_id.clone(), + }; + let method = if input.kind == WorkerInputKind::UserIfIdle { + Method::SubmitIfIdle { + submission_request_id: submission_id.clone(), + input: segments, + source, + } + } else { Method::SubmitTracked { submission_request_id: submission_id.clone(), - input: input.segments.unwrap_or_else(|| { - vec![Segment::text(input.content.trim().to_string())] - }), - source: protocol::AuthenticatedInputSource::Backend { - operation_id: submission_id.clone(), - }, - }, - Some(submission_id), - ) + input: segments, + source, + } + }; + (method, Some(submission_id)) } WorkerInputKind::Notify => { unreachable!("Notify input is dispatched before ordinary input mapping") @@ -5606,6 +5614,124 @@ mod tests { assert_eq!(call_count.load(Ordering::SeqCst), 3); } + #[test] + #[cfg(feature = "ws-server")] + #[serial_test::serial(worker_allocation)] + fn idle_only_input_acknowledges_idle_and_rejects_running_paused_and_stopped() { + let client = MockClient::sequential(vec![MockResponse::Hang( + simple_text_events().into_iter().take(2).collect(), + )]); + let runtime_base = tempfile::tempdir().unwrap(); + let cwd = tempfile::tempdir().unwrap(); + let store = tempfile::tempdir().unwrap(); + let factory = MockFactory { + client, + runtime_base: runtime_base.path().to_path_buf(), + cwd: cwd.path().to_path_buf(), + store_dir: store.path().join("sessions"), + worker_metadata_dir: store.path().join("workers"), + observed_cwds: Arc::new(Mutex::new(Vec::new())), + observed_workspace_clients: Arc::new(Mutex::new(Vec::new())), + }; + let backend = Arc::new(WorkerRuntimeExecutionBackend::new(factory).unwrap()); + let runtime = + EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone()) + .unwrap(); + runtime.store_config_bundle(test_bundle()).unwrap(); + let detail = runtime.create_worker(create_request("idle-only")).unwrap(); + let input = |id: &str| { + let mut input = WorkerInput::user(id); + input.kind = WorkerInputKind::UserIfIdle; + input.submission_request_id = Some(id.to_string()); + input + }; + let ack = runtime + .send_input(&detail.worker_ref, input("idle-input")) + .unwrap(); + assert_eq!(ack.submission.unwrap().submission_request_id, "idle-input"); + wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Running); + let error = runtime + .send_input(&detail.worker_ref, input("running-input")) + .unwrap_err(); + assert!(error.to_string().contains("WorkerNotify"), "{error}"); + runtime + .send_protocol_method( + &detail.worker_ref, + Method::Pause { + command: adapter_command(&backend, &detail.worker_ref), + }, + ) + .unwrap(); + wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Paused); + let error = runtime + .send_input(&detail.worker_ref, input("paused-input")) + .unwrap_err(); + assert!(error.to_string().contains("WorkerNotify"), "{error}"); + let handle = backend + .workers + .lock() + .unwrap() + .get(&detail.worker_ref) + .unwrap() + .handle + .clone(); + let pending = backend + .run_on_adapter_runtime(async move { + let mut events = handle.subscribe(); + handle + .send(Method::ListPendingSubmissions) + .await + .map_err(|e| e.to_string())?; + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Ok(Event::PendingSubmissionsChanged { pending }) = + events.recv().await + { + break pending; + } + } + }) + .await + .map_err(|e| e.to_string()) + }) + .unwrap(); + assert!(pending.submissions.is_empty()); + // Human Submit still queues while paused, and Notify remains advisory. + let ack = runtime + .send_input(&detail.worker_ref, WorkerInput::user("human-input")) + .unwrap(); + assert_eq!( + ack.submission.unwrap().disposition, + protocol::SubmissionDisposition::Queued + ); + let mut notify = WorkerInput::user("advisory"); + notify.kind = WorkerInputKind::Notify; + assert!( + runtime + .send_input(&detail.worker_ref, notify) + .unwrap() + .notification + .is_some() + ); + wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Paused); + let handle = backend + .workers + .lock() + .unwrap() + .get(&detail.worker_ref) + .unwrap() + .handle + .clone(); + let entries = serde_json::to_string(&handle.committed_entries()).unwrap(); + assert!(!entries.contains("running-input")); + assert!(!entries.contains("paused-input")); + runtime.stop_worker(&detail.worker_ref, None).unwrap(); + let error = runtime + .send_input(&detail.worker_ref, input("stopped-input")) + .unwrap_err(); + assert!(error.to_string().contains("WorkerNotify"), "{error}"); + } + #[test] fn stopped_runtime_worker_can_restore_and_accept_input() { let client = MockClient::new(simple_text_events()); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 6b5b2efe9..0e4665d32 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -65,6 +65,14 @@ pub struct WorkerHandle { impl WorkerHandle { pub async fn send(&self, method: Method) -> Result<(), mpsc::error::SendError> { + // Reject known-busy sends before channel dispatch, including maintenance + // that cannot receive methods until it finishes. The controller checks + // again at admission to cover an Idle -> busy race after this snapshot. + if self.shared_state.snapshot().state != WorkerState::Idle + && reject_idle_only_submission(&method, &self.working_event_tx) + { + return Ok(()); + } self.method_tx.send(method).await } @@ -1551,6 +1559,21 @@ where } } +fn reject_idle_only_submission(method: &Method, events: &broadcast::Sender) -> bool { + let Method::SubmitIfIdle { + submission_request_id, + .. + } = method + else { + return false; + }; + let _ = events.send(Event::SubmissionRejected { + submission_request_id: submission_request_id.clone(), + message: "WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress.".to_string(), + }); + true +} + fn durably_accept_method_while_busy( method: Method, pending_submissions: &PendingSubmissionHandle, @@ -1559,6 +1582,9 @@ fn durably_accept_method_while_busy( where St: session_store::Store + Clone, { + if reject_idle_only_submission(&method, working_event_tx) { + return None; + } match method { Method::Submit { submission_request_id, @@ -1668,6 +1694,10 @@ fn reject_method_while_attention_locked( | Method::SubmitTracked { submission_request_id, .. + } + | Method::SubmitIfIdle { + submission_request_id, + .. } => { let _ = working_event_tx.send(Event::SubmissionRejected { submission_request_id: submission_request_id.clone(), @@ -1974,6 +2004,12 @@ async fn controller_loop( } }; + if shared_state.snapshot().state != WorkerState::Idle + && reject_idle_only_submission(&method, &working_event_tx) + { + continue; + } + if worker.has_pending_compaction_cleanup() { match worker.retry_pending_compaction_cleanup().await { Ok(()) => { @@ -2060,6 +2096,11 @@ async fn controller_loop( submission_request_id, input, source, + } + | Method::SubmitIfIdle { + submission_request_id, + input, + source, } => { let request_id = submission_request_id.clone(); let (source_namespace, provenance) = @@ -2446,7 +2487,9 @@ async fn controller_loop( ) .await; } - if let Err(error) = result { + if let Err(error) = result + && !matches!(&error, WorkerError::CompactCancelled) + { let _ = working_event_tx.send(Event::Error { code: worker_error_code(&error), message: error.to_string(), @@ -2870,11 +2913,7 @@ where (WorkerStatus::Paused, shutdown_requested, false) } Err(e) => { - let status = if matches!( - &e, - WorkerError::SegmentActivationIncomplete { .. } - | WorkerError::CompactionCleanupPending { .. } - ) { + let status = if worker_error_requires_attention(&e) { // Metadata has already committed the replacement, but // one live authority failed to publish. Keep the // controller visibly busy/fail-closed; publishing Idle @@ -2883,20 +2922,22 @@ where } else { WorkerStatus::Idle }; - let code = worker_error_code(&e); - let message = e.to_string(); - let _ = working_event_tx.send(Event::Error { - code, - message: message.clone(), - }); - if parent_originated { - crate::ipc::event::fire_and_forget( - parent_socket.cloned(), - protocol::WorkerEvent::Errored { - worker_name: self_name.to_string(), - message, - }, - ); + if !matches!(&e, WorkerError::RunFailureAlreadyDelivered { .. }) { + let code = worker_error_code(&e); + let message = e.to_string(); + let _ = working_event_tx.send(Event::Error { + code, + message: message.clone(), + }); + if parent_originated { + crate::ipc::event::fire_and_forget( + parent_socket.cloned(), + protocol::WorkerEvent::Errored { + worker_name: self_name.to_string(), + message, + }, + ); + } } (status, shutdown_requested, false) } @@ -3045,6 +3086,9 @@ where } } } + Some(method @ Method::SubmitIfIdle { .. }) => { + reject_idle_only_submission(&method, working_event_tx); + } Some(Method::SubmitTracked { submission_request_id, input, @@ -3359,8 +3403,22 @@ where } } +fn worker_error_requires_attention(error: &WorkerError) -> bool { + match error { + WorkerError::SegmentActivationIncomplete { .. } + | WorkerError::CompactionCleanupPending { .. } => true, + WorkerError::RunFailureAlreadyDelivered { source } + | WorkerError::RunTerminalPersistence { + run_failure: source, + .. + } => worker_error_requires_attention(source), + _ => false, + } +} + fn worker_error_code(e: &WorkerError) -> ErrorCode { match e { + WorkerError::RunFailureAlreadyDelivered { source } => worker_error_code(source), WorkerError::Engine(we) => match we { EngineError::Tool(_) => ErrorCode::ToolError, EngineError::Client(_) => ErrorCode::ProviderError, @@ -3586,6 +3644,36 @@ mod tests { tokio::time::timeout(timeout, accept).await.ok().flatten() } + #[tokio::test] + async fn idle_only_input_is_rejected_in_busy_maintenance_admission() { + let env = make_env().await; + let mut events = env.working_event_tx.subscribe(); + let method = Method::SubmitIfIdle { + submission_request_id: "maintenance-input".to_string(), + input: vec![protocol::Segment::text("must not queue")], + source: protocol::AuthenticatedInputSource::Backend { + operation_id: "maintenance-input".to_string(), + }, + }; + assert!( + durably_accept_method_while_busy( + method, + &env.pending_submissions, + &env.working_event_tx + ) + .is_none() + ); + let event = events.try_recv().unwrap(); + assert!( + matches!(event, Event::SubmissionRejected { submission_request_id, message } + if submission_request_id == "maintenance-input" && message.contains("WorkerNotify")) + ); + assert!( + events.try_recv().is_err(), + "must not emit durable acceptance or queue changes" + ); + } + #[tokio::test] async fn parent_originated_finished_fires_turn_ended() { let mut env = make_env().await; @@ -3939,6 +4027,87 @@ mod tests { } } + #[tokio::test] + async fn delivered_run_failure_is_not_reported_again_by_controller() { + let mut env = make_env().await; + let listener = UnixListener::bind(&env.parent_socket_path).expect("bind listener"); + let mut events = env.working_event_tx.subscribe(); + let worker_future = async { + Err::(WorkerError::RunFailureAlreadyDelivered { + source: Box::new(WorkerError::CompactSummaryMissing), + }) + }; + + let (status, _, may_drain_pending) = drive_turn( + worker_future, + &mut env.method_rx, + &env.working_event_tx, + &env.cancel_tx, + &env.pause_tx, + &env.shared_state, + &env.runtime_dir, + None, + &env.notify_buffer, + &env.pending_submissions, + Some(&env.parent_socket_path), + "child-worker", + &env.spawned_registry, + true, + ) + .await; + + assert_eq!(status, WorkerStatus::Idle); + assert!(!may_drain_pending, "failed runs must not become successful"); + assert!(events.try_recv().is_err(), "must not emit a generic Error"); + assert!( + tokio::time::timeout(Duration::from_millis(200), listener.accept()) + .await + .is_err(), + "must not send a second parent Errored event" + ); + } + + #[tokio::test] + async fn terminal_persistence_failure_reports_both_causes_once() { + let mut env = make_env().await; + let mut events = env.working_event_tx.subscribe(); + let worker_future = async { + Err::(WorkerError::RunTerminalPersistence { + run_failure: Box::new(WorkerError::CompactSummaryMissing), + persist_error: Box::new(WorkerError::Store(session_store::StoreError::Io( + std::io::Error::other("terminal append failed"), + ))), + }) + }; + + let (status, _, may_drain_pending) = drive_turn( + worker_future, + &mut env.method_rx, + &env.working_event_tx, + &env.cancel_tx, + &env.pause_tx, + &env.shared_state, + &env.runtime_dir, + None, + &env.notify_buffer, + &env.pending_submissions, + None, + "child-worker", + &env.spawned_registry, + false, + ) + .await; + + assert_eq!(status, WorkerStatus::Idle); + assert!(!may_drain_pending); + let Event::Error { message, .. } = events.try_recv().expect("one error event") else { + panic!("expected Error event"); + }; + assert!(message.contains("compact worker did not produce a summary")); + assert!(message.contains("terminal append failed")); + assert!(events.try_recv().is_err(), "must report once"); + } + #[tokio::test] async fn non_parent_originated_worker_error_stays_silent() { let mut env = make_env().await; diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index 1e2b4d964..4155db86d 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -245,11 +245,11 @@ impl WorkerControlService for WorkspaceWorkerControlService { "unknown Worker or permission not granted".to_string(), ) })?; - record - .session - .send(content) - .await - .map_err(|error| WorkspaceClientError::Request(error.to_string()))?; + record.session.send(content).await.map_err(|error| { + WorkspaceClientError::Request(format!( + "WorkerSendInput requires an Idle SubWorker and never queues input: {error}" + )) + })?; Ok(WorkspaceResponse { status: 200, body: serde_json::json!({ @@ -855,7 +855,7 @@ impl WorkerOperation { "Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input." } Self::SendInput => { - "Send user input to a known Runtime Worker. Idle Workers can start a fresh turn; Running or Paused Workers durably queue it for a later turn. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers require Idle." + "Start a fresh turn for an Idle Worker. Non-Idle Workers reject WorkerSendInput without queueing. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers also require Idle." } Self::Notify => { "Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit." @@ -1536,6 +1536,17 @@ mod tests { assert_eq!(client.requests.lock().unwrap().len(), 1); } + #[test] + fn send_input_rejection_is_a_tool_error_with_notify_guidance() { + let error = tool_output(WorkerOperation::SendInput, WorkspaceResponse { + status: 400, + body: "WorkerSendInput requires an Idle Worker. Use WorkerNotify for work already in progress.".to_string(), + }).unwrap_err(); + assert!(matches!(error, ToolError::ExecutionFailed(_))); + assert!(error.to_string().contains("WorkerNotify")); + assert!(!error.to_string().contains("completed")); + } + #[tokio::test] async fn control_service_routes_by_subject_without_cross_provider_fallback() { let client = Arc::new(RecordingWorkspaceClient::default()); diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index 6167c0050..21ff16e03 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -271,6 +271,17 @@ pub(crate) enum InternalWorkerSessionStatus { Failed, } +/// Typed result of the most recent reusable Internal Worker turn. +/// +/// Session status intentionally remains a compact presentation state. Owners that +/// make domain decisions (such as Compaction) must use this result instead of +/// guessing a stop cause from `Stopped` or parsing the presentation error string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum InternalWorkerTurnOutcome { + Completed(WorkerRunResult), + Failed { message: String }, +} + impl InternalWorkerSessionStatus { fn encode(self) -> u8 { match self { @@ -323,26 +334,53 @@ fn send_internal_worker_state( fn classify_internal_turn_result( result: Result, -) -> (InternalWorkerSessionStatus, Option) { +) -> ( + InternalWorkerSessionStatus, + Option, + InternalWorkerTurnOutcome, +) { match result { - Ok(WorkerRunResult::Finished) => (InternalWorkerSessionStatus::Idle, None), - Ok(WorkerRunResult::Paused) => (InternalWorkerSessionStatus::Paused, None), - Ok(WorkerRunResult::LimitReached) => ( - InternalWorkerSessionStatus::Stopped, - Some("internal Worker reached its turn limit".to_string()), + Ok(result @ WorkerRunResult::Finished) => ( + InternalWorkerSessionStatus::Idle, + None, + InternalWorkerTurnOutcome::Completed(result), ), - Ok(WorkerRunResult::Interrupted { message, .. }) => { - (InternalWorkerSessionStatus::Stopped, Some(message)) + Ok(result @ WorkerRunResult::Paused) => ( + InternalWorkerSessionStatus::Paused, + None, + InternalWorkerTurnOutcome::Completed(result), + ), + Ok(result @ WorkerRunResult::LimitReached) => { + let message = "internal Worker reached its turn limit".to_string(); + ( + InternalWorkerSessionStatus::Stopped, + Some(message), + InternalWorkerTurnOutcome::Completed(result), + ) } - Ok(WorkerRunResult::Cancelled) => ( + Ok(WorkerRunResult::Interrupted { code, message }) => ( + InternalWorkerSessionStatus::Stopped, + Some(message.clone()), + InternalWorkerTurnOutcome::Completed(WorkerRunResult::Interrupted { code, message }), + ), + Ok(result @ WorkerRunResult::Cancelled) => ( InternalWorkerSessionStatus::Stopped, Some("internal Worker run was cancelled".to_string()), + InternalWorkerTurnOutcome::Completed(result), ), - Ok(WorkerRunResult::RolledBack) => ( + Ok(result @ WorkerRunResult::RolledBack) => ( InternalWorkerSessionStatus::Stopped, Some("internal Worker run was cancelled before AI output".to_string()), + InternalWorkerTurnOutcome::Completed(result), ), - Err(error) => (InternalWorkerSessionStatus::Failed, Some(error.to_string())), + Err(error) => { + let message = error.to_string(); + ( + InternalWorkerSessionStatus::Failed, + Some(message.clone()), + InternalWorkerTurnOutcome::Failed { message }, + ) + } } } @@ -389,6 +427,7 @@ pub(crate) struct InternalWorkerSessionHandle { event_tx: broadcast::Sender, visibility: InternalWorkerVisibility, last_error: Arc>>, + last_outcome: Arc>>, child_registry: Option>, sink: SegmentLogSink, #[cfg(test)] @@ -488,6 +527,8 @@ impl InternalWorkerSessionHandle { InternalWorkerSessionStatus::Idle => InternalWorkerSessionError::Unavailable, }, )?; + *self.last_error.lock().unwrap() = None; + *self.last_outcome.lock().unwrap() = None; if self .command_tx .send(InternalWorkerSessionCommand::Run(input.into())) @@ -501,6 +542,9 @@ impl InternalWorkerSessionHandle { self.state_changed.notify_waiters(); let message = "internal Worker session actor is unavailable".to_owned(); *self.last_error.lock().unwrap() = Some(message.clone()); + *self.last_outcome.lock().unwrap() = Some(InternalWorkerTurnOutcome::Failed { + message: message.clone(), + }); let _ = self.event_tx.send(Event::Error { code: protocol::ErrorCode::Internal, message, @@ -524,6 +568,15 @@ impl InternalWorkerSessionHandle { } } + /// Wait for the active turn and return its typed outcome. + /// + /// `None` means the actor stopped without publishing a turn result; callers + /// must treat that as unknown rather than manufacturing a cancellation. + pub(crate) async fn wait_for_turn_outcome(&self) -> Option { + self.wait_until_idle().await; + self.last_outcome.lock().unwrap().clone() + } + #[cfg(test)] pub(crate) fn force_status(&self, status: InternalWorkerSessionStatus) { self.status @@ -808,6 +861,7 @@ pub(crate) async fn prepare_internal_worker_session( )); let state_changed = Arc::new(tokio::sync::Notify::new()); let last_error = Arc::new(Mutex::new(None)); + let last_outcome = Arc::new(Mutex::new(None)); let handle = InternalWorkerSessionHandle { command_tx, status: status.clone(), @@ -819,6 +873,7 @@ pub(crate) async fn prepare_internal_worker_session( event_tx: event_tx.clone(), visibility, last_error: last_error.clone(), + last_outcome: last_outcome.clone(), child_registry, sink, #[cfg(test)] @@ -836,8 +891,10 @@ pub(crate) async fn prepare_internal_worker_session( loop { tokio::select! { result = &mut run => { - let (turn_status, error) = classify_internal_turn_result(result); + let (turn_status, error, outcome) = + classify_internal_turn_result(result); actor_in_flight.clear(); + *last_outcome.lock().unwrap() = Some(outcome); status.store(turn_status.encode(), std::sync::atomic::Ordering::Release); if let Some(message) = error { *last_error.lock().unwrap() = Some(message.clone()); @@ -1148,6 +1205,7 @@ pub(crate) fn test_internal_worker_session( event_tx: event_tx.clone(), visibility, last_error: Arc::new(Mutex::new(None)), + last_outcome: Arc::new(Mutex::new(None)), child_registry: None, sink, fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1354,6 +1412,11 @@ permission = "write" InternalWorkerSessionStatus::Stopped, true, ), + ( + WorkerRunResult::Cancelled, + InternalWorkerSessionStatus::Stopped, + true, + ), ( WorkerRunResult::RolledBack, InternalWorkerSessionStatus::Stopped, @@ -1362,16 +1425,22 @@ permission = "write" ]; for (result, expected_status, expects_error) in cases { - let (status, error) = classify_internal_turn_result(Ok(result)); + let expected_outcome = InternalWorkerTurnOutcome::Completed(result.clone()); + let (status, error, outcome) = classify_internal_turn_result(Ok(result)); assert_eq!(status, expected_status); assert_eq!(error.is_some(), expects_error); + assert_eq!(outcome, expected_outcome); } - let (status, error) = classify_internal_turn_result(Err(WorkerError::Engine( + let (status, error, outcome) = classify_internal_turn_result(Err(WorkerError::Engine( EngineError::Aborted("fatal".to_string()), ))); assert_eq!(status, InternalWorkerSessionStatus::Failed); assert!(error.is_some_and(|message| message.contains("fatal"))); + assert!(matches!( + outcome, + InternalWorkerTurnOutcome::Failed { message } if message.contains("fatal") + )); } #[tokio::test] diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 296872011..bba265bdd 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -937,8 +937,8 @@ mod tests { "missing canonical tool {name}" ); } - assert!(orchestration.contains("durably queue it for a later turn")); - assert!(orchestration.contains("Direct SubWorkers still require Idle")); + assert!(orchestration.contains("requires an Idle Worker and never queues input")); + assert!(orchestration.contains("Direct SubWorkers also require Idle")); assert!(orchestration.contains("use `WorkerNotify` instead")); assert!(orchestration.contains("do not create a queued Submit")); for alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] { diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index c44521d75..b9e42ec4e 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -4949,11 +4949,19 @@ impl Worker { if let Some(block) = request_block { let error = automatic_compact_block_error(block); - if matches!(result, EngineRunExit::Yielded) { - self.persist_compaction_run_failure( + let error = if matches!(result, EngineRunExit::Yielded) { + self.terminalize_compaction_run_failure( request_block_message.expect("blocked request has a terminal reason"), - ); - } + error, + ) + } else { + // `persist_turn` above already committed this non-yielded Run's + // compaction terminal. Preserve Err semantics while preventing a + // second Controller Error for the same durable entry. + WorkerError::RunFailureAlreadyDelivered { + source: Box::new(error), + } + }; if let Some(state) = &self.compact_state { state.finish_logical_run(); } @@ -5007,33 +5015,33 @@ impl Worker { } } - fn persist_compaction_run_failure(&mut self, message: String) { - // Keep the original compaction/preparation error as controller authority. - // A terminal append failure is diagnosed separately and cannot publish a - // false persisted result through the session-log sink. - match self.commit_entry(LogEntry::RunErrored { + fn persist_compaction_run_failure(&mut self, message: String) -> Result<(), WorkerError> { + // The Store is the terminal authority. Only a successful append can be + // marked as already delivered to clients through the session-log sink. + self.commit_entry(LogEntry::RunErrored { ts: segment_log::now_millis(), interrupted: false, message, failure: Some(RunFailureKind::Compaction), - }) { - Ok(()) => { - self.last_run_interrupted = false; - self.engine_mut().set_active_run_turn_count(None); - } - Err(persist_error) => { - warn!( - error = %persist_error, - "failed to persist mid-run compaction terminal record" - ); - self.alert( - AlertLevel::Error, - AlertSource::Worker, - format!( - "mid-run compaction failed and its Run terminal record could not be saved: {persist_error}" - ), - ); - } + })?; + self.last_run_interrupted = false; + self.engine_mut().set_active_run_turn_count(None); + Ok(()) + } + + fn terminalize_compaction_run_failure( + &mut self, + message: String, + run_failure: WorkerError, + ) -> WorkerError { + match self.persist_compaction_run_failure(message) { + Ok(()) => WorkerError::RunFailureAlreadyDelivered { + source: Box::new(run_failure), + }, + Err(persist_error) => WorkerError::RunTerminalPersistence { + run_failure: Box::new(run_failure), + persist_error: Box::new(persist_error), + }, } } @@ -5086,9 +5094,7 @@ impl Worker { } let message = format!("mid-run resume preparation failed after compaction: {error}"); - self.persist_compaction_run_failure(message.clone()); - self.alert(AlertLevel::Error, AlertSource::Compactor, message); - return Err(error); + return Err(self.terminalize_compaction_run_failure(message, error)); } self.resume_prepared(RunResumeSource::Compaction).await } @@ -5110,14 +5116,11 @@ impl Worker { } // The yielded logical Run is no longer resumable. Commit its - // durable failure before any log-derived live event can claim - // the terminal transition. If storage itself is unavailable, - // the helper retains the original compaction error (and its - // fail-closed controller classification) while diagnosing the - // missing terminal record separately. - self.persist_compaction_run_failure(message.clone()); - self.alert(AlertLevel::Error, AlertSource::Compactor, message); - Err(e) + // durable failure before returning a typed handled error. The + // Controller suppresses only that exact handled variant; if + // persistence fails it instead reports both failures through + // the non-durable error channel. + Err(self.terminalize_compaction_run_failure(message, e)) } } }) @@ -5296,6 +5299,10 @@ impl Worker { info!(new_segment_id = %new_segment_id, "Manual compaction succeeded"); Ok(ManualCompactResult::Compacted { new_segment_id }) } + Err(WorkerError::CompactCancelled) => { + info!("Manual compaction was cancelled"); + Err(WorkerError::CompactCancelled) + } Err(e) => { warn!(error = %e, "Manual compaction failed"); self.alert( @@ -5959,25 +5966,7 @@ impl Worker { // outer compaction boundary performs the single stop attempt. return Err(WorkerError::InvalidState(error.to_string())); } - match handle.wait_until_idle().await { - crate::internal_worker::InternalWorkerSessionStatus::Idle => {} - crate::internal_worker::InternalWorkerSessionStatus::Stopped => { - return Err(WorkerError::CompactCancelled); - } - crate::internal_worker::InternalWorkerSessionStatus::Failed => { - return Err(WorkerError::InvalidState( - handle - .protocol_snapshot() - .error - .unwrap_or_else(|| "compactor Internal Worker failed".into()), - )); - } - status => { - return Err(WorkerError::InvalidState(format!( - "compactor Internal Worker ended in {status:?}" - ))); - } - } + ensure_compaction_internal_turn_succeeded(handle.wait_for_turn_outcome().await)?; // Guard: nudge the worker once more if the expected outputs // (summary, and any auto-read nominations when default refs @@ -6011,20 +6000,7 @@ impl Worker { .send(prompt) .await .map_err(|error| WorkerError::InvalidState(error.to_string()))?; - match handle.wait_until_idle().await { - crate::internal_worker::InternalWorkerSessionStatus::Idle => {} - crate::internal_worker::InternalWorkerSessionStatus::Stopped => { - return Err(WorkerError::CompactCancelled); - } - _ => { - return Err(WorkerError::InvalidState( - handle - .protocol_snapshot() - .error - .unwrap_or_else(|| "compactor Internal Worker failed".into()), - )); - } - } + ensure_compaction_internal_turn_succeeded(handle.wait_for_turn_outcome().await)?; } let mut final_ctx = ctx.lock().expect("compact ctx poisoned").clone(); @@ -6043,20 +6019,7 @@ impl Worker { .send(prompt) .await .map_err(|error| WorkerError::InvalidState(error.to_string()))?; - match handle.wait_until_idle().await { - crate::internal_worker::InternalWorkerSessionStatus::Idle => {} - crate::internal_worker::InternalWorkerSessionStatus::Stopped => { - return Err(WorkerError::CompactCancelled); - } - _ => { - return Err(WorkerError::InvalidState( - handle - .protocol_snapshot() - .error - .unwrap_or_else(|| "compactor Internal Worker failed".into()), - )); - } - } + ensure_compaction_internal_turn_succeeded(handle.wait_for_turn_outcome().await)?; final_ctx = ctx.lock().expect("compact ctx poisoned").clone(); summary_text = final_ctx .summary @@ -7912,6 +7875,37 @@ fn restored_flow_runtime_state( .transpose() } +fn ensure_compaction_internal_turn_succeeded( + outcome: Option, +) -> Result<(), WorkerError> { + use crate::internal_worker::InternalWorkerTurnOutcome; + + match outcome { + Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::Finished)) => Ok(()), + Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::LimitReached)) => { + Err(WorkerError::Engine(EngineError::Aborted( + "compactor Internal Worker reached its turn limit".to_string(), + ))) + } + Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::Interrupted { + message, + .. + })) => Err(WorkerError::Engine(EngineError::Aborted(message))), + Some(InternalWorkerTurnOutcome::Completed( + WorkerRunResult::Cancelled | WorkerRunResult::RolledBack, + )) => Err(WorkerError::CompactCancelled), + Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::Paused)) => Err( + WorkerError::InvalidState("compactor Internal Worker paused unexpectedly".to_string()), + ), + Some(InternalWorkerTurnOutcome::Failed { message }) => { + Err(WorkerError::InvalidState(message)) + } + None => Err(WorkerError::InvalidState( + "compactor Internal Worker stopped without a typed turn outcome".to_string(), + )), + } +} + fn automatic_compact_block_error(block: AutomaticCompactBlock) -> WorkerError { match block { AutomaticCompactBlock::Thrash => WorkerError::CompactThrash, @@ -8027,6 +8021,26 @@ pub enum WorkerError { #[error("compaction was cancelled")] CompactCancelled, + /// The logical Run failed, and its terminal entry was already committed and + /// published through the session-log authority. Controllers must preserve the + /// error outcome without emitting a second generic error event. + #[error("{source}")] + RunFailureAlreadyDelivered { + #[source] + source: Box, + }, + + /// The logical Run failed and its terminal entry could not be committed. + /// This remains unhandled so the Controller reports the run and persistence + /// failures together through its non-durable error channel. + #[error( + "run failed ({run_failure}) and its terminal record could not be persisted: {persist_error}" + )] + RunTerminalPersistence { + run_failure: Box, + persist_error: Box, + }, + #[error("compact summary too large: {tokens} tokens exceeds max {max}")] CompactSummaryTooLarge { tokens: u64, max: u64 }, @@ -9112,6 +9126,83 @@ mod build_summary_prompt_tests { } } + #[derive(Clone)] + struct FailRunTerminalStore { + inner: session_store::FsStore, + fail_run_terminal: Arc, + } + + impl Store for FailRunTerminalStore { + fn append( + &self, + session_id: SessionId, + segment_id: SegmentId, + entry: &LogEntry, + ) -> Result<(), StoreError> { + if matches!(entry, LogEntry::RunErrored { .. }) + && self.fail_run_terminal.swap(false, Ordering::SeqCst) + { + return Err(StoreError::Io(std::io::Error::other( + "synthetic terminal append failure", + ))); + } + self.inner.append(session_id, segment_id, entry) + } + + fn read_all( + &self, + session_id: SessionId, + segment_id: SegmentId, + ) -> Result, StoreError> { + self.inner.read_all(session_id, segment_id) + } + + fn list_sessions(&self) -> Result, StoreError> { + self.inner.list_sessions() + } + + fn list_segments(&self, session_id: SessionId) -> Result, StoreError> { + self.inner.list_segments(session_id) + } + + fn lookup_session_of( + &self, + segment_id: SegmentId, + ) -> Result, StoreError> { + self.inner.lookup_session_of(segment_id) + } + + fn create_segment( + &self, + session_id: SessionId, + segment_id: SegmentId, + entries: &[LogEntry], + ) -> Result<(), StoreError> { + self.inner.create_segment(session_id, segment_id, entries) + } + + fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result { + self.inner.exists(session_id, segment_id) + } + + fn read_entry_count( + &self, + session_id: SessionId, + segment_id: SegmentId, + ) -> Result { + self.inner.read_entry_count(session_id, segment_id) + } + + fn append_trace( + &self, + session_id: SessionId, + segment_id: SegmentId, + entry: &session_store::TraceEntry, + ) -> Result<(), StoreError> { + self.inner.append_trace(session_id, segment_id, entry) + } + } + #[derive(Clone)] struct NoopClient; @@ -9322,9 +9413,9 @@ mod build_summary_prompt_tests { .unwrap(); worker.last_run_interrupted = true; - worker.persist_compaction_run_failure( - "mid-run compaction failed: summary unavailable".into(), - ); + worker + .persist_compaction_run_failure("mid-run compaction failed: summary unavailable".into()) + .unwrap(); let entries = store .read_all(worker.session_id(), worker.segment_id()) @@ -9356,6 +9447,92 @@ mod build_summary_prompt_tests { assert_eq!(worker.engine().active_run_turn_count(), None); } + #[tokio::test] + async fn failed_compaction_terminal_append_stays_unhandled_and_unpublished() { + let dir = tempfile::tempdir().unwrap(); + let store = FailRunTerminalStore { + inner: session_store::FsStore::new(dir.path().join("sessions")).unwrap(), + fail_run_terminal: Arc::new(AtomicBool::new(false)), + }; + let mut worker = Worker::new( + minimal_manifest(), + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient), + store.clone(), + WorkerWorkspaceContext::no_workspace(), + WorkerFilesystemAuthority::None, + Scope::empty(), + ) + .await + .unwrap(); + worker.ensure_segment_head().await.unwrap(); + store.fail_run_terminal.store(true, Ordering::SeqCst); + + let error = worker.terminalize_compaction_run_failure( + "mid-run compaction failed: summary unavailable".into(), + WorkerError::CompactSummaryMissing, + ); + + assert!(matches!( + error, + WorkerError::RunTerminalPersistence { + run_failure, + persist_error, + } if matches!(*run_failure, WorkerError::CompactSummaryMissing) + && persist_error.to_string().contains("synthetic terminal append failure") + )); + assert!( + store + .read_all(worker.session_id(), worker.segment_id()) + .unwrap() + .iter() + .all(|entry| !matches!(entry, LogEntry::RunErrored { .. })) + ); + assert!( + worker + .sink() + .subscribe_with_snapshot() + .0 + .iter() + .all(|entry| !matches!(entry, LogEntry::RunErrored { .. })), + "a failed append must not be published as durable" + ); + } + + #[test] + fn compaction_internal_turn_preserves_typed_stop_causes() { + use crate::internal_worker::InternalWorkerTurnOutcome; + + let limit = ensure_compaction_internal_turn_succeeded(Some( + InternalWorkerTurnOutcome::Completed(WorkerRunResult::LimitReached), + )) + .unwrap_err(); + assert!( + matches!(limit, WorkerError::Engine(EngineError::Aborted(message)) if message.contains("turn limit")) + ); + + let interrupted = ensure_compaction_internal_turn_succeeded(Some( + InternalWorkerTurnOutcome::Completed(WorkerRunResult::Interrupted { + code: ErrorCode::ProviderError, + message: "summary provider failed".to_string(), + }), + )) + .unwrap_err(); + assert!( + matches!(interrupted, WorkerError::Engine(EngineError::Aborted(message)) if message == "summary provider failed") + ); + + let cancelled = ensure_compaction_internal_turn_succeeded(Some( + InternalWorkerTurnOutcome::Completed(WorkerRunResult::Cancelled), + )) + .unwrap_err(); + assert!(matches!(cancelled, WorkerError::CompactCancelled)); + + let unknown = ensure_compaction_internal_turn_succeeded(None).unwrap_err(); + assert!( + matches!(unknown, WorkerError::InvalidState(message) if message.contains("without a typed turn outcome")) + ); + } + #[tokio::test] async fn intentional_cancel_commits_a_non_failure_terminal() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index 3785e800d..2c71f3e46 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -991,12 +991,16 @@ async fn compact_failure_and_cancellation_emit_bounded_categories() { let encoded = serde_json::to_string(&failure.metric).unwrap(); assert!(!encoded.contains("missing summary")); - let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(true); + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + cancel_tx.send(true).unwrap(); let error = worker .manual_compact_with_cancel(cancel_rx) .await .unwrap_err(); - assert!(matches!(error, worker::WorkerError::CompactCancelled)); + assert!( + matches!(error, worker::WorkerError::CompactCancelled), + "unexpected cancellation error: {error:?}" + ); let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap(); let cancelled = metrics .iter() @@ -1503,7 +1507,14 @@ async fn compacted_context_above_request_threshold_fails_before_provider_request .await .expect_err("unsafe compacted context must fail closed"); - assert!(matches!(error, WorkerError::CompactThrash)); + assert!( + matches!( + error, + WorkerError::RunFailureAlreadyDelivered { ref source } + if matches!(**source, WorkerError::CompactThrash) + ), + "unexpected error: {error:?}" + ); assert_eq!( call_count.load(Ordering::SeqCst), 3, @@ -1526,6 +1537,100 @@ async fn compacted_context_above_request_threshold_fails_before_provider_request })); } +#[tokio::test] +async fn controller_does_not_repeat_a_persisted_mid_run_compaction_failure() { + let client = MockClient::new(vec![ + grow_tool_use_events("grow-before-compaction", 1_000), + single_text_events("missing summary"), + single_text_events("still missing summary"), + ]); + let (worker, store) = make_worker_with_manifest_and_store(MID_TURN_MANIFEST_TOML, client).await; + let session_id = worker.session_id(); + let runtime_tmp = tempfile::tempdir().unwrap(); + let bash_output_dir = runtime_tmp.path().join("bash-output"); + let (handle, shutdown_receiver) = + WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) + .await + .unwrap(); + let mut rx = handle.subscribe(); + + handle + .send(Method::submit_text( + protocol::new_submission_request_id(), + "trigger failing compaction", + )) + .await + .unwrap(); + let mut observed = Vec::new(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let event = rx.recv().await.expect("event"); + let idle = matches!( + event, + Event::WorkerState { + snapshot: protocol::WorkerStateSnapshot { + state: protocol::WorkerState::Idle, + .. + } + } + ); + observed.push(event); + if idle { + break; + } + } + }) + .await + .expect("timeout waiting for failed compaction and Idle"); + tokio::task::yield_now().await; + while let Ok(event) = rx.try_recv() { + observed.push(event); + } + + let delivered = handle + .committed_entries() + .into_iter() + .filter(|entry| matches!(entry, LogEntry::RunErrored { .. })) + .count(); + assert_eq!(delivered, 1, "the durable terminal is the only delivery"); + assert!( + observed + .iter() + .all(|event| !matches!(event, Event::Error { .. })), + "Controller must not add a generic Error after durable delivery" + ); + assert!( + observed.iter().all(|event| !matches!( + event, + Event::Alert(protocol::Alert { + level: protocol::AlertLevel::Error, + .. + }) + )), + "mid-run terminal failure must not add an Error Alert" + ); + + let terminal_count = store + .list_segments(session_id) + .unwrap() + .into_iter() + .flat_map(|segment_id| store.read_all(session_id, segment_id).unwrap()) + .filter(|entry| matches!(entry, LogEntry::RunErrored { .. })) + .count(); + assert_eq!(terminal_count, 1); + + handle + .send(Method::Shutdown { + command: protocol::WorkerCommandEnvelope::new(1), + }) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), shutdown_receiver) + .await + .expect("controller shutdown timeout") + .expect("shutdown confirmation"); +} + #[tokio::test] async fn pre_run_compact_failure_clears_runtime_progress() { // Only the first run has a response. Compaction will run the @@ -1621,6 +1726,7 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() { .expect("send compact cancel"); let mut saw_interrupted = false; let mut saw_idle = false; + let mut saw_cancel_error = false; while !(saw_interrupted && saw_idle) { match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await @@ -1630,6 +1736,11 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() { Event::CompactionProgress { compaction: None } => { saw_interrupted = true; } + Event::Error { .. } + | Event::Alert(protocol::Alert { + level: protocol::AlertLevel::Error, + .. + }) => saw_cancel_error = true, Event::WorkerState { snapshot } if snapshot.catalog_status() == protocol::WorkerStatus::Idle => { @@ -1642,6 +1753,10 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() { _ => {} } } + assert!( + !saw_cancel_error, + "intentional manual cancellation must not be reported as an Error" + ); let compact = protocol::WorkerCommandEnvelope::new(3); handle diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 7802cb4c5..75d6e8ee4 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -2895,6 +2895,97 @@ async fn drain_until bool>( } } +fn idle_only_input(request_id: &str) -> Method { + Method::SubmitIfIdle { + submission_request_id: request_id.to_string(), + input: vec![protocol::Segment::text(request_id)], + source: protocol::AuthenticatedInputSource::Backend { + operation_id: request_id.to_string(), + }, + } +} + +#[tokio::test] +async fn idle_only_submit_rejects_admission_race_and_paused_without_persisting() { + let client = MockClient::sequential(vec![MockResponse::Hang(vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "partial"), + ])]); + let handle = spawn_controller(make_worker(client.clone()).await).await; + let mut rx = handle.subscribe(); + assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle); + // Both callers can observe Idle. Admission of the first request must make + // the second fail rather than silently scheduling a later turn. + handle.send(idle_only_input("first-idle")).await.unwrap(); + handle.send(idle_only_input("racing-input")).await.unwrap(); + let mut accepted_first = false; + assert!( + drain_until(&mut rx, std::time::Duration::from_secs(2), |event| { + match event { + Event::SubmissionAccepted { + submission_request_id, + .. + } => { + assert_eq!(submission_request_id, "first-idle"); + accepted_first = true; + false + } + Event::SubmissionRejected { + submission_request_id, + message, + } => { + assert_eq!(submission_request_id, "racing-input"); + assert!(message.contains("WorkerNotify")); + true + } + _ => false, + } + }) + .await + ); + assert!(accepted_first); + wait_for_status(&handle, WorkerStatus::Running).await; + handle + .send(Method::Pause { + command: worker_command(&handle), + }) + .await + .unwrap(); + wait_for_status(&handle, WorkerStatus::Paused).await; + handle.send(idle_only_input("paused-input")).await.unwrap(); + assert!( + drain_until(&mut rx, std::time::Duration::from_secs(2), |event| { + matches!(event, Event::SubmissionRejected { submission_request_id, message } + if submission_request_id == "paused-input" && message.contains("WorkerNotify")) + }) + .await + ); + assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Paused); + handle.send(Method::ListPendingSubmissions).await.unwrap(); + assert!( + drain_until(&mut rx, std::time::Duration::from_secs(2), |event| { + if let Event::PendingSubmissionsChanged { pending } = event { + assert!(pending.submissions.is_empty()); + true + } else { + false + } + }) + .await + ); + assert_eq!(client.captured_requests().len(), 1); + let entries = serde_json::to_string(&handle.committed_entries()).unwrap(); + assert!(!entries.contains("racing-input")); + assert!(!entries.contains("paused-input")); + handle + .send(Method::Cancel { + command: worker_command(&handle), + }) + .await + .unwrap(); + wait_for_status(&handle, WorkerStatus::Idle).await; +} + /// Paused → Running → Idle. A notification accepted while Paused must not /// auto-resume the Worker, and explicit Resume must inject it while preserving /// the interrupted turn's history consistency. diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 839f945d7..aa903a20f 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -696,6 +696,8 @@ pub struct WorkerLifecycleResult { #[serde(rename_all = "snake_case")] pub enum WorkerInputKind { User, + /// Agent input that must never enter the human Submit queue. + UserIfIdle, Notify, Compact, ListRewindTargets, @@ -3037,6 +3039,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { let input = EmbeddedWorkerInput { kind: match request.kind { WorkerInputKind::User => EmbeddedWorkerInputKind::User, + WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle, WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify, WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact, WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets, @@ -5028,6 +5031,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { let input = EmbeddedWorkerInput { kind: match request.kind { WorkerInputKind::User => EmbeddedWorkerInputKind::User, + WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle, WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify, WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact, WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets, @@ -6337,6 +6341,28 @@ mod tests { assert_eq!(delivered.content, archive.content); } + #[test] + fn runtime_input_contract_preserves_every_input_kind() { + for kind in [ + EmbeddedWorkerInputKind::User, + EmbeddedWorkerInputKind::UserIfIdle, + EmbeddedWorkerInputKind::Notify, + EmbeddedWorkerInputKind::Compact, + EmbeddedWorkerInputKind::ListRewindTargets, + EmbeddedWorkerInputKind::RegisterPeer, + ] { + let input = EmbeddedWorkerInput { + kind, + content: "contract test".to_string(), + submission_request_id: Some("contract-request".to_string()), + segments: Some(vec![protocol::Segment::text("contract test")]), + }; + let wire: runtime_api::WorkerInput = runtime_contract_convert(input.clone()).unwrap(); + let decoded: EmbeddedWorkerInput = runtime_contract_convert(wire).unwrap(); + assert_eq!(decoded, input); + } + } + #[test] fn remote_profile_source_uses_workspace_config_archive_reference() { let request = embedded_spawn_request(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d76521768..11b0a321e 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -18051,7 +18051,30 @@ async fn send_known_worker_input( &path.worker, permission, )?; - scoped_send_runtime_worker_input(State(api), AxumPath(path), Json(request)).await + let mut request = worker_input_request_from_api(request)?; + let idle_only = request.kind == WorkerInputKind::User; + if idle_only { + request.kind = WorkerInputKind::UserIfIdle; + } + let Json(result) = + dispatch_runtime_worker_input(api, path.worker.runtime_id, path.worker.worker_id, request)?; + if result.state != server_api::WorkerOperationState::Accepted { + let detail = result + .diagnostics + .iter() + .map(|diagnostic| diagnostic.message.as_str()) + .collect::>() + .join("; "); + let message = if idle_only { + format!( + "WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress. {detail}" + ) + } else { + detail + }; + return Err(Error::InvalidInput(message).into()); + } + Ok(Json(result)) } async fn cancel_known_worker( @@ -24818,6 +24841,15 @@ async fn send_runtime_worker_input( Json(request): Json, ) -> ApiResult> { let request = worker_input_request_from_api(request)?; + dispatch_runtime_worker_input(api, runtime_id, worker_id, request) +} + +fn dispatch_runtime_worker_input( + api: WorkspaceApi, + runtime_id: String, + worker_id: String, + request: WorkerInputRequest, +) -> ApiResult> { let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; if api .store @@ -24927,7 +24959,9 @@ pub(crate) fn authorize_browser_worker_method( message, source: source.clone(), }), - protocol::Method::SubmitTracked { .. } | protocol::Method::NotifyTracked { .. } => { + protocol::Method::SubmitTracked { .. } + | protocol::Method::SubmitIfIdle { .. } + | protocol::Method::NotifyTracked { .. } => { Err("authenticated Worker input source is server-owned") } other => Ok(other), @@ -30254,6 +30288,7 @@ mod tests { #[derive(Clone, Default)] struct WorkdirlessFixtureRuntime { + input_requests: Arc>>, workers: Arc>>, spawn_requests: Arc>>, reject_next_spawn: Arc>, @@ -30380,6 +30415,30 @@ mod tests { } impl crate::hosts::WorkspaceWorkerRuntime for WorkdirlessFixtureRuntime { + fn send_input( + &self, + worker_id: &str, + request: WorkerInputRequest, + ) -> crate::hosts::WorkerInputResult { + let idle_only = request.kind == WorkerInputKind::UserIfIdle; + self.input_requests.lock().unwrap().push(request); + crate::hosts::WorkerInputResult { + state: if idle_only { + InternalWorkerOperationState::Rejected + } else { + InternalWorkerOperationState::Accepted + }, + disposition: if idle_only { + crate::hosts::WorkerInputDisposition::Rejected + } else { + crate::hosts::WorkerInputDisposition::Accepted + }, + worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id), + runtime_run_id: None, + diagnostics: Vec::new(), + } + } + fn runtime_id(&self) -> &str { Self::RUNTIME_ID } @@ -33719,6 +33778,76 @@ mod tests { assert_eq!(current.assignment_id, "existing-coder-assignment"); } + #[tokio::test] + async fn worker_control_input_is_idle_only_and_returns_rejection_as_http_error() { + let fixture = guarded_spawn_fixture().await; + let response = post_guarded_spawn( + &fixture, + guarded_spawn_payload(&fixture, "idle-only-control"), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let created: BrowserCreateWorkerResponse = serde_json::from_slice(&bytes).unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + "x-yoi-runtime-id", + fixture.controller.runtime_id.parse().unwrap(), + ); + headers.insert( + "x-yoi-worker-id", + fixture.controller.worker_id.parse().unwrap(), + ); + let path = || ScopedRuntimeWorkerPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + worker: RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id), + }; + let request = |kind: &str| server_api::RuntimeWorkerInputRequest { + kind: Some(kind.to_string()), + content: "follow-up".to_string(), + segments: None, + }; + let error = send_known_worker_input( + State(fixture.api.clone()), + AxumPath(path()), + headers.clone(), + Json(request("user")), + ) + .await + .unwrap_err(); + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(String::from_utf8_lossy(&bytes).contains("WorkerNotify")); + // Machine Notify is unchanged; the human endpoint must still use User. + let Json(notify) = send_known_worker_input( + State(fixture.api.clone()), + AxumPath(path()), + headers, + Json(request("notify")), + ) + .await + .unwrap(); + assert_eq!(notify.state, server_api::WorkerOperationState::Accepted); + let Json(human) = scoped_send_runtime_worker_input( + State(fixture.api.clone()), + AxumPath(path()), + Json(request("user")), + ) + .await + .unwrap(); + assert_eq!(human.state, server_api::WorkerOperationState::Accepted); + let requests = fixture.runtime.input_requests.lock().unwrap(); + assert_eq!( + requests.iter().map(|r| r.kind.clone()).collect::>(), + vec![ + WorkerInputKind::UserIfIdle, + WorkerInputKind::Notify, + WorkerInputKind::User + ] + ); + } + #[tokio::test] async fn worker_control_spawn_retry_converges_on_one_worker_and_one_grant() { let workspace = tempfile::tempdir().unwrap(); diff --git a/resources/prompts/common/worker-orchestration.md b/resources/prompts/common/worker-orchestration.md index 50a47793f..82258d87a 100644 --- a/resources/prompts/common/worker-orchestration.md +++ b/resources/prompts/common/worker-orchestration.md @@ -4,7 +4,7 @@ When SubWorker-management tools are available, create direct children with `SubWorkerSpawn`, discover them with `WorkerList`, continue them with `WorkerSendInput`, and release their delegated authority with `WorkerStop`. Pass the exact `{ kind: "sub_worker", name }` subject returned by `WorkerList`; do not invent direct-only aliases. SubWorker notifications are background signals for the parent Worker to handle at a natural stopping point. Do not ignore routine follow-up, but do not interrupt the current user request unnecessarily. -For Runtime Workers, `WorkerSendInput` submits user input: Idle Workers can start a fresh turn, while Running or Paused Workers durably queue it for a later turn. Queueing does not implicitly resume a paused run; Cancel/error retains pending input until explicitly continued. Direct SubWorkers still require Idle for `WorkerSendInput`. When `WorkerNotify` is available and information should be incorporated into work already in progress, use `WorkerNotify` instead; notifications are advisory and do not create a queued Submit. +`WorkerSendInput` requires an Idle Worker and never queues input. Non-Idle Workers reject it; when information should be incorporated into work already in progress, use `WorkerNotify` instead. Notifications are advisory and do not create a queued Submit. Direct SubWorkers also require Idle for `WorkerSendInput`; do not send follow-up input until they are Idle. The Submit queue is for human-controlled Web/TUI interaction, not Orchestrator scheduling. Do not retry SendInput in a polling loop or cancel/pause work to bypass this restriction. The parent Worker does not need to keep a turn open or call tools solely to wait for a notification. Do not use `sleep` or polling loops just to wait for SubWorker output; if there is no useful immediate work, return control and handle the SubWorker when notified or when the user next asks. diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index 63b7be793..058f424a3 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -181,6 +181,42 @@ is ignored by Git, while authentication state remains outside the repository. `c `target/web-ux` when `--output` is omitted. Keep a bundle outside Git or publish it through the approved immutable artifact channel when durable review evidence is required. +## Workspace Home fixture + +`scenarios/workspace-home.json` captures the production work dashboard with owner, non-owner, +long-name, empty, and intentionally failing-review Workspace projections at 1440px, 768px, and 390px. +The partial-error route deliberately returns HTTP 503 only for Merge Requests; captures should retain +that request/console/visible error while showing successful active-work and recent-update feeds. +It uses no real account or private Workspace data. Build `web/workspace` first, then run from `tools/web-ux`: + +```sh +deno task web-ux capture --scenario scenarios/workspace-home.json --output ../../target/web-ux --run-id home-review +``` + +The scenario owns a fixture server on localhost port 15281 and stops only that server. The production +Home browser regression (`browser-tests/workspace_home_shell_test.ts`, included in `test:browser`) +also checks 320px, both themes, keyboard record navigation, bounded loading, independent slow/error +feeds, explicit refresh, and absence of Host requests. + +## Workspace Identity settings fixture + +`scenarios/workspace-settings.json` uses the same owned fixture server with synthetic metadata, +public identity, and deletion-preflight responses. It covers owner, member, long name, pending +provisioning, and intentional signing-identity HTTP503 states at 1440px, 768px, and 390px. +Build `web/workspace` before capture. No real keys, accounts, or deletion requests are used. +`browser-tests/workspace_settings_shell_test.ts` also checks 320px and both themes, Edit/Save/Cancel, +keyboard disclosure/focus return, full public bundle access, scoped errors, and exact-name deletion +confirmation. Interaction screenshots are saved under `target/web-ux/settings-interactions`. + +## Console pending input fixture + +`scenarios/console-pending.json` uses the owned Console history fixture with `?pending=both`, +`queue`, `notifications`, `legacy`, or `empty`. Each state includes a task for typography comparison. +It captures 1440px, 768px, and 390px; `browser-tests/console_pending_shell_test.ts` additionally checks +320px and both themes, independent group visibility, full-width single groups, uniform 16px rows, +monospace inheritance, truncation, and retained-session cancellation gating. Live revision-fenced +cancellation and state transitions are covered by Console component tests. No real Worker is used. + ## Adding a scenario 1. Name the concrete user task and expected data state; do not write “looks correct”. diff --git a/tools/web-ux/browser-tests/console_history_fixture_server.ts b/tools/web-ux/browser-tests/console_history_fixture_server.ts index 2a4e56b22..627def943 100644 --- a/tools/web-ux/browser-tests/console_history_fixture_server.ts +++ b/tools/web-ux/browser-tests/console_history_fixture_server.ts @@ -174,17 +174,30 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { url.pathname === `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session` ) { + const pendingMode = new URL(request.headers.get("referer") ?? request.url).searchParams.get("pending"); + const hasQueue = pendingMode === "both" || pendingMode === "queue"; + const hasNotifications = pendingMode === "both" || pendingMode === "notifications" || pendingMode === "legacy"; + const submissions = hasQueue ? [ + { submission_id: "queued-1", preview: "First queued input", accepted_at_ms: 1, segment_count: 1, byte_len: 18 }, + { submission_id: "queued-2", preview: "Long queued input — " + "preview text ".repeat(30), accepted_at_ms: 2, segment_count: 1, byte_len: 500 }, + ] : []; + const taskEntries = pendingMode ? [{ + kind: "tool_call", entry_id: "fixture-task", timestamp: 13, provenance: "model_output", + call_id: "fixture-task", name: "TaskCreate", + arguments: JSON.stringify({ subject: "Inspect pending input layout", description: "Synthetic visual fixture" }), + }] : []; return json({ availability: "retained_snapshot", identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 36 }, snapshot: { pending_submissions: { revision: 0, - notification_count: 0, - head_id: null, - submissions: [], + notification_count: hasNotifications ? 2 : 0, + ...(hasNotifications && pendingMode !== "legacy" ? { notification_previews: ["First notification", "Long notification — " + "notification text ".repeat(30)] } : {}), + head_id: submissions[0]?.submission_id ?? null, + submissions, }, - entries: [7, 8, 9, 10, 11, 12].flatMap(historyTurnEntries), + entries: [...[7, 8, 9, 10, 11, 12].flatMap(historyTurnEntries), ...taskEntries], }, }); } diff --git a/tools/web-ux/browser-tests/console_history_shell_test.ts b/tools/web-ux/browser-tests/console_history_shell_test.ts index 9d2192627..f83a5f125 100644 --- a/tools/web-ux/browser-tests/console_history_shell_test.ts +++ b/tools/web-ux/browser-tests/console_history_shell_test.ts @@ -41,22 +41,134 @@ async function historyRequestCount(baseUrl: string): Promise { return state.history_requests; } +async function checkSidebarModeSwitch(page: Page, name: string): Promise { + const button = page.getByRole("button", { name, exact: true }); + await button.hover(); + await button.focus(); + const samples = await button.evaluate(async (button) => { + const frame = button.closest(".sidebar-frame")!; + await Promise.all(frame.getAnimations({ subtree: true }).map((animation) => animation.finished)); + const panel = frame.querySelector(".sidebar-frame__bevel")!; + const content = frame.querySelector(".sidebar-frame-content")!; + const main = document.querySelector(".app-shell__main")!; + const sample = () => ({ + mainLeft: main.getBoundingClientRect().left, + mainWidth: main.getBoundingClientRect().width, + stationary: { + panel: panel.getBoundingClientRect().toJSON(), + content: content.getBoundingClientRect().toJSON(), + button: button.getBoundingClientRect().toJSON(), + folded: frame.classList.contains("folded"), + unclipped: panel.contains(document.elementFromPoint(panel.getBoundingClientRect().right - 8, 40)), + motion: panel.getAnimations({ subtree: true }).filter((animation) => + animation instanceof CSSTransition && ["width", "transform"].includes(animation.transitionProperty) + ).length, + }, + }); + const samples = [sample()]; + (button as HTMLButtonElement).click(); + const start = performance.now(); + do { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + samples.push(sample()); + } while (performance.now() - start < 280); + return samples; + }); + for (const sample of samples) { + assertEquals(sample.stationary, samples[0].stationary, `${name} must keep the open panel stationary without slide animation`); + assertEquals(sample.stationary.folded, false); + assertEquals(sample.stationary.motion, 0); + assertEquals(sample.stationary.unclipped, true); + } + const first = samples[0]; + const last = samples.at(-1)!; + assert(samples.some((sample) => sample.mainLeft > Math.min(first.mainLeft, last.mainLeft) && + sample.mainLeft < Math.max(first.mainLeft, last.mainLeft)), "main position must animate through intermediate coordinates"); + assert(samples.some((sample) => sample.mainWidth > Math.min(first.mainWidth, last.mainWidth) && + sample.mainWidth < Math.max(first.mainWidth, last.mainWidth)), "main width must animate through intermediate sizes"); + await page.locator(".sidebar-fold-button").evaluate((button) => (button as HTMLButtonElement).blur()); +} + +async function checkSidebarPointerModeSwitch(page: Page, pinning: boolean): Promise { + const button = page.getByRole("button", { name: pinning ? "Pin sidebar" : "Unpin sidebar", exact: true }); + await button.hover(); + await page.mouse.down(); + await page.waitForTimeout(100); + assertEquals(await page.locator(".sidebar-frame.folded").count(), pinning ? 1 : 0, + "pointer-down must not open a preview before the mode changes on click"); + const sampling = button.evaluate(async (button) => { + const frame = button.closest(".sidebar-frame")!; + const panel = frame.querySelector(".sidebar-frame__bevel")!; + const main = document.querySelector(".app-shell__main")!; + const content = frame.querySelector(".sidebar-frame-content")!; + const sample = () => ({ + frameWidth: frame.getBoundingClientRect().width, + panelWidth: panel.getBoundingClientRect().width, + mainWidth: main.getBoundingClientRect().width, + expectedMainWidth: Math.min(parseFloat(getComputedStyle(main).maxWidth), innerWidth - panel.getBoundingClientRect().right), + contentWidth: content.getBoundingClientRect().width, + buttonLeft: button.getBoundingClientRect().left, + buttonBottom: button.getBoundingClientRect().bottom, + }); + const samples = [sample()]; + const start = performance.now(); + do { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + samples.push(sample()); + } while (performance.now() - start < 450); + return samples; + }); + await page.mouse.up(); + const samples = await sampling; + for (const sample of samples) { + assert(Math.abs(sample.frameWidth - sample.panelWidth) <= 1, + `sidebar and main must move together throughout the click: ${JSON.stringify(sample)}`); + assert(Math.abs(sample.mainWidth - sample.expectedMainWidth) <= 1); + assertEquals(sample.contentWidth, samples[0].contentWidth); + assertEquals(sample.buttonLeft, samples[0].buttonLeft); + assertEquals(sample.buttonBottom, samples[0].buttonBottom); + } + const first = samples[0].frameWidth; + const last = samples.at(-1)!.frameWidth; + assert(pinning ? last > first : last < first); + assert(samples.some((sample) => sample.frameWidth > Math.min(first, last) && sample.frameWidth < Math.max(first, last)), + "mode switch must animate through intermediate widths"); + assertEquals(await page.locator(".sidebar-frame.folded").count(), pinning ? 0 : 1); +} + async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise { - const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-fold-button"); - const result = await toggle.evaluate(async (button, mobile) => { + const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-hover-region"); + const result = await toggle.evaluate(async (button, { mobile, opening }) => { const frame = document.querySelector(".sidebar-frame")!; const content = frame.querySelector(".sidebar-frame-content")!; const main = document.querySelector(".app-shell__main")!; const moving = mobile ? frame : content; + const footer = frame.querySelector(".sidebar-control-row")!; + const pin = frame.querySelector(".sidebar-fold-button")!; const sample = () => ({ + footerWidth: footer.getBoundingClientRect().width, + footerHeight: footer.getBoundingClientRect().height, + dividerLeft: parseFloat(getComputedStyle(footer, "::before").left), + dividerRight: parseFloat(getComputedStyle(footer, "::before").right), + footerBorder: getComputedStyle(footer).borderTopWidth, + pinWidth: pin.getBoundingClientRect().width, + pinHeight: pin.getBoundingClientRect().height, x: moving.getBoundingClientRect().x, width: moving.getBoundingClientRect().width, frameWidth: frame.getBoundingClientRect().width, + panelWidth: frame.querySelector(".sidebar-frame__bevel")!.getBoundingClientRect().width, mainWidth: main.getBoundingClientRect().width, mainHeight: main.getBoundingClientRect().height, + pinLeft: frame.querySelector(".sidebar-fold-button")!.getBoundingClientRect().left - frame.getBoundingClientRect().left, + pinBottom: frame.getBoundingClientRect().bottom - frame.querySelector(".sidebar-fold-button")!.getBoundingClientRect().bottom, }); const before = sample(); - (button as HTMLButtonElement).click(); + if (mobile) { + (button as HTMLButtonElement).click(); + } else { + button.dispatchEvent(new PointerEvent(opening ? "pointerenter" : "pointerleave", { pointerType: "mouse" })); + if (!opening) await new Promise((resolve) => setTimeout(resolve, 190)); + } await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); const animations = frame.getAnimations({ subtree: true }); const slides = animations.filter((animation) => animation instanceof CSSTransition && @@ -70,22 +182,34 @@ async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): for (const animation of animations) animation.finish(); await Promise.all(animations.map((animation) => animation.finished)); return { before, middle, after: sample(), durations, contentInert: content.inert, mainInert: main.inert }; - }, mobile); - assert(result.durations.length >= (mobile ? 1 : 2)); + }, { mobile, opening }); + assert(result.durations.length >= (mobile ? 1 : 2), `missing sidebar motion: ${JSON.stringify({ mobile, opening, result })}`); assert(result.durations.every((duration) => duration === 220)); const { before, middle, after } = result; + for (const sample of [before, middle, after]) { + assert(Math.abs(sample.pinLeft - 8) <= 1, "Pin must stay at the sidebar's left edge"); + assert(Math.abs(sample.pinBottom - 8) <= 1, "Pin must stay at the sidebar's bottom edge"); + assertEquals(sample.pinWidth, 32); + assertEquals(sample.pinHeight, 32); + assertEquals(sample.footerHeight, 48, "footer keeps compact, equal padding"); + assertEquals(sample.dividerLeft, 8); + assertEquals(sample.dividerRight, 8); + assertEquals(sample.footerBorder, "0px", "divider must not touch the sidebar edges"); + } assert(opening ? before.x < middle.x && middle.x < after.x : before.x > middle.x && middle.x > after.x, `sidebar must slide horizontally through an intermediate position: ${JSON.stringify(result)}`); assertEquals(before.width, after.width, "sidebar contents must slide without reflowing"); assertEquals(result.contentInert, !opening); assertEquals(result.mainInert, mobile && opening); - if (mobile) { - assertEquals(before.mainWidth, after.mainWidth); - assertEquals(before.mainHeight, after.mainHeight); - assertEquals(after.frameWidth, before.frameWidth); - } else { - assert(opening ? before.frameWidth < middle.frameWidth && middle.frameWidth < after.frameWidth - : before.frameWidth > middle.frameWidth && middle.frameWidth > after.frameWidth); + assertEquals(before.mainWidth, after.mainWidth, "hover/touch previews must not resize the main content"); + assertEquals(before.mainHeight, after.mainHeight); + assertEquals(after.frameWidth, before.frameWidth, "hover mode keeps a fixed rail width"); + if (!mobile) { + const folded = opening ? before : after; + assertEquals(folded.footerWidth, folded.footerHeight, "folded footer must be square"); + assert(opening ? before.panelWidth < middle.panelWidth && middle.panelWidth < after.panelWidth + : before.panelWidth > middle.panelWidth && middle.panelWidth > after.panelWidth, + "the overlay panel must expand past the rail and contract again"); } } @@ -384,24 +508,71 @@ async function checkConsoleHistory(viewportHeight: number): Promise { assertEquals(new Set(rowIds).size, rowIds.length); await page.getByRole("button", { name: "Overview", exact: true }).click(); assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12); - await checkSidebarSlide(page, false, false); - await checkSidebarSlide(page, false, true); - await checkSidebarSlide(page, false, false); - assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "true"); - await page.reload(); - await page.getByRole("button", { name: "Unfold sidebar", exact: true }).waitFor(); - assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0); - await page.setViewportSize({ width: 600, height: viewportHeight }); - await page.locator(".sidebar-frame").evaluate(async (element) => { + const sidebar = page.locator(".sidebar-frame"); + const settleSidebar = () => sidebar.evaluate(async (element) => { await Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished)); }); + await checkSidebarModeSwitch(page, "Unpin sidebar"); + await page.mouse.move(1000, 30); + await page.locator(".sidebar-frame.folded").waitFor(); + await settleSidebar(); + const mainBeforeHover = await page.locator(".app-shell__main").boundingBox(); + await checkSidebarSlide(page, false, true); + await checkSidebarSlide(page, false, false); + // The entire footer, including its button, is outside hover activation. + const footer = sidebar.locator(".sidebar-control-row"); + await footer.hover({ position: { x: 4, y: 4 } }); + await page.waitForTimeout(250); + assertEquals(await page.locator(".sidebar-frame.folded").count(), 1); + await page.getByRole("button", { name: "Pin sidebar", exact: true }).hover(); + await page.waitForTimeout(250); + assertEquals(await page.locator(".sidebar-frame.folded").count(), 1); + await sidebar.locator(".sidebar-hover-region").hover({ position: { x: 10, y: 24 } }); + await page.locator(".sidebar-frame:not(.folded)").waitFor(); + await settleSidebar(); + await footer.hover({ position: { x: 4, y: 4 } }); + await page.locator(".sidebar-frame.folded").waitFor(); + await settleSidebar(); + await checkSidebarPointerModeSwitch(page, true); + await checkSidebarPointerModeSwitch(page, false); + // Use real mouse and keyboard events as well as deterministic motion sampling. + await sidebar.hover({ position: { x: 10, y: 40 } }); + await page.locator(".sidebar-frame:not(.folded)").waitFor(); + await settleSidebar(); + assertEquals(await page.locator(".app-shell__main").boundingBox(), mainBeforeHover); + const sidebarLink = sidebar.locator(".sidebar-frame-content a").first(); + await sidebarLink.focus(); + await page.mouse.move(1000, 30); + await page.waitForTimeout(250); + assertEquals(await page.locator(".sidebar-frame.folded").count(), 0, "keyboard focus must hold the preview open"); + await page.getByRole("link", { name: "Open Account", exact: true }).focus(); + await page.locator(".sidebar-frame.folded").waitFor(); + await settleSidebar(); + await sidebar.hover({ position: { x: 10, y: 40 } }); + await checkSidebarModeSwitch(page, "Pin sidebar"); + await page.mouse.move(1000, 30); + await page.waitForTimeout(250); + assertEquals(await page.locator(".sidebar-frame.folded").count(), 0); + assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); + await checkSidebarModeSwitch(page, "Unpin sidebar"); + // Hold a preview open across reload; only the mode should be restored. + await sidebarLink.focus(); + await page.mouse.move(1000, 30); + assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover"); + await page.reload(); + await page.getByRole("button", { name: "Pin sidebar", exact: true }).waitFor(); + assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0); + await page.setViewportSize({ width: 600, height: viewportHeight }); + await settleSidebar(); await checkSidebarSlide(page, true, true); await checkSidebarSlide(page, true, false); await checkSidebarSlide(page, true, true); - assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "false"); + assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover"); await page.reload(); - await page.getByRole("button", { name: "Hide sidebar", exact: true }).waitFor(); - assertEquals(await page.locator(".app-shell.sidebar-open").count(), 1); + await page.getByRole("button", { name: "Show sidebar", exact: true }).waitFor(); + assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0); + await page.getByRole("button", { name: "Show sidebar", exact: true }).click(); + await settleSidebar(); await page.emulateMedia({ reducedMotion: "reduce" }); await page.getByRole("button", { name: "Hide sidebar", exact: true }).click(); const reducedSidebar = await page.locator(".sidebar-frame").evaluate((element) => ({ @@ -411,6 +582,31 @@ async function checkConsoleHistory(viewportHeight: number): Promise { })); assertEquals(reducedSidebar, { animations: 0, right: 0, visibility: "hidden" }); assertEquals(await page.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false); + if (viewportHeight === 600) { + const touchContext = await browser.newContext({ + viewport: { width: 600, height: 600 }, hasTouch: true, isMobile: true, + storageState: await context.storageState(), + }); + const touchPage = await touchContext.newPage(); + await touchPage.goto(page.url()); + await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap(); + await touchPage.getByRole("button", { name: "Pin sidebar", exact: true }).tap(); + assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); + await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap(); + await touchPage.reload(); + await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor(); + assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned"); + // A wide touchscreen still has a temporary-open control in hover mode. + await touchPage.setViewportSize({ width: 1024, height: 600 }); + await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap(); + await touchPage.locator(".sidebar-frame.folded").waitFor(); + await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap(); + await touchPage.locator(".sidebar-frame:not(.folded)").waitFor(); + await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap(); + await touchPage.locator(".sidebar-frame.folded").waitFor(); + assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover"); + await touchContext.close(); + } assertEquals(errors, []); await context.close(); } finally { diff --git a/tools/web-ux/browser-tests/console_pending_shell_test.ts b/tools/web-ux/browser-tests/console_pending_shell_test.ts new file mode 100644 index 000000000..7be6c4046 --- /dev/null +++ b/tools/web-ux/browser-tests/console_pending_shell_test.ts @@ -0,0 +1,179 @@ +/// +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium } from "playwright"; + +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +Deno.test("Console pending scopes are independent and match Tasks row typography", async () => { + const build = await new Deno.Command(Deno.execPath(), { + args: ["task", "build"], + cwd: join(root, "web/workspace"), + stdout: "piped", + stderr: "piped", + }).output(); + assert(build.success, new TextDecoder().decode(build.stderr)); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const url = `http://127.0.0.1:${port}`; + const server = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-net", + "--allow-read", + join(root, "tools/web-ux/browser-tests/console_history_fixture_server.ts"), + String(port), + join(root, "web/workspace/build"), + ], + stdout: "null", + stderr: "null", + }).spawn(); + try { + let started = false; + for (let attempt = 0; attempt < 100; attempt++) { + try { + const response = await fetch(`${url}/health`); + await response.body?.cancel(); + if (response.ok) { + started = true; + break; + } + } catch { /* owned fixture is starting */ } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert(started); + const browser = await chromium.launch({ headless: true }); + try { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390, 320]) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("requestfailed", (request) => errors.push(request.url())); + for (const mode of ["both", "queue", "notifications", "legacy", "empty"]) { + await page.goto( + `${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`, + ); + await page.locator(".task-mini-row").waitFor(); + const queue = mode === "both" || mode === "queue"; + const notifications = ["both", "notifications", "legacy"].includes(mode); + assertEquals( + await page.getByRole("group", { name: "Queued inputs", exact: true }).count(), + queue ? 1 : 0, + ); + assertEquals( + await page.getByRole("group", { name: "Notifications", exact: true }).count(), + notifications ? 1 : 0, + ); + assertEquals(await page.getByRole("heading", { name: "0 Queued" }).count(), 0); + const region = page.getByRole("region", { name: "Pending activations" }); + assertEquals(await region.count(), mode === "empty" ? 0 : 1); + if (mode === "empty") continue; + const geometry = await region.evaluate((region) => { + const task = document.querySelector(".task-mini-row")!; + const taskStyle = getComputedStyle(task); + const metrics = (node: Element) => { + const style = getComputedStyle(node); + return { + height: node.getBoundingClientRect().height, + family: style.fontFamily, + size: style.fontSize, + line: style.lineHeight, + }; + }; + return { + task: metrics(task), + rows: [...region.querySelectorAll("h2, li")].map(metrics), + lists: [...region.querySelectorAll("ol")].map((list) => ({ + margin: getComputedStyle(list).margin, + padding: getComputedStyle(list).padding, + gap: getComputedStyle(list).rowGap, + })), + columns: [...region.querySelectorAll(".pending-column")].map((column) => + column.getBoundingClientRect().toJSON() + ), + width: region.getBoundingClientRect().width, + pageOverflow: document.documentElement.scrollWidth > innerWidth, + mainOverflow: document.querySelector("main")!.scrollWidth > + document.querySelector("main")!.clientWidth, + previews: [...region.querySelectorAll(".pending-submission-preview")].map(( + preview, + ) => ({ + title: preview.getAttribute("title"), + whitespace: getComputedStyle(preview).whiteSpace, + overflow: getComputedStyle(preview).textOverflow, + })), + expectedGap: getComputedStyle(task.closest(".task-mini")!).rowGap, + taskMono: taskStyle.fontFamily.includes("mono"), + }; + }); + assertEquals(geometry.task.height, 16); + assertEquals(geometry.taskMono, true); + for (const row of geometry.rows) { + assertEquals( + row, + geometry.task, + `${width}px/${mode}: heading and preview rows must match Tasks`, + ); + } + for (const list of geometry.lists) { + assertEquals(list, { margin: "0px", padding: "0px", gap: geometry.expectedGap }); + } + if (mode === "both") assertEquals(geometry.columns[0].y, geometry.columns[1].y); + else {assertEquals( + geometry.columns[0].width, + geometry.width, + "a lone scope must not reserve an empty column", + );} + assertEquals(geometry.pageOverflow, false); + assertEquals(geometry.mainOverflow, false); + for (const preview of geometry.previews) { + assert(preview.title); + assertEquals(preview.whitespace, "nowrap"); + assertEquals(preview.overflow, "ellipsis"); + } + if (queue) { + const cancel = page.getByRole("button", { name: "Cancel queued input 1" }); + assertEquals((await cancel.boundingBox())!.height, 16); + assertEquals( + await cancel.isDisabled(), + true, + "retained sessions cannot mutate the queue", + ); + await page.locator(".pending-queue li").first().hover(); + assertEquals( + await cancel.evaluate((button) => getComputedStyle(button).opacity), + "1", + ); + } + if (mode === "legacy") { + await page.getByText("2 pending · Preview unavailable", { exact: true }).waitFor(); + } + } + assertEquals(errors, []); + } finally { + await context.close(); + } + } + } + } finally { + await browser.close(); + } + } finally { + try { + server.kill("SIGTERM"); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + await server.status; + } +}); diff --git a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts new file mode 100644 index 000000000..b1bf91a9b --- /dev/null +++ b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts @@ -0,0 +1,176 @@ +import { extname, join, normalize } from "jsr:@std/path@1.1.4"; +import { dashboardFixture } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts"; + +import { settingsFixtureHandler } from "../../../web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts"; +const settingsFixture = settingsFixtureHandler(); + +const port = Number(Deno.args[0]); +const buildRoot = Deno.args[1]; +if (!Number.isInteger(port) || !buildRoot) { + throw new Error("usage: server "); +} +const names: Record = { + "home-owner": "Workspace Home Review", + "home-member": "Shared Workspace", + "home-empty": "New Workspace", + "home-error": "Partial failure Workspace", + "home-long": + "Workspace with a long name — international documentation and distributed development", +}; +const json = (value: unknown, status = 200) => Response.json(value, { status }); +const mime: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", +}; +Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { + const url = new URL(request.url); + if (url.pathname === "/health") return new Response("ok"); + const settingsResponse = await settingsFixture(request); + if (settingsResponse) return settingsResponse; + if (url.pathname === "/api/workspaces") { + return json( + Object.entries(names).map(([workspace_id, display_name]) => ({ + workspace_id, + display_name, + owner_account_id: "fixture-owner", + state: "active", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + })), + ); + } + const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/); + if (match) { + const [, workspaceId, path] = match; + const owner = workspaceId !== "home-member"; + if (workspaceId === "home-error" && path === "/merge-requests") { + return json({ error: "fixture unavailable" }, 503); + } + const dashboardData = dashboardFixture(path, url.searchParams, workspaceId); + if (dashboardData) return json(dashboardData); + if (path === "/workspace") { + return json({ + workspace_id: workspaceId, + display_name: names[workspaceId], + record_authority: "fixture", + schema_version: 1, + auth: { + Passkey: { + rp_id: "127.0.0.1", + origin: url.origin, + public_base_url: url.origin, + cookie_name: "fixture", + }, + }, + permissions: { + manage_repositories: owner, + manage_secrets: owner, + manage_runtimes: owner, + delete_workspace: owner, + }, + extension_points: { + store: "fixture", + event_stream: { status: "ready", note: "fixture", diagnostics: [] }, + host_worker_bridge: { + status: "ready", + note: "fixture", + diagnostics: [], + }, + companion_console: { + status: "ready", + note: "fixture", + diagnostics: [], + }, + }, + }); + } + if (path === "/repositories") { + return json({ + workspace_id: workspaceId, + items: [], + source: "fixture", + diagnostics: [], + }); + } + if (path === "/working-directories") { + return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); + } + // Retained only for before-change captures. The redesigned Home must not request Hosts. + if (path === "/hosts") { + return json({ + workspace_id: workspaceId, + limit: 100, + source: "fixture", + diagnostics: [], + items: workspaceId === "home-member" ? [] : [{ + runtime_id: "fixture-runtime", + host_id: "fixture-host", + label: "Development host", + kind: "local", + status: "available", + observed_at: "2026-01-02T00:00:00Z", + last_seen_at: null, + os: "linux", + arch: "x86_64", + diagnostics: [], + }], + }); + } + if (path === "/memory") { + return json({ + body_md: "# Home navigation destination", + bytes: 29, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + record_source: "fixture", + }); + } + if ( + path === "/protocol/ws" && request.headers.get("upgrade") === "websocket" + ) { + const { socket, response } = Deno.upgradeWebSocket(request); + socket.onmessage = (event) => { + const frame = JSON.parse(String(event.data)); + if (frame?.message?.method !== "subscribe_events") return; + socket.send(JSON.stringify({ + protocol_version: 1, + frame: "response", + message: { + result: "subscribed", + payload: { + request_id: frame.message.params.request_id, + subscription_id: "fixture-workers", + selector: { topic: "workspace_workers" }, + snapshot_revision: 1, + snapshot: { topic: "workers", data: { workers: [] } }, + }, + }, + })); + }; + return response; + } + return json({ error: "Unexpected fixture API request" }, 404); + } + const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html"); + if (relative.startsWith("..")) { + return new Response("not found", { status: 404 }); + } + let file = join(buildRoot, relative); + try { + if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html"); + return new Response(await Deno.readFile(file), { + headers: { + "content-type": mime[extname(file)] ?? "application/octet-stream", + }, + }); + } catch { + return new Response(await Deno.readFile(join(buildRoot, "index.html")), { + headers: { "content-type": mime[".html"] }, + }); + } +}); diff --git a/tools/web-ux/browser-tests/workspace_home_shell_test.ts b/tools/web-ux/browser-tests/workspace_home_shell_test.ts new file mode 100644 index 000000000..19ae707eb --- /dev/null +++ b/tools/web-ux/browser-tests/workspace_home_shell_test.ts @@ -0,0 +1,186 @@ +/// +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium, type Page } from "playwright"; + +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const ready = async (page: Page) => { + for (const id of ["home-attention", "home-active", "home-recent"]) { + await page.locator(`[data-feed-ready="${id}"]`).waitFor(); + } +}; +Deno.test("production Home dashboard isolates failures, shows authoritative records, and supports responsive keyboard navigation", async () => { + const build = await new Deno.Command(Deno.execPath(), { + args: ["task", "build"], + cwd: join(root, "web/workspace"), + stdout: "piped", + stderr: "piped", + }).output(); + assert(build.success, new TextDecoder().decode(build.stderr)); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const url = `http://127.0.0.1:${port}`; + const server = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-net", + "--allow-read", + join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"), + String(port), + join(root, "web/workspace/build"), + ], + stdout: "null", + stderr: "null", + }).spawn(); + try { + let started = false; + for (let n = 0; n < 100; n++) { + try { + const response = await fetch(`${url}/health`); + await response.body?.cancel(); + if (response.ok) { + started = true; + break; + } + } catch { /* fixture is starting */ } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert(started); + const browser = await chromium.launch({ headless: true }); + try { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390, 320]) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const errors: string[] = []; + const requests: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("requestfailed", (request) => errors.push(request.url())); + page.on("request", (request) => requests.push(request.url())); + for (const workspace of ["home-owner", "home-member", "home-long"]) { + await page.goto(`${url}/w/${workspace}`); + await ready(page); + assertEquals(await page.locator("main h1").allTextContents(), ["Activity"]); + assertEquals(await page.locator("main nav").count(), 0); + assertEquals(await page.locator("main [role=alert]").count(), 0); + assertEquals(await page.locator("[data-feed-ready=home-attention] a").count(), 3); + assertEquals(await page.locator("[data-feed-ready=home-active] a").count(), 2); + assertEquals(await page.locator("[data-feed-ready=home-recent] a").count(), 4); + await page.getByText("Blocked by T-99", { exact: true }).waitFor(); + await page.getByText("W-7", { exact: true }).waitFor(); + const geometry = await page.locator(".workspace-home").evaluate((home) => { + const main = home.closest("main")!; + const sections = [...home.querySelectorAll("section")].map((section) => + section.getBoundingClientRect() + ); + return { + pageOverflow: document.documentElement.scrollWidth > innerWidth, + mainOverflow: main.scrollWidth > main.clientWidth, + firstLinkTop: home.querySelector("a")!.getBoundingClientRect().top, + sideBySide: sections[1].left > sections[0].right && + sections[2].left > sections[1].right, + stacked: sections[1].top >= sections[0].bottom && + sections[2].top >= sections[1].bottom, + }; + }); + assertEquals(geometry.pageOverflow, false); + assertEquals(geometry.mainOverflow, false); + assert(geometry.firstLinkTop < 900); + assert(width === 1440 ? geometry.sideBySide : geometry.stacked); + const links = page.locator("main a"); + await links.first().focus(); + assertEquals( + await links.first().evaluate((link) => getComputedStyle(link).outlineStyle), + "solid", + ); + await page.keyboard.press("Tab"); + assertEquals( + await links.nth(1).evaluate((link) => document.activeElement === link), + true, + ); + for (const link of await links.all()) { + assert( + new RegExp(`/w/${workspace}/(tickets|objectives|merge-requests)/[^/]+$`).test( + (await link.getAttribute("href"))!, + ), + ); + } + } + const beforeRefresh = requests.filter((request) => + request.includes("/merge-requests?") + ).length; + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await page.getByRole("button", { name: "Refresh", exact: true }).waitFor(); + await ready(page); + assertEquals( + requests.filter((request) => request.includes("/merge-requests?")).length, + beforeRefresh + 1, + ); + const objective = page.locator("main a[href*='/objectives/O-12-']"); + await objective.focus(); + await page.keyboard.press("Enter"); + await page.waitForURL("**/objectives/O-12-*"); + await page.getByText("Home dashboard navigation destination.", { exact: true }) + .waitFor(); + assertEquals( + requests.some((request) => new URL(request).pathname.endsWith("/hosts")), + false, + ); + assertEquals(errors, []); + } finally { + await context.close(); + } + } + } + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + try { + await page.goto(`${url}/w/home-empty`); + await ready(page); + assertEquals(await page.locator("main a").count(), 0); + await page.getByText("No open Merge Requests.", { exact: true }).waitFor(); + await page.goto(`${url}/w/home-error`); + await ready(page); + assertEquals( + await page.locator("[data-feed-ready=home-attention] [role=alert]").count(), + 1, + ); + assertEquals(await page.getByText("No open Merge Requests.", { exact: true }).count(), 0); + assertEquals(await page.locator("[data-feed-ready=home-active] a").count(), 2); + assertEquals(await page.locator("[data-feed-ready=home-recent] a").count(), 4); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route("**/merge-requests?*", async (route) => { + await held; + await route.continue(); + }); + await page.goto(`${url}/w/home-owner`); + await page.locator("[data-feed-ready=home-active]").waitFor(); + assertEquals(await page.getByText("Loading needs attention…", { exact: true }).count(), 1); + release(); + await ready(page); + } finally { + await page.close(); + } + } finally { + await browser.close(); + } + } finally { + try { + server.kill("SIGTERM"); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + await server.status; + } +}); diff --git a/tools/web-ux/browser-tests/workspace_settings_shell_test.ts b/tools/web-ux/browser-tests/workspace_settings_shell_test.ts new file mode 100644 index 000000000..b323a1c24 --- /dev/null +++ b/tools/web-ux/browser-tests/workspace_settings_shell_test.ts @@ -0,0 +1,301 @@ +/// +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium, type Page } from "playwright"; +import { metadataFixture } from "../../../web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts"; +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const evidence = join(root, "target/web-ux/settings-interactions"); +async function ready(page: Page, owner = true) { + await page.locator("[data-name-ready=true]").waitFor(); + if (owner) await page.locator("[data-identity-ready=true]").waitFor(); +} +async function noOverflow(page: Page) { + assertEquals( + await page.locator("main").evaluate((main) => ({ + page: document.documentElement.scrollWidth > innerWidth, + main: main.scrollWidth > main.clientWidth, + })), + { page: false, main: false }, + ); +} +Deno.test("production Workspace Settings separates read/edit, discloses details, and preserves keyboard and destructive safety", async () => { + const build = await new Deno.Command(Deno.execPath(), { + args: ["task", "build"], + cwd: join(root, "web/workspace"), + stdout: "piped", + stderr: "piped", + }).output(); + assert(build.success, new TextDecoder().decode(build.stderr)); + await Deno.mkdir(evidence, { recursive: true }); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const url = `http://127.0.0.1:${port}`; + const server = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-net", + "--allow-read", + join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"), + String(port), + join(root, "web/workspace/build"), + ], + stdout: "null", + stderr: "null", + }).spawn(); + try { + let started = false; + for (let n = 0; n < 100; n++) { + try { + const response = await fetch(`${url}/health`); + await response.body?.cancel(); + if (response.ok) { + started = true; + break; + } + } catch { /* starting */ } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert(started); + const browser = await chromium.launch({ headless: true }); + try { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390, 320]) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const errors: string[] = []; + const mutations: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("requestfailed", (request) => errors.push(request.url())); + page.on("request", (request) => { + if (request.method() !== "GET") mutations.push(request.url()); + }); + for ( + const workspace of ["home-owner", "home-member", "home-long"] + ) { + const owner = workspace !== "home-member"; + await page.goto(`${url}/w/${workspace}/settings`); + await ready(page, owner); + assertEquals(await page.locator("main h1").allTextContents(), [ + "Workspace Identity", + ]); + assertEquals( + await page.locator("main input:visible, main textarea:visible") + .count(), + 0, + ); + assertEquals(await page.locator("main details[open]").count(), 0); + assertEquals(await page.locator("main .badge").count(), 0); + assertEquals( + await page.getByRole("button", { name: "Copy bundle" }).count(), + owner ? 1 : 0, + ); + const edit = page.getByRole("button", { name: "Edit name" }); + assert((await edit.boundingBox())!.y < 900); + await noOverflow(page); + const saved = await page.locator(".saved-name").innerText(); + await edit.focus(); + await page.keyboard.press("Enter"); + const input = page.getByRole("textbox", { + name: "New display name", + }); + assertEquals( + await input.evaluate((node) => node === document.activeElement), + true, + ); + assertEquals(await input.inputValue(), saved); + await input.fill("Discard draft"); + if (workspace === "home-long") { + await page.screenshot({ + path: join(evidence, `edit-${colorScheme}-${width}.png`), + }); + } + await page.getByRole("button", { name: "Cancel", exact: true }) + .click(); + assertEquals( + await edit.evaluate((node) => node === document.activeElement), + true, + ); + assertEquals( + await page.locator(".saved-name").innerText(), + saved, + ); + const technical = page.getByText("Technical details", { + exact: true, + }); + await technical.focus(); + await page.keyboard.press("Enter"); + assertEquals( + await page.getByText("Source", { exact: true }).isVisible(), + true, + ); + await page.keyboard.press("Enter"); + assertEquals( + await page.getByText("Source", { exact: true }).isVisible(), + false, + ); + if (owner) { + const bundle = page.getByText("Public bundle and key details", { + exact: true, + }); + await bundle.focus(); + await page.keyboard.press("Enter"); + assertEquals( + await page.getByRole("textbox", { + name: "Public identity bundle", + }).isVisible(), + true, + ); + assertEquals( + await page.getByRole("textbox", { + name: "Public identity bundle", + }).getAttribute("readonly"), + "", + ); + await noOverflow(page); + if (workspace === "home-owner") { + await page.screenshot({ + path: join(evidence, `bundle-${colorScheme}-${width}.png`), + }); + } + await bundle.click(); + const danger = page.locator(".deletion > summary"); + await danger.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("button", { + name: "Review deletion impact", + }).click(); + const confirmation = page.getByRole("textbox", { + name: /^Type .* to confirm$/, + }); + await confirmation.waitFor(); + assertEquals( + await confirmation.evaluate((node) => node === document.activeElement), + true, + ); + const deletion = page.getByRole("button", { + name: "Delete Workspace", + exact: true, + }); + assertEquals(await deletion.isDisabled(), true); + await confirmation.fill("wrong"); + assertEquals(await deletion.isDisabled(), true); + await confirmation.fill(saved); + assertEquals(await deletion.isDisabled(), false); + await noOverflow(page); + if (workspace === "home-owner") { + await page.screenshot({ + path: join(evidence, `delete-${colorScheme}-${width}.png`), + }); + } + await page.getByRole("button", { name: "Cancel", exact: true }) + .click(); + assertEquals( + await danger.evaluate((node) => node === document.activeElement), + true, + ); + assertEquals(await page.locator(".deletion[open]").count(), 0); + } + } + assertEquals(mutations, []); + assertEquals(errors, []); + await page.getByRole("button", { name: "Edit name" }).click(); + const renamed = `Confirmed name ${width} ${colorScheme}`; + await page.getByRole("textbox", { name: "New display name" }).fill( + renamed, + ); + await page.getByRole("button", { name: "Save name" }).click(); + await page.getByText("Workspace name saved.", { exact: true }) + .waitFor(); + assertEquals( + await page.locator(".saved-name").innerText(), + renamed, + ); + assertEquals(await page.locator("main input:visible").count(), 0); + assertEquals(mutations.length, 1); + assertEquals(errors, []); + // Restore the synthetic name so every width actually exercises long text. + const currentResponse = await context.request.get(`${url}/api/w/home-long/settings`); + const current = await currentResponse.json(); + const reset = await context.request.put(`${url}/api/w/home-long/settings`, { + data: { + display_name: metadataFixture("home-long").display_name, + revision: current.revision, + }, + }); + assertEquals(reset.status(), 200); + } finally { + await context.close(); + } + } + } + const page = await browser.newPage({ + viewport: { width: 390, height: 844 }, + }); + try { + await page.goto(`${url}/w/home-empty/settings`); + await ready(page); + await page.getByText("Not provisioned", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Provision identity" }).click(); + await page.getByRole("button", { name: "Copy bundle" }).waitFor(); + await page.goto(`${url}/w/home-error/settings`); + await ready(page); + assertEquals( + await page.getByRole("button", { name: "Edit name" }).isVisible(), + true, + ); + assertEquals( + await page.getByRole("button", { name: "Retry identity" }) + .isVisible(), + true, + ); + await page.goto(`${url}/w/home-owner/settings`); + await ready(page); + await page.route("**/settings", async (route) => { + if (route.request().method() === "PUT") { + await route.fulfill({ + status: 409, + body: "Workspace metadata changed. Reload the saved name.", + }); + } else await route.continue(); + }); + await page.getByRole("button", { name: "Edit name" }).click(); + await page.getByRole("textbox", { name: "New display name" }).fill( + "Keep this draft", + ); + await page.getByRole("button", { name: "Save name" }).click(); + await page.getByRole("alert").waitFor(); + assertEquals( + await page.getByRole("textbox", { name: "New display name" }) + .inputValue(), + "Keep this draft", + ); + await page.screenshot({ + path: join(evidence, "save-error-mobile.png"), + }); + await page.getByRole("button", { name: "Reload saved name" }).click(); + await page.getByRole("button", { name: "Edit name" }).waitFor(); + assertEquals(await page.getByRole("alert").count(), 0); + } finally { + await page.close(); + } + } finally { + await browser.close(); + } + } finally { + try { + server.kill("SIGTERM"); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + await server.status; + } +}); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index 48fe75c03..ab994d533 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/tools/web-ux/scenarios/console-pending.json b/tools/web-ux/scenarios/console-pending.json new file mode 100644 index 000000000..c499b6f6f --- /dev/null +++ b/tools/web-ux/scenarios/console-pending.json @@ -0,0 +1,137 @@ +{ + "schemaVersion": 1, + "id": "console-pending", + "title": "Console pending inputs and notifications", + "baseUrl": "http://127.0.0.1:15283", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Retained Console fixture", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "both", + "label": "both", + "path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=both", + "goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.", + "dataState": "Synthetic retained Console: both; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.", + "ready": { + "kind": "selector", + "selector": ".task-mini-row" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Pending input layout" + } + ] + }, + { + "id": "queue", + "label": "queue", + "path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=queue", + "goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.", + "dataState": "Synthetic retained Console: queue; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.", + "ready": { + "kind": "selector", + "selector": ".task-mini-row" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Pending input layout" + } + ] + }, + { + "id": "notifications", + "label": "notifications", + "path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=notifications", + "goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.", + "dataState": "Synthetic retained Console: notifications; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.", + "ready": { + "kind": "selector", + "selector": ".task-mini-row" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Pending input layout" + } + ] + }, + { + "id": "legacy", + "label": "legacy", + "path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=legacy", + "goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.", + "dataState": "Synthetic retained Console: legacy; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.", + "ready": { + "kind": "selector", + "selector": ".task-mini-row" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Pending input layout" + } + ] + }, + { + "id": "empty", + "label": "empty", + "path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=empty", + "goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.", + "dataState": "Synthetic retained Console: empty; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.", + "ready": { + "kind": "selector", + "selector": ".task-mini-row" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Pending input layout" + } + ] + } + ], + "processes": [ + { + "id": "console-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/console_history_fixture_server.ts", + "15283", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15283/health" + } + ] +} diff --git a/tools/web-ux/scenarios/workspace-home.json b/tools/web-ux/scenarios/workspace-home.json new file mode 100644 index 000000000..c29a168c7 --- /dev/null +++ b/tools/web-ux/scenarios/workspace-home.json @@ -0,0 +1,96 @@ +{ + "schemaVersion": 1, + "id": "workspace-home", + "title": "Workspace Home operational dashboard", + "baseUrl": "http://127.0.0.1:15281", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Fixture account; permission projection varies by Workspace", + "auth": { "kind": "anonymous" } + } + ], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 900 }, + { "label": "narrow", "width": 768, "height": 900 }, + { "label": "mobile", "width": 390, "height": 844 } + ], + "routes": [ + { + "id": "owner", + "label": "Owner Home", + "path": "/w/home-owner", + "goal": "Identify review work, blocked or assigned active tickets, and recent updates; open an actual record.", + "dataState": "Mixed review states, in-progress/queued tickets with a coder and dependency blocker, done/closed tickets and an Objective.", + "ready": { + "kind": "selector", + "selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])" + }, + "capturePoints": [{ "id": "initial", "label": "Dashboard" }] + }, + { + "id": "non-owner", + "label": "Non-owner Home", + "path": "/w/home-member", + "goal": "Read the same work records without owner-only management controls.", + "dataState": "All management permissions false; same authorized read-only work projections as owner.", + "ready": { + "kind": "selector", + "selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])" + }, + "capturePoints": [{ "id": "initial", "label": "Dashboard" }] + }, + { + "id": "long-name", + "label": "Long names and selectors", + "path": "/w/home-long", + "goal": "Read long record titles and branch selectors without page-wide overflow.", + "dataState": "Owner with long Workspace name, Objective title, and Merge Request selectors.", + "ready": { + "kind": "selector", + "selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])" + }, + "capturePoints": [{ "id": "initial", "label": "Dashboard" }] + }, + { + "id": "empty", + "label": "Empty Workspace", + "path": "/w/home-empty", + "goal": "Distinguish empty work scopes without fabricated status totals.", + "dataState": "All dashboard resource queries successfully return empty collections.", + "ready": { + "kind": "selector", + "selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])" + }, + "capturePoints": [{ "id": "initial", "label": "Empty scopes" }] + }, + { + "id": "partial-error", + "label": "Reviews unavailable", + "path": "/w/home-error", + "goal": "Keep active work and recent updates usable when reviews fail; never claim all-clear.", + "dataState": "Intentional HTTP 503 from Merge Requests. Expected visible error and browser HTTP console diagnostic; other resource requests succeed.", + "ready": { + "kind": "selector", + "selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])" + }, + "capturePoints": [{ "id": "initial", "label": "Scoped review failure" }] + } + ], + "processes": [{ + "id": "home-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/workspace_home_fixture_server.ts", + "15281", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15281/health" + }] +} diff --git a/tools/web-ux/scenarios/workspace-settings.json b/tools/web-ux/scenarios/workspace-settings.json new file mode 100644 index 000000000..e038c719e --- /dev/null +++ b/tools/web-ux/scenarios/workspace-settings.json @@ -0,0 +1,137 @@ +{ + "schemaVersion": 1, + "id": "workspace-settings", + "title": "Workspace Identity settings disclosure", + "baseUrl": "http://127.0.0.1:15282", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic Workspace permission projections", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "owner", + "label": "Owner settings", + "path": "/w/home-owner/settings", + "goal": "Read saved name and identity state without permanent edit forms or public JSON.", + "dataState": "Active public identity and saved name.", + "ready": { + "kind": "selector", + "selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Saved settings" + } + ] + }, + { + "id": "member", + "label": "Member settings", + "path": "/w/home-member/settings", + "goal": "Keep permitted metadata editing without owner identity or deletion controls.", + "dataState": "All owner permissions false; metadata read/write remains available under existing Backend contract.", + "ready": { + "kind": "selector", + "selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Member view" + } + ] + }, + { + "id": "long", + "label": "Long name", + "path": "/w/home-long/settings", + "goal": "Keep controls in the viewport and preserve complete long names.", + "dataState": "Long Workspace display name and active identity.", + "ready": { + "kind": "selector", + "selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Long name" + } + ] + }, + { + "id": "pending", + "label": "Pending identity", + "path": "/w/home-empty/settings", + "goal": "Show provisioning attention without pretending the identity is active.", + "dataState": "Identity pending_provisioning; metadata available.", + "ready": { + "kind": "selector", + "selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Provisioning required" + } + ] + }, + { + "id": "error", + "label": "Identity failure", + "path": "/w/home-error/settings", + "goal": "Keep name usable when public identity fails and offer scoped recovery.", + "dataState": "Intentional signing identity HTTP503 with its expected console diagnostic.", + "ready": { + "kind": "selector", + "selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Scoped error" + } + ] + } + ], + "processes": [ + { + "id": "settings-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/workspace_home_fixture_server.ts", + "15282", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15282/health" + } + ] +} diff --git a/web/workspace/README.md b/web/workspace/README.md index 686d4bbe2..d4f7b3827 100644 --- a/web/workspace/README.md +++ b/web/workspace/README.md @@ -94,6 +94,8 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac - `src/routes/+layout.svelte`: root application shell and override contexts. - `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration. - `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration. +- `src/lib/workspace/home/`: bounded, read-only work dashboard feeds and their presentation. +- `src/lib/workspace/settings/WorkspaceIdentitySettings.svelte`: Workspace-scoped name editing, public identity disclosure, and deletion confirmation. - `src/lib/workspace/header/`: Header frame, context, and overrides. - `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack. - `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers. diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index c9c938adb..adaa5c669 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -338,6 +338,13 @@ Deno.test("durable compaction failure reconciles live alert and error in both vi }, }, }, + { + eventId: "controller-error", + event: { + event: "error", + data: { code: "internal", message: "summary unavailable" }, + }, + }, { eventId: "failed", event: transition("failed-entry", { @@ -346,13 +353,6 @@ Deno.test("durable compaction failure reconciles live alert and error in both vi failure: "compaction", }), }, - { - eventId: "controller-error", - event: { - event: "error", - data: { code: "internal", message: "summary unavailable" }, - }, - }, ]); const errors = projection.lines.filter((line) => line.kind === "error"); @@ -362,6 +362,47 @@ Deno.test("durable compaction failure reconciles live alert and error in both vi assertEquals(errors[0].entryId, "failed-entry"); assertEquals(projectConsoleLines(projection.lines, "normal"), errors); assertEquals(projectConsoleLines(projection.lines, "overview"), errors); + + const history = projectSessionHistoryEntries([{ + entry_id: "failed-entry", + timestamp: 10, + provenance: "legacy_unknown", + kind: "run_error", + message, + failure: "compaction", + }], null); + const merged = mergeCommittedHistoryLines(history, projection.lines); + assertEquals(merged.filter((line) => line.kind === "error").length, 1); + assertEquals(merged.find((line) => line.kind === "error")?.entryId, "failed-entry"); +}); + +Deno.test("reapplying one committed run_error entry is idempotent in both views", () => { + const committed = (eventId: string): ConsoleEventInput => ({ + eventId, + event: { + event: "session_entry_committed", + data: { + entry: { + entry_id: "same-run-error", + timestamp: 10, + provenance: "legacy_unknown", + kind: "run_error", + message: "mid-run compaction failed: summary unavailable", + failure: "compaction", + }, + }, + }, + }); + + const projection = projectConsole([ + committed("live-delivery"), + committed("snapshot-replay"), + ]); + const errors = projection.lines.filter((line) => line.kind === "error"); + assertEquals(errors.length, 1); + assertEquals(errors[0].entryId, "same-run-error"); + assertEquals(projectConsoleLines(projection.lines, "normal").length, 1); + assertEquals(projectConsoleLines(projection.lines, "overview").length, 1); }); Deno.test("snapshot restores compaction terminal without reviving transition progress", () => { @@ -3232,6 +3273,26 @@ Deno.test("committed history reconciles overlapping Read groups before counting } }); +Deno.test("committed history keeps identical run failures with distinct entry identities", () => { + const message = "the same failure happened again"; + const runError = (entryId: string): SessionSnapshotEntry => ({ + kind: "run_error", + entry_id: entryId, + timestamp: 1, + provenance: "legacy_unknown", + message, + failure: "compaction", + }); + const retained = projectSessionHistoryEntries([runError("run-error-1")], null); + const current = projectSessionHistoryEntries([runError("run-error-2")], null); + + const merged = mergeCommittedHistoryLines(retained, current); + assertEquals(merged.map((line) => line.entryId), ["run-error-1", "run-error-2"]); + assertEquals(merged.map((line) => line.body), [message, message]); + assertEquals(projectConsoleLines(merged, "normal").length, 2); + assertEquals(projectConsoleLines(merged, "overview").length, 2); +}); + Deno.test("committed history matches tool calls across live and durable result identities", () => { const retained: ConsoleLine = { ...consoleLine("tool-call-a", "tool"), entryId: "call-entry-a", diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index 4763ada3e..cdfca96d2 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -1287,15 +1287,19 @@ function hasDurableRunFailure( function appendDurableRunFailure( projection: ConsoleProjection, eventId: string, + entryId: string | undefined, message: string, failure: string | undefined, ): void { + if ( + entryId && projection.lines.some((line) => line.entryId === entryId) + ) return; projection.lines = projection.lines.filter((line) => { if (isDurableRunFailure(line)) return true; return !(line.kind === "error" && runFailureMessagesMatch(line.body, message)); }); - projection.lines.push( - line( + projection.lines.push({ + ...line( eventId, "error", failure === "compaction" ? "Compaction failed" : "Run error", @@ -1304,7 +1308,8 @@ function appendDurableRunFailure( false, true, ), - ); + entryId, + }); } function appendAlertLine( @@ -2198,6 +2203,7 @@ function applySessionEntry( appendDurableRunFailure( projection, eventId, + stringField(value, "entry_id"), stringField(value, "message") ?? "Worker run failed.", stringField(value, "failure"), ); @@ -2208,7 +2214,10 @@ function applySessionEntry( const entryId = stringField(value, "entry_id"); if (entryId) { for ( - let index = Math.max(0, lineCountBefore - 1); + let index = Math.max( + 0, + Math.min(lineCountBefore, projection.lines.length - 1), + ); index < projection.lines.length; index += 1 ) { diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 4c0640485..cbb4c92f2 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -245,15 +245,10 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () ); assert( - workspacePage.includes("ticketsHref") && - workspacePage.includes("runtimeSettingsHref") && - workspacePage.includes("workersHref") && - workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") && - workspacePage.includes( - "workspaceRoute(workspaceId, '/settings/runtimes')", - ) && - workspacePage.includes("workspaceRoute(workspaceId, '/workers')"), - "top workspace page should link to Tickets, Runtimes under Settings, and the Workers page", + workspacePage.includes("DashboardSection") && + !workspacePage.includes("") && + sidebarFrame.includes("Pin sidebar") && + sidebarFrame.includes("Unpin sidebar") && + rootLayout.includes("let sidebarMode = $state(loadSidebarMode())") && + rootLayout.includes("mode={sidebarMode}") && !workspaceLayout.includes("sidebarFolded") && !workspaceLayout.includes("onToggleFold") && !sidebar.includes("folded?: boolean") && diff --git a/web/workspace/src/lib/workspace/home/DashboardSection.svelte b/web/workspace/src/lib/workspace/home/DashboardSection.svelte new file mode 100644 index 000000000..d71f5048f --- /dev/null +++ b/web/workspace/src/lib/workspace/home/DashboardSection.svelte @@ -0,0 +1,67 @@ + + +
+

{title}

+ {#await result} +

Loading {title.toLowerCase()}…

+ {:then feed} +
+ {#each feed.errors as error}{/each} + {#if feed.rows.length} + + {:else if !feed.errors.length} +

{empty}

+ {/if} + {#if feed.notice}

{feed.notice}

{/if} +
+ {:catch} + + {/await} +
+ + diff --git a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts new file mode 100644 index 000000000..bcb2cac38 --- /dev/null +++ b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts @@ -0,0 +1,194 @@ +// Deterministic read-only fixtures shared by Home component and browser tests. +export const fixturePage = (returned: number, more = false) => ({ + returned, + limit: 5, + sort: "priority", + has_more: more, + next_cursor: more ? "next" : null, + source_limit: null, + source_truncated: false, +}); +const updated = (hour: number) => + `2026-09-28T${String(hour).padStart(2, "0")}:00:00Z`; +export function fixtureTicket(key: string, state: string, hour = 10) { + return { + id: `internal-${key}`, + resource_key: key, + title: key === "T-101" + ? "Restore queued Worker submissions" + : `${state} ticket ${key}`, + state, + priority: "normal", + queued_at: null, + queued_by: null, + updated_at: updated(hour), + record_source: "fixture", + workspace_action_priority: state === "inprogress" || state === "queued" + ? "active_work" + : "background", + }; +} +export function fixtureDetail(key = "T-101") { + const summary = fixtureTicket(key, key === "T-101" ? "inprogress" : "queued"); + const { workspace_action_priority: _, ...ticket } = summary; + return { + ...ticket, + item_revision: "revision-1", + created_at: updated(1), + readiness: null, + body: "Implementation in progress.", + body_truncated: false, + resolution: null, + artifacts: [], + artifact_count: 0, + assignments: [], + assignment_diagnostics: [], + events: [], + event_count: 0, + event_page: fixturePage(0), + implementation_reports: [], + evidence: { + has_merge_request: false, + has_current_subject_ref: false, + has_review_request: false, + has_commit: false, + review_status: null, + approved_current_subject: false, + review_after_rescope: false, + unresolved_request_changes: false, + complete_for_integration: false, + missing: [], + }, + current_coder: key === "T-101" + ? { + assignment_id: "a-1", + runtime_id: "internal-runtime", + worker_id: "internal-worker", + worker_resource_key: "W-7", + } + : null, + action_eligibility: { + blockers: [], + can_assign_orchestrator: false, + can_queue: false, + can_start_manual_coder: false, + can_unassign_orchestrator: false, + queue_tickets: [], + }, + linked_objectives: [], + merge_request: null, + merge_requests: [], + targets: [], + risk_flags: [], + relations: { + outgoing: [], + incoming: [], + notices: [], + blockers: key === "T-101" + ? [{ + blocking_resource_key: "T-99", + blocking_state: "ready", + blocking_ticket: "internal-T-99", + note: null, + reason_kind: "dependency", + relation_kind: "depends_on", + }] + : [], + }, + }; +} +export function dashboardFixture( + path: string, + search: URLSearchParams, + workspaceId = "home-owner", +): unknown | null { + const empty = workspaceId === "home-empty"; + const long = workspaceId === "home-long"; + if (path === "/tickets") { + const state = search.get("states")!; + const items = empty + ? [] + : state === "inprogress,queued" + ? [fixtureTicket("T-101", "inprogress"), fixtureTicket("T-102", "queued")] + : state === "done" + ? [fixtureTicket("T-103", "done", 12), fixtureTicket("T-104", "done", 7)] + : [fixtureTicket("T-105", "closed", 14)]; + if (long && items[0]) { + items[0].title = "長いTicket名 — ".repeat(8) + + "preserve-the-full-title-without-horizontal-overflow"; + } + return { + workspace_id: workspaceId, + limit: 5, + items, + page: fixturePage(items.length, !empty), + invalid_records: [], + record_authority: "fixture", + }; + } + if (/^\/tickets\/T-10[12]/.test(path)) { + return fixtureDetail(path.includes("T-101") ? "T-101" : "T-102"); + } + const objective = { + id: "internal-objective", + resource_key: "O-12", + title: long ? "Objective ".repeat(18) : "Improve daily Workspace operation", + state: "active", + created_at: updated(1), + updated_at: updated(13), + summary: "", + linked_tickets: [], + record_source: "fixture", + }; + if (path === "/objectives") { + return { + workspace_id: workspaceId, + limit: 5, + items: empty ? [] : [objective], + invalid_records: [], + record_authority: "fixture", + }; + } + if (path.startsWith("/objectives/O-12")) { + const { summary: _, ...detail } = objective; + return { + ...detail, + body: "# Objective detail\n\nHome dashboard navigation destination.", + body_truncated: false, + revision: "revision-1", + linked_ticket_summaries: [], + resources: [], + events: [], + event_page: fixturePage(0), + }; + } + if (path === "/merge-requests") { + return { + items: empty + ? [] + : ["request_changes", "pending", "approved"].map((status, index) => ({ + summary: { + merge_request_id: `internal-merge-${index}`, + repository_key: "main", + state: "open", + selector_from: long + ? "feature/" + "long-branch-".repeat(15) + : ["fix/queue", "feat/home", "fix/sidebar"][index], + selector_to: "develop", + review_status: status, + updated_at: updated(15 - index), + review_excerpt: null, + review_requested_at: updated(10), + review_submitted_at: null, + current_subject_ref: "abc123", + review_subject_ref: "abc123", + }, + thread_event_count: 1, + ticket_ids: [], + ref_diagnostics: [], + })), + next_cursor: empty ? null : "older-reviews", + }; + } + return null; +} diff --git a/web/workspace/src/lib/workspace/home/dashboard.ts b/web/workspace/src/lib/workspace/home/dashboard.ts new file mode 100644 index 000000000..e831e6c3b --- /dev/null +++ b/web/workspace/src/lib/workspace/home/dashboard.ts @@ -0,0 +1,263 @@ +import type { TicketListItemSummary } from "$lib/generated/ticket-api"; +import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { + parseMergeRequestListResponse, + parseObjectiveListResponse, + parseTicketDetail, + parseTicketListResponse, + TICKET_BROWSER_API_LOAD_POLICY, +} from "$lib/workspace/api/ticket-browser"; +import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests"; +import { objectiveHref, ticketHref } from "$lib/workspace/resource-links"; + +export const HOME_LIMIT = 5; +export type DashboardRow = { + key: string; + kind: "Ticket" | "Objective" | "Merge Request"; + reference: string; + title: string; + href: string; + status: string; + detail?: string; + workerKey?: string; + attention?: boolean; + updatedAt?: string | null; +}; +export type DashboardFeed = { + rows: DashboardRow[]; + errors: string[]; + notice?: string; +}; +export type Dashboard = { + reviews: Promise; + active: Promise; + recent: Promise; +}; + +const states: Record = { + inprogress: "In progress", + queued: "Queued", + done: "Done", + closed: "Closed", + active: "Active", + completed: "Completed", + paused: "Paused", +}; +const reviewStates: Record = { + none: "Not reviewed", + pending: "Review pending", + approved: "Approved", + request_changes: "Changes requested", + unresolved_changes: "Unresolved changes", +}; +function ticketRow( + workspaceId: string, + ticket: TicketListItemSummary, +): DashboardRow { + return { + key: `ticket:${ticket.resource_key}`, + kind: "Ticket", + reference: ticket.resource_key, + title: ticket.title, + href: ticketHref(workspaceId, ticket), + status: states[ticket.state] ?? ticket.state, + updatedAt: ticket.updated_at, + }; +} +function failure(label: string, error: string | null): string { + const status = error?.match(/HTTP \d{3}/)?.[0]; + return `${label} unavailable.${ + status ? ` ${status}.` : "" + } Refresh to retry.`; +} +function dateValue(value?: string | null): number { + const timestamp = value ? Date.parse(value) : NaN; + return Number.isFinite(timestamp) ? timestamp : -Infinity; +} +export function recentRows(rows: DashboardRow[]): DashboardRow[] { + const latest = new Map(); + for (const row of rows) { + const previous = latest.get(row.key); + if ( + !previous || dateValue(row.updatedAt) >= dateValue(previous.updatedAt) + ) latest.set(row.key, row); + } + return [...latest.values()] + .sort((a, b) => + dateValue(b.updatedAt) - dateValue(a.updatedAt) || + a.key.localeCompare(b.key) + ) + .slice(0, HOME_LIMIT); +} + +// These are bounded, read-only browser endpoints. QueryTicket/QueryObjective are +// tool-only capabilities and must not be approximated by guessed HTTP routes. +export function loadDashboard( + fetchFn: typeof fetch, + workspaceId: string, +): Dashboard { + function get(path: string, parse: (value: unknown) => T) { + return loadJson( + fetchFn, + workspaceApiPath(workspaceId, path), + { signal: AbortSignal.timeout(10_000) }, + parse, + TICKET_BROWSER_API_LOAD_POLICY, + ); + } + const tickets = (state: string) => + get( + `/tickets?${new URLSearchParams({ + states: state, + limit: String(HOME_LIMIT), + })}`, + parseTicketListResponse, + ); + const activeResult = tickets("inprogress,queued"); + const doneResult = tickets("done"); + const closedResult = tickets("closed"); + const objectivesResult = get( + `/objectives?limit=${HOME_LIMIT}`, + parseObjectiveListResponse, + ); + const reviewsResult = get( + `/merge-requests?state=open&limit=${HOME_LIMIT}`, + parseMergeRequestListResponse, + ); + + return { + reviews: reviewsResult.then((result): DashboardFeed => { + if (!result.data) { + return { rows: [], errors: [failure("Merge Requests", result.error)] }; + } + return { + rows: result.data.items.slice(0, HOME_LIMIT).map(( + { summary, ref_diagnostics }, + ) => ({ + key: `merge:${summary.merge_request_id}`, + kind: "Merge Request", + reference: summary.repository_key, + title: `${ + summary.selector_from ?? "Source unavailable" + } → ${summary.selector_to}`, + href: mergeRequestPagePath(workspaceId, summary.merge_request_id), + status: reviewStates[summary.review_status] ?? summary.review_status, + attention: ["request_changes", "unresolved_changes"].includes( + summary.review_status, + ) || Boolean(ref_diagnostics?.length), + detail: ref_diagnostics?.length + ? "Source or target needs attention" + : undefined, + updatedAt: summary.updated_at, + })), + errors: [], + notice: result.data.next_cursor + ? `Showing ${ + Math.min(result.data.items.length, HOME_LIMIT) + } most recently updated open Merge Requests.` + : undefined, + }; + }), + active: activeResult.then(async (result): Promise => { + if (!result.data) { + return { rows: [], errors: [failure("Active tickets", result.error)] }; + } + const details = await Promise.all( + result.data.items.slice(0, HOME_LIMIT).map(async (ticket) => { + const detail = await get( + `/tickets/${encodeURIComponent(ticket.resource_key)}`, + parseTicketDetail, + ); + if (!detail.data) { + return { + row: { + ...ticketRow(workspaceId, ticket), + detail: "Assignment and blockers unavailable", + }, + error: failure(`${ticket.resource_key} details`, detail.error), + }; + } + const current = detail.data; + // A concurrent transition can remove a Ticket from this snapshot's scope. + if (!["inprogress", "queued"].includes(current.state)) { + return { changed: true }; + } + const blockers = current.relations.blockers; + const row = ticketRow(workspaceId, { ...ticket, ...current }); + const blockedKeys = blockers.map((blocker) => + blocker.blocking_resource_key + ).filter(Boolean); + return { + row: { + ...row, + status: blockers.length ? `${row.status} · Blocked` : row.status, + attention: blockers.length > 0, + detail: blockers.length + ? (blockedKeys.length === blockers.length + ? `Blocked by ${blockedKeys.join(", ")}` + : `${blockers.length} unresolved dependencies`) + : current.current_coder + ? undefined + : "No coder assigned", + workerKey: current.current_coder?.worker_resource_key ?? + undefined, + }, + }; + }), + ); + return { + rows: details.flatMap((item) => item.row ? [item.row] : []), + errors: [ + ...details.flatMap((item) => item.error ? [item.error] : []), + ...(result.data.invalid_records.length + ? ["Some Ticket records could not be read."] + : []), + ], + notice: details.some((item) => item.changed) + ? "Work changed while loading. Refresh for the latest list." + : result.data.page.has_more || result.data.page.source_truncated + ? `Showing ${ + Math.min(result.data.items.length, HOME_LIMIT) + } active tickets, with in-progress work first.` + : undefined, + }; + }), + recent: Promise.all([doneResult, closedResult, objectivesResult]).then(( + [done, closed, objectives], + ): DashboardFeed => ({ + rows: recentRows([ + ...(done.data?.items ?? []).map((ticket) => + ticketRow(workspaceId, ticket) + ), + ...(closed.data?.items ?? []).map((ticket) => + ticketRow(workspaceId, ticket) + ), + ...(objectives.data?.items ?? []).map((objective): DashboardRow => ({ + key: `objective:${objective.resource_key}`, + kind: "Objective", + reference: objective.resource_key, + title: objective.title, + href: objectiveHref(workspaceId, objective), + status: states[objective.state] ?? objective.state, + updatedAt: objective.updated_at, + })), + ]), + errors: [ + ...(!done.data ? [failure("Done tickets", done.error)] : []), + ...(!closed.data ? [failure("Closed tickets", closed.error)] : []), + ...(!objectives.data ? [failure("Objectives", objectives.error)] : []), + ...([done.data, closed.data, objectives.data].some((data) => + data?.invalid_records.length + ) + ? ["Some records could not be read."] + : []), + ], + notice: + [done.data, closed.data, objectives.data].some((data) => + data?.items.length + ) + ? "Up to 5 latest updates to done / closed tickets and Objectives." + : undefined, + })), + }; +} diff --git a/web/workspace/src/lib/workspace/settings/WorkspaceIdentitySettings.svelte b/web/workspace/src/lib/workspace/settings/WorkspaceIdentitySettings.svelte new file mode 100644 index 000000000..2ebfa3788 --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/WorkspaceIdentitySettings.svelte @@ -0,0 +1,396 @@ + + +
+

Workspace Identity

+
+
+

Display name

+ {#if workspaceMetadata && !editing} + + {/if} +
+ {#if loading}

Loading workspace settings…

{/if} + {#if metadataError} + + + {/if} + {#if workspaceMetadata} + {#if editing} +
{ event.preventDefault(); void submitWorkspaceName(); }} aria-label="Edit Workspace name"> + +

Current: {workspaceMetadata.display_name}

+ {#if nameError}{/if} +
+ + + {#if nameError}{/if} +
+
+ {:else} +

{workspaceMetadata.display_name}

+ {/if} + {#if nameNotice}

{nameNotice}

{/if} + {#each diagnostics.filter((item) => item.severity !== 'info') as diagnostic} +

{diagnostic.message}

+ {/each} +
+ Technical details +
+
Source
{workspaceMetadata.source}
+
Revision
{workspaceMetadata.revision}
+
+ {#each diagnostics.filter((item) => item.severity === 'info') as diagnostic}

{diagnostic.message}

{/each} +
+ {/if} +
+ + {#if canDelete} + +
+
+

Public identity

+ {#if signingIdentity?.public_bundle}{/if} +
+ {#if identityLoading}

Loading identity…

{/if} + {#if identityError}{/if} + {#if !identityLoading && !signingIdentity}{/if} + {#if signingIdentity?.identity.state === 'pending_provisioning'} +

Not provisioned

+

Provision a signing identity before connecting a Runtime.

+ + {:else if signingIdentity?.public_bundle} +

Active

+ {#if identityCopied}

Public bundle copied.

{/if} + {#if copyError}{/if} +
+ Public bundle and key details +

Use this public bundle when connecting a Runtime. It contains no private key material.

+
+
Key
{signingIdentity.identity.key_id}
+
Fingerprint
{signingIdentity.identity.public_key_fingerprint}
+
Revision
{signingIdentity.identity.revision}
+
+ +
+ {/if} +
+ +
{ if (!event.currentTarget.open && !deletionSubmitting) deletionOpen = false; }}> + {deletionSubmitting ? 'Deleting Workspace…' : 'Delete Workspace'} +

Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data. This cannot be undone.

+ {#if !deletionOpen} + + {:else} +
+

Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? workspaceMetadata?.display_name ?? workspaceName}?

+ {#if deletionLoading}

Loading deletion impact…

{/if} + {#if deletionPreflight} +
    +
  • {deletionPreflight.resources.workers} Workers
  • {deletionPreflight.resources.workdirs} Workdirs
  • +
  • {deletionPreflight.resources.repositories} repositories
  • {deletionPreflight.resources.runtime_bindings} Runtime bindings
  • +
  • {deletionPreflight.resources.secrets} secret records
  • {deletionPreflight.resources.artifacts} artifacts
  • +
+ {#each deletionPreflight.blockers as blocker}{/each} + {#if !deletionRequest} + + {/if} + {/if} + {#if deletionOperation} +

Deletion state: {deletionOperation.state}

+ {#each deletionOperation.blockers as blocker}{/each} + {/if} + {#if deletionError}{/if} +
+ + {#if deletionPreflight || deletionRequest} + + {:else if !deletionLoading} + + {/if} +
+
+ {/if} +
+ {/if} +
+ + diff --git a/web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts b/web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts new file mode 100644 index 000000000..2ddacf317 --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts @@ -0,0 +1,125 @@ +// Synthetic public data only; shared by component tests and production browser fixtures. +export function metadataFixture(workspaceId = "home-owner") { + return { + workspace_id: workspaceId, + display_name: workspaceId === "home-long" + ? "Workspace with a long name — international documentation and distributed development" + : "Workspace Settings Review", + created_at: "2026-01-01T00:00:00Z", + revision: "2026-01-02T00:00:00Z", + source: "server_db", + diagnostics: [], + }; +} +export function identityFixture(workspaceId = "home-owner", pending = false) { + const identity = { + workspace_id: workspaceId, + key_id: "fixture-workspace-key", + algorithm: "ed25519", + revision: 1, + state: pending ? "pending_provisioning" : "active", + created_at: "2026-01-01T00:00:00Z", + }; + if (pending) return { identity }; + const publicKey = "yoi-ed25519-pub:v1:synthetic-public-key"; + const fingerprint = "SHA256:synthetic-public-fingerprint-for-ui-review"; + return { + identity: { + ...identity, + public_key: publicKey, + public_key_fingerprint: fingerprint, + provisioned_at: identity.created_at, + }, + public_bundle: { + workspace_id: workspaceId, + backend_url: "https://workspace.example.test", + key_id: identity.key_id, + algorithm: identity.algorithm, + public_key: publicKey, + public_key_fingerprint: fingerprint, + revision: 1, + }, + }; +} +export function deletionFixture(workspaceId = "home-owner") { + return { + workspace_id: workspaceId, + display_name: metadataFixture(workspaceId).display_name, + expected_revision: metadataFixture(workspaceId).revision, + can_delete: true, + blockers: [], + resources: { + workers: 2, + workdirs: 1, + repositories: 1, + runtime_bindings: 1, + secrets: 0, + artifacts: 0, + }, + }; +} +export function settingsFixtureHandler() { + const names = new Map>(); + const provisioned = new Set(); + return async (request: Request): Promise => { + const url = new URL(request.url); + const match = url.pathname.match(/^\/api\/w\/([^/]+)\/settings(.*)$/); + if (match) { + const [, id, path] = match; + if (!path) { + const current = names.get(id) ?? metadataFixture(id); + if (request.method === "PUT") { + const body = await request.json(); + if (body.revision !== current.revision) { + return new Response( + "Workspace metadata changed. Reload the saved name.", + { status: 409 }, + ); + } + const next = { + ...current, + display_name: String(body.display_name).trim(), + revision: String(Number.parseInt(current.revision, 10) + 1), + }; + names.set(id, next); + return Response.json({ workspace: next, diagnostics: [] }); + } + return Response.json(current); + } + if (path === "/signing-identity/provision") { + provisioned.add(id); + return Response.json(identityFixture(id)); + } + if (path === "/signing-identity") { + if (id === "home-error") { + return new Response("Public identity is temporarily unavailable.", { + status: 503, + }); + } + return Response.json( + identityFixture(id, id === "home-empty" && !provisioned.has(id)), + ); + } + } + const deletion = url.pathname.match( + /^\/api\/workspaces\/([^/]+)\/deletion$/, + ); + if (deletion) { + if (request.method !== "GET") { + return new Response("Fixture does not delete Workspaces.", { + status: 409, + }); + } + return Response.json({ + ...deletionFixture(deletion[1]), + ...(names.has(deletion[1]) + ? { + display_name: names.get(deletion[1])!.display_name, + expected_revision: names.get(deletion[1])!.revision, + } + : {}), + }); + } + return null; + }; +} diff --git a/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte b/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte index 6bfc9e323..cc935c951 100644 --- a/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte +++ b/web/workspace/src/lib/workspace/sidebar/SidebarFrame.svelte @@ -1,49 +1,124 @@ - + diff --git a/web/workspace/src/lib/workspace/sidebar/SidebarToggleIcon.svelte b/web/workspace/src/lib/workspace/sidebar/SidebarToggleIcon.svelte new file mode 100644 index 000000000..c4b4dbe34 --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/SidebarToggleIcon.svelte @@ -0,0 +1,22 @@ + + + + diff --git a/web/workspace/src/lib/workspace/sidebar/sidebar.css b/web/workspace/src/lib/workspace/sidebar/sidebar.css index cfd0875e0..1acca85b8 100644 --- a/web/workspace/src/lib/workspace/sidebar/sidebar.css +++ b/web/workspace/src/lib/workspace/sidebar/sidebar.css @@ -5,6 +5,7 @@ --sidebar-item-hover: oklch(96% 0 0); --sidebar-item-active: oklch(92.5% 0 0); --sidebar-width: clamp(220px, 20vw, 280px); + --sidebar-rail-width: calc(32px + var(--space-2) + var(--space-2) + 1px); --sidebar-motion-duration: 220ms; --sidebar-motion-easing: cubic-bezier(0.2, 0, 0, 1); @@ -12,13 +13,19 @@ grid-column: 1; grid-row: 1 / 3; width: var(--sidebar-width); + /* Animate reserved main-content space, independently of the panel. */ transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing); + position: relative; + z-index: 40; min-width: 0; min-height: 0; - overflow: hidden; + overflow: visible; } .sidebar-frame .bevel.sidebar-frame__bevel { - width: 100%; + /* Keep the open panel's width identical in pinned and overlay modes. */ + width: var(--sidebar-width); + transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing); + background: var(--bg); min-width: 0; min-height: 0; } @@ -29,7 +36,7 @@ height: 100%; min-width: 0; min-height: 0; - padding-block: var(--space-4); + padding-top: var(--space-4); } @media (prefers-color-scheme: dark) { .sidebar-frame { @@ -38,11 +45,17 @@ } } .sidebar-frame.folded { - width: calc(32px + var(--space-2) + var(--space-2) + 1px); + width: var(--sidebar-rail-width); + overflow: hidden; + } + .sidebar-hover-region { + flex: 1 1 0; + min-width: 0; + min-height: 0; overflow: hidden; } .sidebar-frame-content { - flex: 1 1 auto; + height: 100%; width: calc(var(--sidebar-width) - 1px); transform: translateX(0); visibility: visible; @@ -58,6 +71,24 @@ visibility: hidden; transition-delay: 0s, var(--sidebar-motion-duration); } + .sidebar-frame.hover-mode { + position: relative; + z-index: 40; + width: var(--sidebar-rail-width); + overflow: visible; + } + .sidebar-frame.hover-mode .bevel.sidebar-frame__bevel { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--sidebar-width); + overflow: hidden; + background: var(--bg); + } + .sidebar-frame.hover-mode.folded .bevel.sidebar-frame__bevel { + width: var(--sidebar-rail-width); + } .sidebar-frame-content, .global-sidebar, .workspace-sidebar, @@ -112,13 +143,21 @@ outline-offset: 1px; } .sidebar-control-row { + position: relative; display: flex; - justify-content: flex-end; + flex: 0 0 auto; + justify-content: flex-start; gap: var(--space-1); + padding: var(--space-2); } - .sidebar-control-row { - margin-top: auto; - padding: var(--space-4) var(--space-2) 0; + .sidebar-control-row::before { + content: ""; + position: absolute; + top: 0; + inset-inline: var(--space-2); + height: 1px; + background: var(--line); + pointer-events: none; } .workspace-label { flex: 1 1 auto; @@ -647,7 +686,8 @@ } @media (max-width: 760px) { - .sidebar-frame { + .sidebar-frame, + .sidebar-frame.hover-mode { --sidebar-width: 100%; z-index: 20; grid-column: 1; @@ -660,7 +700,8 @@ transition: transform var(--sidebar-motion-duration) var(--sidebar-motion-easing), visibility 0s; } - .sidebar-frame.folded { + .sidebar-frame.folded, + .sidebar-frame.hover-mode.folded { width: 100%; transform: translateX(-100%); visibility: hidden; @@ -673,8 +714,11 @@ visibility: inherit; transition: none; } - .sidebar-control-row { - display: none; + .sidebar-frame.hover-mode .bevel.sidebar-frame__bevel, + .sidebar-frame.hover-mode.folded .bevel.sidebar-frame__bevel { + position: static; + width: 100%; + transition: none; } } diff --git a/web/workspace/src/lib/workspace/styles/workspace-pages.css b/web/workspace/src/lib/workspace/styles/workspace-pages.css index d86012a44..b1ca31cac 100644 --- a/web/workspace/src/lib/workspace/styles/workspace-pages.css +++ b/web/workspace/src/lib/workspace/styles/workspace-pages.css @@ -58,39 +58,6 @@ margin-top: var(--space-1); } - .workspace-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-3); - } - .workspace-action-card { - display: grid; - gap: var(--space-1); - padding: var(--space-4); - border: 1px solid var(--line); - background: var(--bg-raised); - color: inherit; - text-decoration: none; - } - .workspace-action-card:hover, - .workspace-action-card:focus-visible { - border-color: var(--accent); - } - .workspace-action-card span, - .workspace-action-card small { - color: var(--text-muted); - } - .workspace-action-card span { - font-size: var(--font-size-compact); - } - .workspace-action-card strong { - color: var(--text-strong); - } - @media (max-width: 720px) { - .workspace-actions { - grid-template-columns: 1fr; - } - } .runtime-card { padding: 0; } diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 00f1bc367..5f09acf68 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -11,6 +11,7 @@ } from '$lib/workspace/header/context'; import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte'; import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte'; + import SidebarToggleIcon from '$lib/workspace/sidebar/SidebarToggleIcon.svelte'; import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '$lib/workspace/sidebar/context'; import { createOverrideStack } from '$lib/workspace/sidebar/override-stack'; import '../app.css'; @@ -25,31 +26,42 @@ const headerOverrides = createOverrideStack((activeHeader) => { header = activeHeader; }); - // Browser-wide layout preference; independent of authentication and Workspace. - const sidebarFoldStorageKey = 'yoi.sidebar.folded.v1'; - function loadSidebarFolded(): boolean { - if (typeof window === 'undefined') return false; + // Only the display mode persists; hover/touch previews never do. + const sidebarModeStorageKey = 'yoi.sidebar.mode.v1'; + const legacySidebarStorageKey = 'yoi.sidebar.folded.v1'; + function loadSidebarMode(): 'pinned' | 'hover' { + if (typeof window === 'undefined') return 'pinned'; try { - return window.localStorage.getItem(sidebarFoldStorageKey) === 'true'; + const mode = window.localStorage.getItem(sidebarModeStorageKey); + if (mode === 'pinned' || mode === 'hover') return mode; + return window.localStorage.getItem(legacySidebarStorageKey) === 'true' ? 'hover' : 'pinned'; } catch { - return false; + return 'pinned'; } } - let sidebarFolded = $state(loadSidebarFolded()); + let sidebarMode = $state(loadSidebarMode()); + let sidebarTransientOpen = $state(false); const mobileLayout = new MediaQuery('(max-width: 760px)'); + const sidebarOpen = $derived((!mobileLayout.current && sidebarMode === 'pinned') || sidebarTransientOpen); $effect(() => { - const folded = sidebarFolded; + const mode = sidebarMode; try { - window.localStorage.setItem(sidebarFoldStorageKey, String(folded)); + window.localStorage.setItem(sidebarModeStorageKey, mode); + window.localStorage.removeItem(legacySidebarStorageKey); } catch { - // Storage can be blocked or full; folding must still work for this page. + // Blocked/full storage must not prevent mode changes in this page. } }); + $effect(() => { + mobileLayout.current; + sidebarTransientOpen = false; + }); + function toggleSidebar() { - sidebarFolded = !sidebarFolded; + sidebarTransientOpen = !sidebarOpen; } provideHeaderController({ @@ -62,8 +74,14 @@ -
- +
+ { sidebarMode = mode; }} + onOpenChange={(open) => { sidebarTransientOpen = open; }} + > - +
@@ -203,6 +213,12 @@ padding: var(--space-4); } + @media (hover: none) { + .sidebar-hover-mode .app-shell__mobile-sidebar-toggle { + display: inline-flex; + } + } + @media (max-width: 760px) { .app-shell { grid-template-columns: minmax(0, 1fr); diff --git a/web/workspace/src/routes/sidebar-fold.browser.spec.ts b/web/workspace/src/routes/sidebar-fold.browser.spec.ts index ac4ceff48..d71217811 100644 --- a/web/workspace/src/routes/sidebar-fold.browser.spec.ts +++ b/web/workspace/src/routes/sidebar-fold.browser.spec.ts @@ -2,88 +2,226 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; -import { createRawSnippet } from "svelte"; +import { createRawSnippet, tick } from "svelte"; import { logout } from "$lib/workspace/auth/api"; import Layout from "./+layout.svelte"; vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } })); - -const storageKey = "yoi.sidebar.folded.v1"; +const storageKey = "yoi.sidebar.mode.v1"; +const legacyKey = "yoi.sidebar.folded.v1"; const props = { params: {}, children: createRawSnippet(() => ({ render: () => "

Page content

" })), data: { accessibleWorkspaces: [], workspaceCatalogError: null }, }; +const frame = () => screen.getByRole("complementary", { name: "Sidebar" }); +const folded = () => frame().classList.contains("folded"); +const hoverRegion = () => frame().querySelector(".sidebar-hover-region")!; +const enter = () => fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse" }); +const leave = () => fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" }); +const delay = () => vi.advanceTimersByTimeAsync(200); -beforeEach(() => localStorage.clear()); +beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); }); afterEach(() => { cleanup(); + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); localStorage.clear(); }); -test("sidebar starts unfolded and persists both desktop and mobile toggles", async () => { - const view = render(Layout, { props }); - expect(screen.getByRole("button", { name: "Fold sidebar" }).getAttribute("aria-expanded")).toBe("true"); - const content = view.container.querySelector(".sidebar-frame-content")!; +test("unpinning changes mode, and pointer departure closes the preview", async () => { + render(Layout, { props }); + expect(screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute("aria-pressed")).toBe("true"); + await enter(); + screen.getByRole("button", { name: "Unpin sidebar" }).focus(); + await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 }); + expect(localStorage.getItem(storageKey)).toBe("hover"); + expect(folded()).toBe(false); + await leave(); + await delay(); + expect(folded()).toBe(true); +}); + +test("footer unpin closes in the same update as the mode change, without the hover delay", async () => { + render(Layout, { props }); + const button = screen.getByRole("button", { name: "Unpin sidebar" }); + button.focus(); + await fireEvent.click(button, { detail: 1 }); + expect(localStorage.getItem(storageKey)).toBe("hover"); + expect(folded()).toBe(true); + await delay(); + expect(folded()).toBe(true); + await fireEvent.click(button, { detail: 1 }); + expect(localStorage.getItem(storageKey)).toBe("pinned"); + expect(folded()).toBe(false); +}); + +test("footer pointer clicks release previous sidebar link focus before unpinning", async () => { + render(Layout, { props }); + frame().querySelector("a")!.focus(); + await tick(); + await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 }); + expect(folded()).toBe(true); +}); + +test("keyboard mode switching preserves the focused preview", async () => { + render(Layout, { props }); + const button = screen.getByRole("button", { name: "Unpin sidebar" }); + button.focus(); + await fireEvent.click(button, { detail: 0 }); + expect(localStorage.getItem(storageKey)).toBe("hover"); + await delay(); + expect(folded()).toBe(false); +}); + +test.each(["pinned", "hover"])("restores %s mode without persisting a transient open state", (mode) => { + localStorage.setItem(storageKey, mode); + render(Layout, { props }); + expect(folded()).toBe(mode === "hover"); + expect(localStorage.getItem(storageKey)).toBe(mode); +}); + +test.each([true, false])("migrates legacy folded=%s to a mode", (value) => { + localStorage.setItem(legacyKey, String(value)); + render(Layout, { props }); + expect(localStorage.getItem(storageKey)).toBe(value ? "hover" : "pinned"); + expect(localStorage.getItem(legacyKey)).toBeNull(); + expect(folded()).toBe(value); +}); + +test("a saved mode takes precedence over the legacy preference", () => { + localStorage.setItem(legacyKey, "true"); + localStorage.setItem(storageKey, "pinned"); + render(Layout, { props }); + expect(folded()).toBe(false); +}); + +test("hover keeps the same content node, delays closing, and cancels closing on reentry", async () => { + localStorage.setItem(storageKey, "hover"); + render(Layout, { props }); + const content = frame().querySelector(".sidebar-frame-content")!; + expect(content.inert).toBe(true); + await enter(); + expect(folded()).toBe(false); expect(content.inert).toBe(false); - await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); - expect(view.container.querySelector(".sidebar-frame-content")).toBe(content); + await leave(); + await vi.advanceTimersByTimeAsync(100); + expect(folded()).toBe(false); + await enter(); + await delay(); + expect(folded()).toBe(false); + await leave(); + await delay(); + expect(folded()).toBe(true); + expect(frame().querySelector(".sidebar-frame-content")).toBe(content); expect(content.inert).toBe(true); expect(content.getAttribute("aria-hidden")).toBe("true"); - expect(localStorage.getItem(storageKey)).toBe("true"); - expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(false); - await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" })); - expect(content.inert).toBe(false); - expect(content.getAttribute("aria-hidden")).toBe("false"); - expect(localStorage.getItem(storageKey)).toBe("false"); - expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); - await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" })); - expect(localStorage.getItem(storageKey)).toBe("true"); - await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" })); - expect(localStorage.getItem(storageKey)).toBe("false"); + expect(localStorage.getItem(storageKey)).toBe("hover"); }); -test.each([true, false])("restores stored folded=%s without overwriting it with the default", (folded) => { - localStorage.setItem(storageKey, String(folded)); - const writes = vi.spyOn(Storage.prototype, "setItem"); - const view = render(Layout, { props }); - expect(screen.getByRole("button", { name: folded ? "Unfold sidebar" : "Fold sidebar" }).getAttribute("aria-expanded")).toBe(String(!folded)); - expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(!folded); - expect(writes.mock.calls.every(([key, value]) => key !== storageKey || value === String(folded))).toBe(true); +test("footer hover does not open or hold the preview, but its button still changes mode", async () => { + localStorage.setItem(storageKey, "hover"); + render(Layout, { props }); + const footer = frame().querySelector(".sidebar-control-row")!; + const button = screen.getByRole("button", { name: "Pin sidebar" }); + expect(button.querySelector('[data-icon="panel-right-open"] rect')).not.toBeNull(); + await fireEvent.pointerEnter(footer, { pointerType: "mouse" }); + await fireEvent.pointerEnter(button, { pointerType: "mouse" }); + await delay(); + expect(folded()).toBe(true); + await enter(); + expect(folded()).toBe(false); + await leave(); + await fireEvent.pointerEnter(footer, { pointerType: "mouse" }); + await delay(); + expect(folded()).toBe(true); + await fireEvent.click(button, { detail: 1 }); + expect(folded()).toBe(false); + expect(button.querySelector('[data-icon="panel-right-close"] rect')).not.toBeNull(); }); -test("fold preference survives logout and a fresh app mount", async () => { +test("keyboard focus opens the preview and holds it until focus leaves", async () => { + localStorage.setItem(storageKey, "hover"); + render(Layout, { props }); + screen.getByRole("button", { name: "Pin sidebar" }).focus(); + await tick(); + expect(folded()).toBe(false); + await enter(); + await leave(); + await delay(); + expect(folded()).toBe(false); + screen.getByRole("link", { name: "Open Account" }).focus(); + await delay(); + expect(folded()).toBe(true); +}); + +test("pinning a preview keeps it open after pointer and focus leave", async () => { + localStorage.setItem(storageKey, "hover"); + render(Layout, { props }); + await enter(); + await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }), { detail: 1 }); + await leave(); + await delay(); + expect(folded()).toBe(false); + expect(localStorage.getItem(storageKey)).toBe("pinned"); +}); + +test("mode survives logout and remount, but an open preview does not", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" }))); + localStorage.setItem(storageKey, "hover"); const view = render(Layout, { props }); - await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); + await enter(); await logout(); view.unmount(); render(Layout, { props }); - expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull(); - expect(localStorage.getItem(storageKey)).toBe("true"); + expect(folded()).toBe(true); + expect(localStorage.getItem(storageKey)).toBe("hover"); }); -test("invalid storage falls back to unfolded", () => { - localStorage.setItem(storageKey, "not-a-boolean"); +test("touch does not hover; mobile header opens temporarily without changing mode", async () => { + const matchMedia = window.matchMedia.bind(window); + vi.spyOn(window, "matchMedia").mockImplementation((query) => { + const result = matchMedia(query); + Object.defineProperty(result, "matches", { value: query === "(max-width: 760px)" }); + return result; + }); + localStorage.setItem(storageKey, "hover"); + const view = render(Layout, { props }); + await fireEvent.pointerEnter(frame(), { pointerType: "touch" }); + expect(folded()).toBe(true); + await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" })); + expect(folded()).toBe(false); + expect(view.container.querySelector("main")!.inert).toBe(true); + await fireEvent.pointerLeave(frame(), { pointerType: "touch" }); + await delay(); + expect(folded()).toBe(false); + await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" })); + expect(folded()).toBe(true); + expect(view.container.querySelector("main")!.inert).toBe(false); + expect(localStorage.getItem(storageKey)).toBe("hover"); +}); + +test("invalid storage falls back to pinned", () => { + localStorage.setItem(storageKey, "not-a-mode"); render(Layout, { props }); - expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); + expect(folded()).toBe(false); }); -test("unavailable storage does not prevent folding", async () => { +test("unavailable storage does not prevent mode switching", async () => { vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); }); render(Layout, { props }); - await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); - expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull(); - await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" })); - expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" })); + await delay(); + expect(folded()).toBe(true); + await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" })); + expect(folded()).toBe(false); }); -test("full storage still restores the saved value and allows toggles", async () => { - localStorage.setItem(storageKey, "true"); +test("full storage still restores the mode and allows changes", async () => { + localStorage.setItem(storageKey, "hover"); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); }); render(Layout, { props }); - await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" })); - expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" })); + expect(folded()).toBe(false); }); diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/+page.svelte index 0f8431623..55c5f7125 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/+page.svelte @@ -1,103 +1,53 @@ - - Yoi Workspace Control Plane - - +{data.workspace?.display_name ?? 'Workspace'} · Yoi -
-

Workspace

- {#if data.workspace} -
-
-
ID
-
{data.workspace.workspace_id}
-
-
-
Name
-
{data.workspace.display_name}
-
-
-
Record authority
-
{data.workspace.record_authority}
-
-
-
Host / Worker bridge
-
{data.workspace.extension_points.host_worker_bridge.status}
-
-
- {:else if data.workspaceError} -

{data.workspaceError}

- {:else} -

Waiting for /api/workspace…

- {/if} -
+
+
+

Activity

+ +
+ {#if refreshError}{/if} +
+ + + +
+
-
- - Tickets - Browse workspace tickets - Read typed Ticket records - - - Runtimes - Open admin Runtimes - {data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible - - - Workers - Open worker list - Inspect status and attach to consoles - -
- -
-

Hosts

- {#if data.hosts} - {#if data.hosts.items.length === 0} -

No local Hosts are visible.

- {:else} -
- {#each data.hosts.items as host} -
-
- {host.label} - {host.status} -
-
-
-
ID
-
{host.host_id}
-
-
-
Kind
-
{host.kind}
-
-
-
Runtime
-
{host.runtime_id}
-
-
-
Platform
-
{host.os} / {host.arch}
-
-
-
- {/each} -
- {/if} - {:else if data.hostsError} -

{data.hostsError}

- {:else} -

Waiting for /api/hosts…

- {/if} -
+ diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/+page.ts index daf3fc39f..5f1276ca4 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/+page.ts @@ -1,28 +1,10 @@ -import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; -import { - parseHostListResponse, - type HostListResponse, -} from "$lib/workspace/api/workspace-model"; +import { loadDashboard } from "$lib/workspace/home/dashboard"; import type { PageLoad } from "./$types"; -const HOST_API_LOAD_POLICY = { - diagnosticLabel: "Host API", - maxResponseBytes: 4 * 1024 * 1024, -} as const; - -export const load: PageLoad = async ({ fetch, params }) => { - const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); - const hosts = await loadJson( - fetch, - apiPath("/hosts"), - undefined, - parseHostListResponse, - HOST_API_LOAD_POLICY, - ); - +export const load: PageLoad = ({ fetch, params, depends }) => { + depends("workspace:home"); return { workspaceId: params.workspaceId, - hosts: hosts.data, - hostsError: hosts.error, + dashboard: loadDashboard(fetch, params.workspaceId), }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts new file mode 100644 index 000000000..10dba201f --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts @@ -0,0 +1,266 @@ +// @vitest-environment happy-dom +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import type { ComponentProps } from "svelte"; +import { invalidate } from "$app/navigation"; +import Home from "./+page.svelte"; +import { load } from "./+page"; +import { + type Dashboard, + type DashboardFeed, + loadDashboard, + recentRows, +} from "$lib/workspace/home/dashboard"; +import { + dashboardFixture, + fixtureDetail, +} from "$lib/workspace/home/dashboard.test-fixtures"; + +vi.mock("$app/navigation", () => ({ invalidate: vi.fn(async () => {}) })); +type Data = ComponentProps["data"]; +function fixtureFetch(failPath?: string) { + return vi.fn(async (path: RequestInfo | URL, _init?: RequestInit) => { + const url = new URL(String(path), "https://example.test"); + const [, id, resource] = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/)!; + return resource === failPath + ? Response.json({}, { status: 503 }) + : Response.json( + dashboardFixture(resource, url.searchParams, decodeURIComponent(id)), + ); + }); +} +function data(id = "home-owner", fetchFn: typeof fetch = fixtureFetch()): Data { + return { + workspaceId: id, + workspace: { workspace_id: id, display_name: "Home review" }, + dashboard: loadDashboard(fetchFn, id), + } as unknown as Data; +} +function mount(value = data()) { + return render(Home, { + props: { params: { workspaceId: value.workspaceId }, data: value }, + }); +} +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +test("Home shows actual reviews, active assignments/blockers, and recent records, not navigation tiles", async () => { + mount(); + expect(await screen.findByText("Changes requested")).toBeTruthy(); + expect(await screen.findByText("Blocked by T-99")).toBeTruthy(); + expect(screen.getByText("W-7")).toBeTruthy(); + expect(await screen.findByText("Improve daily Workspace operation")) + .toBeTruthy(); + expect(screen.getByText("Review pending")).toBeTruthy(); + expect(screen.getByText("Approved")).toBeTruthy(); + expect(screen.queryByRole("navigation")).toBeNull(); + expect(screen.queryByRole("heading", { name: "Settings" })).toBeNull(); + expect(screen.getAllByRole("button")).toHaveLength(1); + expect(document.body.textContent).not.toMatch( + /internal-worker|internal-merge|internal-runtime|Record authority/, + ); + for (const link of screen.getAllByRole("link")) { + expect(link.getAttribute("href")).toMatch( + /^\/w\/home-owner\/(tickets|objectives|merge-requests)\/[^/]+$/, + ); + } +}); + +test("loader only uses bounded read-only supported endpoints and advertises explicit refresh dependency", async () => { + const fetch = fixtureFetch(); + const depends = vi.fn(); + const result = await load( + { + fetch, + params: { workspaceId: "home-owner" }, + depends, + } as unknown as Parameters[0], + ); + await Promise.all(Object.values(result!.dashboard)); + expect(depends).toHaveBeenCalledWith("workspace:home"); + expect(fetch).toHaveBeenCalledTimes(7); + for (const [path, init] of fetch.mock.calls) { + const url = new URL(String(path), "https://example.test"); + expect(init?.method ?? "GET").toBe("GET"); + expect(init?.signal).toBeTruthy(); + expect(url.pathname).not.toMatch(/query|hosts|workers|settings/); + if (!url.pathname.includes("/tickets/T-")) { + expect(url.searchParams.get("limit")).toBe("5"); + } + } + expect(fetch.mock.calls.some(([url]) => String(url).includes("states=done"))) + .toBe(true); + expect( + fetch.mock.calls.some(([url]) => String(url).includes("states=closed")), + ).toBe(true); +}); + +test("recent updates merge independently limited sources in actual update order, not state priority", async () => { + const feed = await data().dashboard.recent; + expect(feed.errors).toEqual([]); + expect(feed.rows.map((row) => row.reference)).toEqual([ + "T-105", + "O-12", + "T-103", + "T-104", + ]); + const rows = Array.from( + { length: 10 }, + (_, index) => ({ + ...feed.rows[0], + key: String(index), + updatedAt: index === 9 ? null : `2026-01-01T0${index}:00:00Z`, + }), + ); + expect( + recentRows([...rows, { ...rows[8], updatedAt: rows[0].updatedAt }]).map(( + row, + ) => row.key), + ).toEqual([ + "8", + "7", + "6", + "5", + "4", + ]); +}); + +test("partial failure is not an empty/all-clear state and leaves sibling sections usable", async () => { + mount(data("home-owner", fixtureFetch("/merge-requests"))); + expect((await screen.findByRole("alert")).textContent).toContain( + "Merge Requests", + ); + expect(screen.queryByText("No open Merge Requests.")).toBeNull(); + expect(await screen.findByText("Blocked by T-99")).toBeTruthy(); + expect(await screen.findByText("Improve daily Workspace operation")) + .toBeTruthy(); +}); + +test("one recent source failure preserves the other records and does not assert an empty feed", async () => { + const value = data("home-owner", fixtureFetch("/objectives")); + mount(value); + expect((await value.dashboard.recent).rows.map((row) => row.reference)) + .toEqual(["T-105", "T-103", "T-104"]); + expect((await screen.findByRole("alert")).textContent).toContain( + "Objectives", + ); + expect(screen.queryByText("No done / closed tickets or Objectives yet.")) + .toBeNull(); +}); + +test("a failed Ticket detail preserves the summary without inventing no-assignee or unblocked state", async () => { + const feed = await data("home-owner", fixtureFetch("/tickets/T-101")) + .dashboard.active; + expect(feed.rows[0].detail).toBe("Assignment and blockers unavailable"); + expect(feed.rows[0].workerKey).toBeUndefined(); + expect(feed.rows[0].attention).toBeUndefined(); + expect(feed.errors[0]).toContain("T-101"); +}); + +test("a concurrent Ticket transition leaves the active scope with a refresh notice", async () => { + const fallback = fixtureFetch(); + const fetchFn: typeof fetch = (path, init) => + String(path).endsWith("/tickets/T-101") + ? Promise.resolve(Response.json({ ...fixtureDetail(), state: "done" })) + : fallback(path, init); + const feed = await data("home-owner", fetchFn).dashboard.active; + expect(feed.rows.map((row) => row.reference)).toEqual(["T-102"]); + expect(feed.notice).toContain("Work changed"); +}); + +test("empty resources have scoped empty states and no fabricated totals", async () => { + mount(data("home-empty")); + expect(await screen.findByText("No open Merge Requests.")).toBeTruthy(); + expect(await screen.findByText("No in-progress or queued tickets.")) + .toBeTruthy(); + expect(await screen.findByText("No done / closed tickets or Objectives yet.")) + .toBeTruthy(); + expect(screen.queryAllByRole("link")).toHaveLength(0); +}); + +test("slow review loading does not hide the other completed sections", async () => { + const value = data(); + value.dashboard.reviews = new Promise(() => {}); + mount(value); + expect( + within(screen.getByRole("region", { name: "Needs attention" })).getByRole( + "status", + ).textContent, + ).toContain("Loading needs attention"); + expect(await screen.findByText("Blocked by T-99")).toBeTruthy(); + expect(await screen.findByText("Improve daily Workspace operation")) + .toBeTruthy(); +}); + +test("route reuse drops pending old Workspace results and updates scoped links", async () => { + let finish!: (feed: DashboardFeed) => void; + const value = data(); + value.dashboard.reviews = new Promise((resolve) => { + finish = resolve; + }); + const view = mount(value); + await view.rerender({ data: data("space / 日本") }); + await screen.findByText("Changes requested"); + finish({ rows: [], errors: ["OLD WORKSPACE ERROR"] }); + await Promise.resolve(); + expect(screen.queryByText("OLD WORKSPACE ERROR")).toBeNull(); + for (const link of screen.getAllByRole("link")) { + expect(link.getAttribute("href")).toContain( + "/w/space%20%2F%20%E6%97%A5%E6%9C%AC/", + ); + } +}); + +test("malformed review responses are unavailable rather than empty", async () => { + const fallback = fixtureFetch(); + const fetchFn: typeof fetch = (path, init) => + String(path).includes("/merge-requests?") + ? Promise.resolve(Response.json({ items: "invalid" })) + : fallback(path, init); + const feed = await data("home-owner", fetchFn).dashboard.reviews; + expect(feed.rows).toEqual([]); + expect(feed.errors).toEqual([ + "Merge Requests unavailable. Refresh to retry.", + ]); +}); + +test("unknown review states are preserved and branch text never becomes HTML", async () => { + const fallback = fixtureFetch(); + const fetchFn: typeof fetch = async (path, init) => { + const response = await fallback(path, init); + if (!String(path).includes("/merge-requests?")) return response; + const body = await response.json(); + body.items[0].summary.review_status = "future_status"; + body.items[0].summary.selector_from = ''; + return Response.json(body); + }; + const view = mount(data("home-owner", fetchFn)); + expect(await screen.findByText("future_status")).toBeTruthy(); + expect(view.container.querySelector("img")).toBeNull(); + expect(await screen.findByText(/ { + const value = data(); + const feed = await value.dashboard.recent; + feed.rows[0].updatedAt = "invalid"; + value.dashboard.recent = Promise.resolve(feed); + mount(value); + expect(await screen.findByText("Update time unavailable")).toBeTruthy(); +}); + +test("Refresh invalidates only the Home data dependency", async () => { + const value = data(); + mount(value); + await Promise.all(Object.values(value.dashboard)); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(invalidate).toHaveBeenCalledWith("workspace:home"); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte index 8841e6be1..2de8f71f9 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte @@ -1,395 +1,17 @@ - - Workspace settings · Yoi Workspace - +Workspace Identity · Settings · Yoi -
-
-
-

editable

-

Workspace Identity

-
- Backend scoped -
- - {#if loading} -

Loading workspace settings…

- {:else} -
{ event.preventDefault(); void submitWorkspaceName(); }}> - -

Workspace id: {workspaceMetadata?.workspace_id ?? workspaceId}

- -
- -
-
-
Source
-
{workspaceMetadata?.source ?? 'unknown'}
-
-
-
Revision
-
{workspaceMetadata?.revision ?? 'unknown'}
-
-
- {/if} - - {#if message} -

{message}

- {/if} - -
- -{#if data.workspace?.permissions.delete_workspace} -
-
-
-

Workspace public identity

-

Use this public bundle when connecting a Runtime to this Workspace.

-
- {#if signingIdentity?.public_bundle} - - {/if} -
- {#if identityError} -

{identityError}

- {/if} - {#if identityLoading} -

Loading identity…

- {:else if signingIdentity?.identity.state === 'pending_provisioning'} -

This existing Workspace needs one explicit signing identity provisioning operation.

- - {:else if signingIdentity?.public_bundle} - - - {/if} -
- -
-
-

Danger zone

-

Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.

-
- -
+{#if data.workspace} + {#key `${data.workspace.workspace_id}:${data.workspace.permissions.delete_workspace}`} + + {/key} {/if} - -{#if deletionOpen} - -{/if} - - diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/settings.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/settings/settings.browser.spec.ts new file mode 100644 index 000000000..498a162f5 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/settings/settings.browser.spec.ts @@ -0,0 +1,532 @@ +// @vitest-environment happy-dom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import type { ComponentProps } from "svelte"; +import { goto, invalidate } from "$app/navigation"; +import Settings from "./+page.svelte"; +import { + deletionFixture, + identityFixture, + metadataFixture, + settingsFixtureHandler, +} from "$lib/workspace/settings/identity.test-fixtures"; +vi.mock( + "$app/navigation", + () => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }), +); +vi.mock( + "$lib/workspace/multiplexer", + () => ({ disposeWorkspaceMultiplexer: vi.fn() }), +); +vi.mock( + "$lib/workspace/sidebar/worker-subscription", + () => ({ disposeWorkspaceWorkersStore: vi.fn() }), +); +type Data = ComponentProps["data"]; +function data(id = "home-owner", owner = true): Data { + return { + workspace: { + workspace_id: id, + display_name: metadataFixture(id).display_name, + permissions: { delete_workspace: owner }, + }, + } as Data; +} +function mount(value = data()) { + return render(Settings, { + props: { + data: value, + params: { workspaceId: value.workspace!.workspace_id }, + }, + }); +} +function mockApi( + override?: (request: Request) => Promise | Response | null, +) { + const handler = settingsFixtureHandler(); + const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request( + new URL(String(input), "https://fixture.test"), + init, + ); + return await override?.(request) ?? await handler(request) ?? + new Response("Unexpected fixture request", { status: 404 }); + }); + vi.stubGlobal("fetch", mock); + return mock; +} +async function openName() { + await fireEvent.click( + await screen.findByRole("button", { name: "Edit name" }), + ); + return screen.getByLabelText("New display name") as HTMLInputElement; +} +async function openDeletion() { + await screen.findByText("Active", { exact: true }); + await fireEvent.click( + screen.getByText("Delete Workspace", { selector: "summary" }), + ); + await fireEvent.click( + screen.getByRole("button", { name: "Review deletion impact" }), + ); + return await screen.findByLabelText( + `Type ${metadataFixture().display_name} to confirm`, + ) as HTMLInputElement; +} +function operation(state = "succeeded", operationId = "op-test") { + return { + operation_id: operationId, + workspace_id: "home-owner", + display_name: metadataFixture().display_name, + state, + resources: deletionFixture().resources, + child_operation_ids: [], + blockers: [], + failure_category: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + completed_at: null, + }; +} +afterEach(() => { + cleanup(); + sessionStorage.clear(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +test("saved settings have no editable form, badges or raw data expanded; each technical scope is disclosed", async () => { + const api = mockApi(); + mount(); + await screen.findByText("Active", { exact: true }); + expect(screen.getByText(metadataFixture().display_name)).toBeTruthy(); + expect(document.querySelector("form")).toBeNull(); + expect(document.querySelector("input, textarea:not([readonly])")).toBeNull(); + // happy-dom does not implement native details visibility; Chromium verifies it. + expect(document.querySelector("textarea")?.closest("details")?.open).toBe( + false, + ); + expect(document.querySelectorAll("details[open]")).toHaveLength(0); + expect(document.querySelector(".badge")).toBeNull(); + expect(api.mock.calls).toHaveLength(2); + expect(api.mock.calls.some(([path]) => String(path).includes("deletion"))) + .toBe(false); +}); + +test("Edit focuses the field; Cancel discards the draft and returns focus without mutation", async () => { + const api = mockApi(); + mount(); + const input = await openName(); + expect(document.activeElement).toBe(input); + await fireEvent.input(input, { target: { value: "Discard this" } }); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Edit name" }), + ); + expect((await openName()).value).toBe(metadataFixture().display_name); + expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true); +}); + +test("save uses the current revision, publishes confirmed name and invalidates the Workspace header", async () => { + const api = mockApi(); + mount(); + await fireEvent.input(await openName(), { + target: { value: "Renamed Workspace" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save name" })); + await screen.findByText("Workspace name saved."); + expect(screen.getByText("Renamed Workspace")).toBeTruthy(); + expect(document.querySelector("form")).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Edit name" }), + ); + expect( + JSON.parse(String( + api.mock.calls.find(([, init]) => init?.method === "PUT")![1]!.body, + )), + ).toEqual({ + display_name: "Renamed Workspace", + revision: metadataFixture().revision, + }); + expect(invalidate).toHaveBeenCalledWith("/api/w/home-owner/workspace"); +}); + +test("failed save preserves the draft and saved name; Reload saved name recovers from revision conflicts", async () => { + mockApi((request) => + request.method === "PUT" + ? new Response("Revision conflict", { status: 409 }) + : null + ); + mount(); + const input = await openName(); + await fireEvent.input(input, { target: { value: "Keep draft" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save name" })); + expect((await screen.findByRole("alert")).textContent).toBe( + "Revision conflict", + ); + expect(input.value).toBe("Keep draft"); + expect(screen.getByText(`Current: ${metadataFixture().display_name}`)) + .toBeTruthy(); + await fireEvent.click( + screen.getByRole("button", { name: "Reload saved name" }), + ); + await screen.findByRole("button", { name: "Edit name" }); + expect(screen.queryByRole("alert")).toBeNull(); + expect((await openName()).value).toBe(metadataFixture().display_name); +}); + +test("pending save disables duplicate submit and cancel while preserving the current value", async () => { + let release!: (response: Response) => void; + const held = new Promise((resolve) => release = resolve); + const api = mockApi((request) => request.method === "PUT" ? held : null); + mount(); + await fireEvent.input(await openName(), { target: { value: "New name" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save name" })); + expect( + (screen.getByRole("button", { name: "Saving…" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + await fireEvent.submit(document.querySelector("form")!); + expect(api.mock.calls.filter(([, init]) => init?.method === "PUT")) + .toHaveLength(1); + release( + Response.json({ + workspace: { ...metadataFixture(), display_name: "New name" }, + diagnostics: [], + }), + ); + await screen.findByText("Workspace name saved."); +}); + +test("metadata and public identity load independently and offer scoped retries", async () => { + let failName = true; + mockApi((request) => + failName && new URL(request.url).pathname.endsWith("/settings") + ? new Response("Name unavailable", { status: 503 }) + : null + ); + mount(); + await screen.findByText("Active", { exact: true }); + await screen.findByText("Name unavailable"); + expect(screen.queryByRole("button", { name: "Edit name" })).toBeNull(); + failName = false; + await fireEvent.click(screen.getByRole("button", { name: "Retry name" })); + await screen.findByRole("button", { name: "Edit name" }); +}); + +test("identity failure does not block name editing or masquerade as pending provisioning", async () => { + let fail = true; + mockApi((request) => + fail && request.url.endsWith("/signing-identity") + ? new Response("Identity unavailable", { status: 503 }) + : null + ); + mount(); + await screen.findByRole("button", { name: "Edit name" }); + await screen.findByText("Identity unavailable"); + expect(screen.queryByRole("button", { name: "Provision identity" })) + .toBeNull(); + fail = false; + await fireEvent.click(screen.getByRole("button", { name: "Retry identity" })); + await screen.findByText("Active", { exact: true }); +}); + +test("non-owner retains metadata editing but never fetches or shows owner-only identity/deletion", async () => { + const api = mockApi(); + mount(data("home-member", false)); + await screen.findByRole("button", { name: "Edit name" }); + expect(screen.queryByRole("heading", { name: "Public identity" })).toBeNull(); + expect(screen.queryByText("Delete Workspace")).toBeNull(); + expect(api).toHaveBeenCalledTimes(1); +}); + +test("pending provisioning is explicit, only provisions on action and focuses the resulting copy action", async () => { + const api = mockApi(); + mount(data("home-empty")); + await screen.findByText("Not provisioned"); + expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true); + await fireEvent.click( + screen.getByRole("button", { name: "Provision identity" }), + ); + const copy = await screen.findByRole("button", { name: "Copy bundle" }); + expect(document.activeElement).toBe(copy); + expect(screen.getByText("Active", { exact: true })).toBeTruthy(); +}); + +test("copy uses the exact public bundle; clipboard failure offers manual selection rather than losing data", async () => { + mockApi(); + const writeText = vi.fn().mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Denied")); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + mount(); + const button = await screen.findByRole("button", { name: "Copy bundle" }); + await fireEvent.click(button); + await screen.findByText("Public bundle copied."); + expect(writeText).toHaveBeenCalledWith( + JSON.stringify(identityFixture().public_bundle, null, 2), + ); + await fireEvent.click(button); + expect((await screen.findByRole("alert")).textContent).toContain( + "select the bundle manually", + ); + expect(document.querySelector("textarea")!.value).toBe( + JSON.stringify(identityFixture().public_bundle, null, 2), + ); +}); + +test("deletion is deferred, requires exact current name, and Cancel restores disclosure focus", async () => { + const api = mockApi(); + mount(); + const input = await openDeletion(); + expect(document.activeElement).toBe(input); + const confirm = screen.getByRole("button", { + name: "Delete Workspace", + }) as HTMLButtonElement; + expect(confirm.disabled).toBe(true); + await fireEvent.input(input, { target: { value: "Wrong name" } }); + expect(confirm.disabled).toBe(true); + await fireEvent.input(input, { + target: { value: metadataFixture().display_name }, + }); + expect(confirm.disabled).toBe(false); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(document.activeElement?.textContent).toBe("Delete Workspace"); + expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true); +}); + +test("deletion blockers and preflight failure do not permit a destructive request", async () => { + let fail = true; + const api = mockApi((request) => + request.url.endsWith("/deletion") + ? fail + ? new Response("Preflight unavailable", { status: 503 }) + : Response.json({ + ...deletionFixture(), + can_delete: false, + blockers: [{ + kind: "worker_removal_blocked", + resource_kind: "worker", + resource_key: "W-7", + message: "Worker W-7 is running.", + }], + }) + : null + ); + mount(); + await screen.findByText("Active", { exact: true }); + await fireEvent.click( + screen.getByText("Delete Workspace", { selector: "summary" }), + ); + await fireEvent.click( + screen.getByRole("button", { name: "Review deletion impact" }), + ); + await screen.findByRole("button", { name: "Retry deletion check" }); + expect(screen.queryByRole("button", { name: "Delete Workspace" })).toBeNull(); + fail = false; + await fireEvent.click( + screen.getByRole("button", { name: "Retry deletion check" }), + ); + await screen.findByText("Worker W-7 is running."); + expect( + (screen.getByRole("button", { + name: "Delete Workspace", + }) as HTMLButtonElement).disabled, + ).toBe(true); + expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true); +}); + +test("deletion retry reuses the persisted operation id and confirmation, then redirects only after success", async () => { + let attempts = 0; + const api = mockApi(async (request) => { + if (request.method === "POST" && request.url.endsWith("/deletion")) { + const body = await request.json(); + return ++attempts === 1 + ? new Response("Connection lost", { status: 503 }) + : Response.json(operation("queued", body.operation_id)); + } + if (request.url.includes("/workspace-deletions/")) { + return Response.json( + operation("succeeded", request.url.split("/").pop()), + ); + } + return null; + }); + mount(); + await fireEvent.input(await openDeletion(), { + target: { value: metadataFixture().display_name }, + }); + await fireEvent.click( + screen.getByRole("button", { name: "Delete Workspace" }), + ); + await screen.findByRole("button", { name: "Retry deletion" }); + expect(goto).not.toHaveBeenCalled(); + const stored = sessionStorage.getItem("yoi:workspace-deletion:home-owner"); + expect(stored).toBeTruthy(); + await fireEvent.click(screen.getByRole("button", { name: "Retry deletion" })); + await waitFor(() => expect(goto).toHaveBeenCalledWith("/")); + const requests = api.mock.calls.filter(([, init]) => init?.method === "POST"); + expect(requests[0][1]!.body).toBe(requests[1][1]!.body); + expect(sessionStorage.getItem("yoi:workspace-deletion:home-owner")) + .toBeNull(); +}); + +test("reload resumes persisted deletion without sending another POST", async () => { + sessionStorage.setItem( + "yoi:workspace-deletion:home-owner", + JSON.stringify({ + operation_id: "op-test", + expected_revision: metadataFixture().revision, + confirmation: metadataFixture().display_name, + }), + ); + const api = mockApi((request) => + request.url.includes("/workspace-deletions/") + ? Response.json(operation("failed")) + : null + ); + mount(); + await screen.findByText("Deletion state: failed"); + expect(document.querySelector("details.deletion")?.hasAttribute("open")).toBe( + true, + ); + expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true); +}); + +test("Workspace switch resets drafts and ignores old load/mutation completions", async () => { + let release!: (response: Response) => void; + const held = new Promise((resolve) => release = resolve); + mockApi((request) => request.method === "PUT" ? held : null); + const view = mount(); + await fireEvent.input(await openName(), { + target: { value: "Old scope draft" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save name" })); + await view.rerender({ + data: data("home-long"), + params: { workspaceId: "home-long" }, + }); + await screen.findByText(metadataFixture("home-long").display_name); + release( + Response.json({ + workspace: { ...metadataFixture(), display_name: "Old scope draft" }, + diagnostics: [], + }), + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(screen.queryByText("Old scope draft")).toBeNull(); + expect(invalidate).not.toHaveBeenCalled(); + expect(goto).not.toHaveBeenCalled(); +}); + +test("slow identity keeps the name usable and old identity responses cannot leak across Workspaces", async () => { + let release!: (response: Response) => void; + const held = new Promise((resolve) => release = resolve); + mockApi((request) => + request.url.endsWith("/home-owner/settings/signing-identity") ? held : null + ); + const view = mount(); + await screen.findByRole("button", { name: "Edit name" }); + expect(screen.getByText("Loading identity…")).toBeTruthy(); + await view.rerender({ + data: data("home-empty"), + params: { workspaceId: "home-empty" }, + }); + await screen.findByText("Not provisioned"); + release(Response.json(identityFixture())); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(screen.queryByText("Active", { exact: true })).toBeNull(); + expect(screen.getByText("Not provisioned")).toBeTruthy(); +}); + +test("a completed deletion request from an unmounted Workspace cannot navigate away from the new scope", async () => { + const request = { + operation_id: "op-test", + expected_revision: metadataFixture().revision, + confirmation: metadataFixture().display_name, + }; + sessionStorage.setItem( + "yoi:workspace-deletion:home-owner", + JSON.stringify(request), + ); + let release!: (response: Response) => void; + const held = new Promise((resolve) => release = resolve); + mockApi((request) => + request.url.includes("/workspace-deletions/") ? held : null + ); + const view = mount(); + await screen.findByText("Deleting…"); + await view.rerender({ + data: data("home-member", false), + params: { workspaceId: "home-member" }, + }); + await screen.findByRole("button", { name: "Edit name" }); + release(Response.json(operation())); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(goto).not.toHaveBeenCalled(); + expect(sessionStorage.getItem("yoi:workspace-deletion:home-owner")).not + .toBeNull(); +}); + +test("name editing and deletion confirmation cannot overlap", async () => { + mockApi(); + mount(); + await openName(); + await fireEvent.click( + screen.getByText("Delete Workspace", { selector: "summary" }), + ); + expect( + (screen.getByRole("button", { + name: "Review deletion impact", + }) as HTMLButtonElement).disabled, + ).toBe(true); + await fireEvent.click( + screen.getByRole("button", { name: "Cancel" }), + ); + await fireEvent.click( + screen.getByRole("button", { name: "Review deletion impact" }), + ); + await screen.findByLabelText( + `Type ${metadataFixture().display_name} to confirm`, + ); + expect( + (screen.getByRole("button", { name: "Edit name" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + await fireEvent.click( + screen.getByRole("button", { name: "Cancel" }), + ); + expect( + (screen.getByRole("button", { name: "Edit name" }) as HTMLButtonElement) + .disabled, + ).toBe(false); +}); + +test("untrusted names and errors remain text, not interpreted HTML", async () => { + const name = ""; + mockApi((request) => + request.url.endsWith("/settings") + ? Response.json({ ...metadataFixture(), display_name: name }) + : request.url.endsWith("/signing-identity") + ? new Response("", { status: 503 }) + : null + ); + mount(); + await screen.findByText(name); + expect((await screen.findByRole("alert")).textContent).toBe( + "", + ); + expect(document.querySelector("img, script")).toBeNull(); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 4ae4f17d5..110c50821 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -1892,10 +1892,10 @@ /> {#if pendingSubmissionItems.length > 0 || pendingSubmissions.notification_count > 0} -
-
-

{pendingSubmissionItems.length} Queued

- {#if pendingSubmissionItems.length > 0} +
0 && pendingSubmissions.notification_count > 0} aria-label="Pending activations"> + {#if pendingSubmissionItems.length > 0} +
+

{pendingSubmissionItems.length} Queued

    {#each pendingSubmissionItems as submission, index (submission.submission_id)}
  1. @@ -1923,11 +1923,11 @@
  2. {/each}
- {/if} -
-
-

Notifications

- {#if pendingSubmissions.notification_count > 0} +
+ {/if} + {#if pendingSubmissions.notification_count > 0} +
+

Notifications

    {#each pendingSubmissions.notification_previews ?? [] as preview}
  1. @@ -1941,8 +1941,8 @@
  2. {/each}
- {/if} -
+
+ {/if}
{/if} @@ -2309,24 +2309,34 @@ .pending-submissions { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); gap: var(--space-3); flex: 0 0 auto; min-width: 0; margin: 0 var(--space-3); color: var(--text-muted); + font-family: var(--font-mono); font-size: var(--font-size-compact); line-height: var(--line-height-compact); } + .pending-submissions.has-both { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .pending-column { + display: grid; + align-content: start; + gap: var(--space-1); min-width: 0; } .pending-submissions li { display: grid; grid-template-columns: minmax(0, 1fr); - min-height: var(--space-6); + height: var(--line-height-compact); + margin: 0; + padding: 0; align-items: center; } @@ -2340,16 +2350,16 @@ } .pending-submissions-label { - margin: 0 0 var(--space-1); - font-size: var(--font-size-compact); - line-height: var(--line-height-compact); - font-weight: 500; + margin: 0; + padding: 0; + font: inherit; + color: inherit; } .pending-submissions ol { display: grid; gap: var(--space-1); - max-height: calc(var(--space-6) * 4); + max-height: calc(var(--line-height-compact) * 4 + var(--space-1) * 3); overflow-y: auto; margin: 0; padding: 0; @@ -2371,8 +2381,8 @@ align-items: center; justify-content: center; width: var(--space-6); - height: var(--space-6); - padding: var(--space-2); + height: var(--line-height-compact); + padding: 0; border: 0; border-radius: var(--radius-soft); background: transparent; diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts index 3a313e00b..069aa88a0 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts @@ -369,7 +369,8 @@ test("notification-only state displays previews without actions and supports leg session.pending_submissions.head_id = "notification-head"; latestListener().onFrame(subscribedFrame(session)); const region = await screen.findByRole("region", { name: "Pending activations" }); - expect(region.textContent).toContain("0 Queued"); + expect(screen.queryByRole("group", { name: "Queued inputs" })).toBeNull(); + expect(screen.queryByRole("heading", { name: "0 Queued" })).toBeNull(); expect(region.textContent).toContain("一件目の通知"); expect(region.textContent).toContain("二件目の通知"); expect(region.querySelectorAll("li")).toHaveLength(2); @@ -382,7 +383,7 @@ test("notification-only state displays previews without actions and supports leg await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull()); }); -test("queue-only state keeps the notification column empty and disables cancellation when disconnected", async () => { +test("queue-only state hides notifications and disables cancellation when disconnected", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); render(ConsolePage, { data: pageData() }); await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); @@ -390,7 +391,8 @@ test("queue-only state keeps the notification column empty and disables cancella session.pending_submissions.submissions = [{ submission_id: "queue-1", preview: "送信内容", accepted_at_ms: 1, segment_count: 1, byte_len: 12 }]; latestListener().onFrame(subscribedFrame(session)); await screen.findByText("送信内容"); - expect(screen.getByRole("group", { name: "Notifications" }).querySelector("ol")).toBeNull(); + expect(screen.queryByRole("group", { name: "Notifications" })).toBeNull(); + expect(screen.queryByRole("heading", { name: "Notifications" })).toBeNull(); latestListener().onStatus?.("closed", "connection lost"); await waitFor(() => expect((screen.getByRole("button", { name: "Cancel queued input 1" }) as HTMLButtonElement).disabled).toBe(true)); }); @@ -1137,3 +1139,28 @@ function expectHistoryTurn(text: string) { expect(screen.getAllByText(text)).toHaveLength(1); expect(screen.getByRole("button", { name: new RegExp("^Turn \\d+: " + text + "$") })).not.toBeNull(); } + +test("pending groups independently follow authoritative changes, including zero count with stale previews", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); + render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + const session = emptySession(); + session.pending_submissions.submissions = [{ submission_id: "queue-1", preview: "Queued", accepted_at_ms: 1, segment_count: 1, byte_len: 6 }]; + session.pending_submissions.notification_count = 1; + session.pending_submissions.notification_previews = ["Notice"]; + latestListener().onFrame(subscribedFrame(session)); + await screen.findByRole("group", { name: "Queued inputs" }); + expect(screen.getByRole("group", { name: "Notifications" })).toBeTruthy(); + session.pending_submissions.notification_count = 0; + latestListener().onFrame(subscribedFrame(session)); + await waitFor(() => expect(screen.queryByRole("group", { name: "Notifications" })).toBeNull()); + expect(screen.getByRole("group", { name: "Queued inputs" })).toBeTruthy(); + session.pending_submissions.submissions = []; + session.pending_submissions.notification_count = 1; + latestListener().onFrame(subscribedFrame(session)); + await waitFor(() => expect(screen.queryByRole("group", { name: "Queued inputs" })).toBeNull()); + expect(screen.getByRole("group", { name: "Notifications" })).toBeTruthy(); + session.pending_submissions.notification_count = 0; + latestListener().onFrame(subscribedFrame(session)); + await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull()); +}); diff --git a/web/workspace/static/licenses/lucide.txt b/web/workspace/static/licenses/lucide.txt new file mode 100644 index 000000000..e92fb943b --- /dev/null +++ b/web/workspace/static/licenses/lucide.txt @@ -0,0 +1,19 @@ +Lucide panel-right-close and panel-right-open +https://lucide.dev/icons/panel-right-close +https://lucide.dev/icons/panel-right-open + +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/web/workspace/tests/bevel-line-contract.test.ts b/web/workspace/tests/bevel-line-contract.test.ts index f433d9412..15451cd8c 100644 --- a/web/workspace/tests/bevel-line-contract.test.ts +++ b/web/workspace/tests/bevel-line-contract.test.ts @@ -93,18 +93,18 @@ Deno.test("Shell regions use selected Bevel edges while open separators use Beve assert( rootLayout.includes('class="app-shell__topbar-bevel"') && rootLayout.includes("top={false} right={false} left={false}") && - rootLayout.includes("bind:folded={sidebarFolded}") && + rootLayout.includes("open={sidebarOpen}") && rootLayout.includes("app-shell__mobile-sidebar-toggle") && - rootLayout.includes("class:sidebar-open={!sidebarFolded}") && + rootLayout.includes("class:sidebar-open={sidebarOpen}") && !rootLayout.includes("depth=") && !rootLayout.includes("BevelLine") && sidebarFrame.includes('class="sidebar-frame__bevel"') && sidebarFrame.includes("top={false} bottom={false} left={false}") && - sidebarFrame.includes("$bindable(false)") && + sidebarFrame.includes("class:folded={!open}") && !sidebarFrame.includes("depth=") && !sidebarFrame.includes("BevelLine") && sidebarCss.includes(".sidebar-frame.folded") && - sidebarCss.includes("display: none") && + sidebarCss.includes("transform: translateX(-100%)") && sidebarCss.includes("display: grid"), "Desktop shell boundaries must use one selected border edge and Mobile must share Header fold state", ); diff --git a/web/workspace/tests/workspace-model.test.ts b/web/workspace/tests/workspace-model.test.ts index 7ae598119..fc83d2ac7 100644 --- a/web/workspace/tests/workspace-model.test.ts +++ b/web/workspace/tests/workspace-model.test.ts @@ -313,11 +313,11 @@ Deno.test("Workspace deletion DTOs fail closed and preserve durable operation st }); Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => { - const source = await Deno.readTextFile( - new URL( - "../src/routes/w/[workspaceId]/settings/+page.svelte", - import.meta.url, - ), + const source = (await Promise.all([ + "../src/routes/w/[workspaceId]/settings/+page.svelte", + "../src/lib/workspace/settings/WorkspaceIdentitySettings.svelte", + ].map((path) => Deno.readTextFile(new URL(path, import.meta.url))))).join( + "\n", ); for ( const token of [ diff --git a/web/workspace/vite.config.ts b/web/workspace/vite.config.ts index 21556da02..34816d901 100644 --- a/web/workspace/vite.config.ts +++ b/web/workspace/vite.config.ts @@ -19,7 +19,9 @@ export default defineConfig({ ignored: [ "**/.tmp*", "**/.tmp*/**", + // Config bundling creates and immediately deletes these files. "**/vite.config.*.timestamp-*.mjs", + "**/vitest.config.*.timestamp-*.mjs", ], },