diff --git a/src/crates/core/src/agentic/tools/implementations/work_tool.rs b/src/crates/core/src/agentic/tools/implementations/work_tool.rs index eea27040..ac529c33 100644 --- a/src/crates/core/src/agentic/tools/implementations/work_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/work_tool.rs @@ -24,7 +24,7 @@ impl Tool for WorkTool { } async fn description(&self) -> CoreResult { - Ok("Run and manage specialist Work through one control-plane tool. action=start atomically creates and launches an Agent WorkSession and returns its work_id; action=continue sends more instructions to existing Work; action=status reads progress and results; action=control changes lifecycle state. Always target Work by the work_id from start, never by a session id.".to_string()) + Ok("Run and manage specialist Work through one control-plane tool. action=start atomically creates and launches an Agent WorkSession and returns its work_id; action=continue sends more instructions to existing Work; action=status reads progress and results; action=control changes lifecycle state; action=reclassify changes kind or topic attachment. Always target Work by the work_id from start, never by a session id. System-managed works are immutable.".to_string()) } fn input_schema(&self) -> Value { @@ -33,17 +33,25 @@ impl Tool for WorkTool { "properties": { "action": { "type": "string", - "enum": ["start", "continue", "status", "control"], - "description": "start: create and launch new Work. continue: add instructions to existing Work. status: read progress and results. control: change lifecycle state." + "enum": ["start", "continue", "status", "control", "reclassify"], + "description": "start: create and launch new Work. continue: add instructions to existing Work. status: read progress and results. control: change lifecycle state. reclassify: change kind or topic attachment." }, "work_id": { "type": "string", - "description": "The Work to target. Required for continue and control, and for status of one specific Work. This is the work_id returned by start, not a session id." + "description": "The Work to target. Required for continue, control, reclassify, and for status of one specific Work. This is the work_id returned by start, not a session id." }, "kind": { "type": "string", - "enum": ["one_shot", "multi_step", "long_running_session"], - "description": "start only. multi_step (default) for normal multi-step execution; one_shot for a single self-contained task; long_running_session for ongoing work." + "enum": ["one_shot", "multi_step", "long_running_session", "tracking", "topic", "recurring", "app_workflow"], + "description": "start or reclassify. multi_step (default) for normal multi-step execution; one_shot for a single self-contained task; long_running_session/tracking for ongoing user work; topic for a theme container; recurring for user cadence work; app_workflow when an app subject is attached." + }, + "topic_work_id": { + "type": "string", + "description": "Optional Topic work_id to attach this Work under. Topic target must have kind=topic." + }, + "clear_topic_work_id": { + "type": "boolean", + "description": "reclassify/start only. Clear topic attachment when true." }, "title": { "type": "string", @@ -293,8 +301,8 @@ mod tests { let actions = schema["properties"]["action"]["enum"] .as_array() .expect("action enum"); - assert_eq!(actions.len(), 4); - for action in ["start", "continue", "status", "control"] { + assert_eq!(actions.len(), 5); + for action in ["start", "continue", "status", "control", "reclassify"] { assert!( actions.iter().any(|value| value.as_str() == Some(action)), "missing action {action}" diff --git a/src/crates/core/src/agentic_os/tools/work.rs b/src/crates/core/src/agentic_os/tools/work.rs index ee29b8e6..c8d520c6 100644 --- a/src/crates/core/src/agentic_os/tools/work.rs +++ b/src/crates/core/src/agentic_os/tools/work.rs @@ -3,8 +3,9 @@ use serde_json::{json, Value}; use crate::agentic_os::work::{ AdvanceWorkRequest, ControlWorkAction, ControlWorkRequest, PrimarySurfacePolicy, - StartWorkRequest, WorkAssignmentKind, WorkAssignmentRef, WorkId, WorkKind, WorkOwnerRef, - WorkProjection, WorkRecord, WorkScope, WorkService, WorkStatus, WorkSubject, WorkVisibility, + ReclassifyWorkRequest, StartWorkRequest, UpdateWorkRequest, WorkAssignmentKind, + WorkAssignmentRef, WorkId, WorkKind, WorkOwnerRef, WorkProjection, WorkRecord, WorkScope, + WorkService, WorkStatus, WorkSubject, WorkVisibility, }; use crate::error::{CoreError, CoreResult}; @@ -15,6 +16,7 @@ pub enum WorkAction { Continue, Status, Control, + Reclassify, } #[derive(Debug, Deserialize)] @@ -52,6 +54,10 @@ pub struct WorkInput { pub control_action: Option, #[serde(default)] pub include_archived: Option, + #[serde(default)] + pub topic_work_id: Option, + #[serde(default)] + pub clear_topic_work_id: Option, #[serde(skip)] pub owner: Option, } @@ -62,6 +68,7 @@ pub async fn handle(service: &WorkService, input: WorkInput) -> CoreResult continue_work(service, input).await, WorkAction::Status => status_work(service, input).await, WorkAction::Control => control_work(service, input).await, + WorkAction::Reclassify => reclassify_work(service, input).await, } } @@ -89,18 +96,32 @@ async fn start_work(service: &WorkService, input: WorkInput) -> CoreResult CoreResult CoreResult { + let work = service + .reclassify(ReclassifyWorkRequest { + work_id: required_work_id(input.work_id, "reclassify")?, + kind: input + .kind + .ok_or_else(|| CoreError::validation("kind is required for action=reclassify"))?, + topic_work_id: input.topic_work_id, + clear_topic_work_id: input.clear_topic_work_id.unwrap_or(false), + }) + .await?; + + Ok(json!({ + "action": "reclassify", + "work_id": work.id, + "kind": work.kind, + "topic_work_id": work.topic_work_id, + "status": work.status, + "work": work, + })) +} + fn work_executor_to_assignment(executor: WorkExecutorInput) -> CoreResult { match executor.kind.unwrap_or(WorkExecutorKind::Agent) { WorkExecutorKind::Agent => { diff --git a/src/crates/core/src/agentic_os/work/hooks.rs b/src/crates/core/src/agentic_os/work/hooks.rs index a44b2370..03ef01b4 100644 --- a/src/crates/core/src/agentic_os/work/hooks.rs +++ b/src/crates/core/src/agentic_os/work/hooks.rs @@ -289,6 +289,30 @@ impl WorkLifecycleHookBus { items: reports, } } + + pub async fn notify_deleted( + &self, + context: &WorkLifecycleHookContext, + report: WorkCleanupReport, + ) { + let hook = WorkLifecycleHookKind::Deleted { report }; + for handler in self.handlers.iter() { + if !handler + .phases() + .contains(&WorkLifecycleHookPhase::AfterCommit) + { + continue; + } + if let Err(error) = handler.handle(context, &hook).await { + log::warn!( + "Work delete lifecycle hook failed after commit: handler_id={} work_id={} error={}", + handler.id(), + context.work.id, + error + ); + } + } + } } struct WorkSessionLifecycleHook; diff --git a/src/crates/core/src/agentic_os/work/mod.rs b/src/crates/core/src/agentic_os/work/mod.rs index 3697e2c6..0d78c54b 100644 --- a/src/crates/core/src/agentic_os/work/mod.rs +++ b/src/crates/core/src/agentic_os/work/mod.rs @@ -50,9 +50,9 @@ pub use service::{ AdvanceWorkRequest, AdvanceWorkResponse, ControlWorkAction, ControlWorkRequest, ControlWorkResponse, CreateWorkRequest, DeleteWorkResponse, DispatchNewWorkRequest, DispatchWorkRequest, DispatchWorkResponse, LinkSessionToWorkRequest, PrimarySurfacePolicy, - ResolveAppWorkRequest, ResolveAppWorkResponse, ResolveComponentWorkRequest, - ResolveComponentWorkResponse, StartWorkRequest, StartWorkResponse, UpdateWorkRequest, - WorkService, + ReclassifyWorkRequest, ResolveAppWorkRequest, ResolveAppWorkResponse, + ResolveComponentWorkRequest, ResolveComponentWorkResponse, StartWorkRequest, StartWorkResponse, + UpdateWorkRequest, WorkService, }; pub use store::{default_work_store, FileWorkStore, MemoryWorkStore, WorkStore}; pub use subject::{ diff --git a/src/crates/core/src/agentic_os/work/projection.rs b/src/crates/core/src/agentic_os/work/projection.rs index 4b3994a4..cbc88264 100644 --- a/src/crates/core/src/agentic_os/work/projection.rs +++ b/src/crates/core/src/agentic_os/work/projection.rs @@ -18,6 +18,12 @@ pub struct WorkProjection { pub scope: WorkScope, pub primary_surface: WorkSurfaceRef, pub running: bool, + #[serde(default)] + pub system_managed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_process_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topic_work_id: Option, pub updated_at: i64, } @@ -37,6 +43,9 @@ impl From<&WorkRecord> for WorkProjection { .execution_bindings .iter() .any(|binding| binding.is_running()), + system_managed: record.system_managed, + system_process_kind: record.system_process_kind.clone(), + topic_work_id: record.topic_work_id.clone(), updated_at: record.updated_at, } } diff --git a/src/crates/core/src/agentic_os/work/record.rs b/src/crates/core/src/agentic_os/work/record.rs index d90b4f6d..4eb59921 100644 --- a/src/crates/core/src/agentic_os/work/record.rs +++ b/src/crates/core/src/agentic_os/work/record.rs @@ -152,6 +152,12 @@ pub struct WorkRecord { pub builder_issues: Vec, pub artifact_refs: Vec, pub memory_refs: Vec, + #[serde(default)] + pub system_managed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_process_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topic_work_id: Option, pub created_at: i64, pub updated_at: i64, } @@ -199,6 +205,9 @@ impl WorkRecord { builder_issues: Vec::new(), artifact_refs: Vec::new(), memory_refs: Vec::new(), + system_managed: false, + system_process_kind: None, + topic_work_id: None, created_at: now, updated_at: now, } diff --git a/src/crates/core/src/agentic_os/work/service.rs b/src/crates/core/src/agentic_os/work/service.rs index 80380272..1abd66a5 100644 --- a/src/crates/core/src/agentic_os/work/service.rs +++ b/src/crates/core/src/agentic_os/work/service.rs @@ -76,6 +76,8 @@ pub struct CreateWorkRequest { pub title_state: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub delegation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topic_work_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -114,6 +116,24 @@ pub struct UpdateWorkRequest { pub primary_surface: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub title_state: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topic_work_id: Option, + #[serde(default)] + pub clear_topic_work_id: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visibility: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReclassifyWorkRequest { + pub work_id: WorkId, + pub kind: WorkKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topic_work_id: Option, + #[serde(default)] + pub clear_topic_work_id: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -360,6 +380,12 @@ impl WorkService { }); }; + if record.system_managed { + return Err(CoreError::validation( + "system_managed work cannot be deleted", + )); + } + let context = WorkLifecycleHookContext::new(record.clone(), Arc::clone(&self.runtime_bridge)); let plan = self.hook_bus.plan_delete(&context, options).await?; @@ -371,8 +397,15 @@ impl WorkService { ))); } + let deleted = self.store.delete(id).await?; + if deleted { + self.hook_bus + .notify_deleted(&context, cleanup_report.clone()) + .await; + } + Ok(DeleteWorkResponse { - deleted: self.store.delete(id).await?, + deleted, cleanup_report, }) } @@ -411,7 +444,19 @@ impl WorkService { .then_with(|| right.updated_at.cmp(&left.updated_at)) .then_with(|| left.id.cmp(&right.id)) }); + let requested_title_state = default_title_state_for_policy( + request.primary_surface_policy, + request.primary_surface.as_ref(), + &subject, + ); if let Some(work) = candidates.into_iter().next() { + let work = self + .normalize_resolved_app_work_title_state( + work, + request.title.as_str(), + &requested_title_state, + ) + .await?; return Ok(ResolveAppWorkResponse { work, created: false, @@ -430,9 +475,10 @@ impl WorkService { primary_surface_policy: request.primary_surface_policy, primary_surface: request.primary_surface, assignment: request.assignment, - title_state: None, + title_state: Some(requested_title_state), delegation: None, - }) + topic_work_id: None, + }) .await?; Ok(ResolveAppWorkResponse { work, @@ -499,7 +545,8 @@ impl WorkService { assignment: request.assignment, title_state: None, delegation: None, - }) + topic_work_id: None, + }) .await?; Ok(ResolveComponentWorkResponse { work, @@ -531,11 +578,11 @@ impl WorkService { }; let title_state = request.title_state.clone().unwrap_or_else(|| { - if let WorkSurfaceRef::ApplicationSurface { product_app_id, .. } = &primary_surface { - WorkTitleState::application_surface(product_app_id) - } else { - WorkTitleState::default() - } + default_title_state_for_policy( + request.primary_surface_policy, + Some(&primary_surface), + &request.subject, + ) }); let mut record = WorkRecord::new( @@ -553,6 +600,11 @@ impl WorkService { record.assignment = request.assignment; record.title_state = title_state; record.delegation = request.delegation; + if let Some(topic_work_id) = request.topic_work_id { + self.validate_topic_attachment(&topic_work_id, Some(&work_id)) + .await?; + record.topic_work_id = Some(topic_work_id); + } match request.primary_surface_policy { PrimarySurfacePolicy::WorkSession => { @@ -618,6 +670,19 @@ impl WorkService { let now = now_millis(); let mut record = self.get(id).await?; + if record.system_managed + && (request.kind.is_some() + || request.topic_work_id.is_some() + || request.clear_topic_work_id + || request.visibility.is_some() + || request.title.is_some() + || request.objective.is_some()) + { + return Err(CoreError::validation( + "system_managed work cannot change kind, topic, visibility, title, or objective", + )); + } + if let Some(title) = request.title { validate_required("title", &title)?; record.title = title; @@ -643,16 +708,196 @@ impl WorkService { record.touch(now); } if let Some(status) = request.status { + if record.system_managed { + return Err(CoreError::validation( + "system_managed work status is owned by the system process", + )); + } record.set_status(status, "status updated", now); } if let Some(surface) = request.primary_surface { record.bind_surface(surface, true, now); } + if let Some(visibility) = request.visibility { + record.visibility = visibility; + record.touch(now); + } + if let Some(kind) = request.kind { + validate_kind_transition(&record, kind)?; + record.kind = kind; + if kind == WorkKind::Topic { + record.topic_work_id = None; + } + record.touch(now); + } + if request.clear_topic_work_id { + record.topic_work_id = None; + record.touch(now); + } else if let Some(topic_work_id) = request.topic_work_id { + if record.kind == WorkKind::Topic { + return Err(CoreError::validation( + "topic work cannot attach to another topic", + )); + } + self.validate_topic_attachment(&topic_work_id, Some(id)) + .await?; + record.topic_work_id = Some(topic_work_id); + record.touch(now); + } self.store.put(&record).await?; Ok(record) } + pub async fn reclassify( + &self, + request: ReclassifyWorkRequest, + ) -> CoreResult { + self.update( + &request.work_id, + UpdateWorkRequest { + kind: Some(request.kind), + topic_work_id: request.topic_work_id, + clear_topic_work_id: request.clear_topic_work_id, + ..UpdateWorkRequest::default() + }, + ) + .await + } + + pub async fn ensure_system_works_from_processes( + &self, + processes: &[crate::agentic_os::background_process::BackgroundProcess], + ) -> CoreResult> { + let now = now_millis(); + let mut ensured = Vec::with_capacity(processes.len()); + for process in processes { + let work_id = system_work_id_for_process(process)?; + let scope = work_scope_from_process_scope(&process.scope); + let status = work_status_from_process_status(process.status); + let summary_text = process_summary_text(process); + + if let Some(mut record) = self.store.get(&work_id).await? { + if !record.system_managed { + return Err(CoreError::validation(format!( + "work id {} exists but is not system_managed", + work_id + ))); + } + let mut dirty = false; + if record.title != process.title { + record.title = process.title.clone(); + dirty = true; + } + if record.objective != system_work_objective(process) { + record.objective = system_work_objective(process); + dirty = true; + } + if record.scope != scope { + record.scope = scope; + dirty = true; + } + if record.kind != WorkKind::Recurring { + record.kind = WorkKind::Recurring; + dirty = true; + } + if record.visibility != WorkVisibility::Secondary { + record.visibility = WorkVisibility::Secondary; + dirty = true; + } + if record.system_process_kind.as_deref() + != Some(system_process_kind_key(process.kind).as_str()) + { + record.system_process_kind = Some(system_process_kind_key(process.kind)); + dirty = true; + } + if record.status != status { + record.set_status(status, "system process sync", now); + dirty = true; + } + let next_summary = WorkSummary { + text: summary_text, + updated_at: now, + }; + if record.summary.as_ref().map(|item| item.text.as_str()) + != Some(next_summary.text.as_str()) + { + record.summary = Some(next_summary); + dirty = true; + } + if dirty { + record.touch(now); + self.store.put(&record).await?; + } + ensured.push(record); + continue; + } + + let primary_surface = WorkSurfaceRef::WorkCenter { + work_id: work_id.clone(), + }; + let mut record = WorkRecord::new( + work_id, + WorkKind::Recurring, + process.title.clone(), + system_work_objective(process), + WorkVisibility::Secondary, + WorkSubject::Goal, + Vec::new(), + scope, + primary_surface, + now, + ); + record.system_managed = true; + record.system_process_kind = Some(system_process_kind_key(process.kind)); + record.assignment = Some(WorkAssignmentRef { + kind: WorkAssignmentKind::Application, + agent_type: None, + assistant_id: None, + application_id: Some("sparo_os".to_string()), + human_label: None, + external_label: None, + }); + record.title_state = WorkTitleState { + source: WorkTitleSource::Template, + locked: true, + subject_ref: Some(system_process_kind_key(process.kind)), + }; + record.set_status(status, "system process ensure", now); + record.summary = Some(WorkSummary { + text: summary_text, + updated_at: now, + }); + self.store.put(&record).await?; + ensured.push(record); + } + Ok(ensured) + } + + async fn validate_topic_attachment( + &self, + topic_work_id: &WorkId, + child_work_id: Option<&WorkId>, + ) -> CoreResult<()> { + if child_work_id.is_some_and(|id| id == topic_work_id) { + return Err(CoreError::validation( + "work cannot attach to itself as topic", + )); + } + let topic = self.get(topic_work_id).await?; + if topic.system_managed { + return Err(CoreError::validation( + "system_managed work cannot be used as topic", + )); + } + if topic.kind != WorkKind::Topic { + return Err(CoreError::validation( + "topic_work_id must reference kind=topic", + )); + } + Ok(()) + } + pub async fn sync_title_from_agent_session( &self, session_id: &str, @@ -668,7 +913,9 @@ impl WorkService { let now = now_millis(); let mut updated = Vec::new(); for mut record in self.store.list().await? { - if !work_title_can_follow_agent_session(&record, session_id) { + if !work_title_can_follow_agent_session(&record, session_id) + && !work_title_can_adopt_agent_session_title(&record, session_id) + { continue; } @@ -1138,7 +1385,8 @@ impl WorkService { assignment: Some(assignment), title_state: Some(WorkTitleState::agent()), delegation: Some(delegation), - }) + topic_work_id: None, + }) .await?; let advanced = self @@ -1176,7 +1424,8 @@ impl WorkService { assignment: Some(request.assignment), title_state: Some(WorkTitleState::agent()), delegation: parent.delegation.clone(), - }) + topic_work_id: None, + }) .await?; let mut parent = parent; @@ -1275,6 +1524,11 @@ impl WorkService { pub async fn control(&self, request: ControlWorkRequest) -> CoreResult { let now = now_millis(); let mut record = self.get(&request.work_id).await?; + if record.system_managed { + return Err(CoreError::validation( + "system_managed work lifecycle is owned by the system process", + )); + } match request.action { ControlWorkAction::Pause => record.set_status(WorkStatus::Paused, "paused", now), ControlWorkAction::Resume => record.set_status(WorkStatus::Active, "resumed", now), @@ -1637,6 +1891,26 @@ impl WorkService { ); Ok(()) } + + async fn normalize_resolved_app_work_title_state( + &self, + mut record: WorkRecord, + requested_title: &str, + requested_title_state: &WorkTitleState, + ) -> CoreResult { + if !should_adopt_resolved_default_title_state( + &record, + requested_title, + requested_title_state, + ) { + return Ok(record); + } + + record.title_state = requested_title_state.clone(); + record.touch(now_millis()); + self.store.put(&record).await?; + Ok(record) + } } async fn application_surface_for_product_app_subject( @@ -1763,6 +2037,129 @@ fn resolve_runtime_workspace_path(scope: &WorkScope) -> CoreResult { } } +fn validate_kind_transition(record: &WorkRecord, next: WorkKind) -> CoreResult<()> { + if record.system_managed { + return Err(CoreError::validation( + "system_managed work kind is immutable", + )); + } + if record.kind == next { + return Ok(()); + } + if next == WorkKind::DelegatedWork { + return Err(CoreError::validation( + "cannot reclassify into delegated_work", + )); + } + if next == WorkKind::AppWorkflow && record.subject.app_ref().is_none() { + return Err(CoreError::validation( + "app_workflow requires an app subject attachment", + )); + } + Ok(()) +} + +fn system_process_kind_key( + kind: crate::agentic_os::background_process::BackgroundProcessKind, +) -> String { + match kind { + crate::agentic_os::background_process::BackgroundProcessKind::AutoMemoryExtraction => { + "auto_memory_extraction".to_string() + } + crate::agentic_os::background_process::BackgroundProcessKind::MemoryConsolidation => { + "memory_consolidation".to_string() + } + crate::agentic_os::background_process::BackgroundProcessKind::HostScan => { + "host_scan".to_string() + } + crate::agentic_os::background_process::BackgroundProcessKind::WorkspaceOverviewRefresh => { + "workspace_overview_refresh".to_string() + } + crate::agentic_os::background_process::BackgroundProcessKind::GlobalDailyReport => { + "global_daily_report".to_string() + } + crate::agentic_os::background_process::BackgroundProcessKind::DailyLetter => { + "daily_letter".to_string() + } + crate::agentic_os::background_process::BackgroundProcessKind::GlobalMilestone => { + "global_milestone".to_string() + } + } +} + +fn system_work_id_for_process( + process: &crate::agentic_os::background_process::BackgroundProcess, +) -> CoreResult { + let id = format!("sysbp_{}", process.id.replace(':', "_")); + WorkId::parse(id).map_err(CoreError::validation) +} + +fn work_scope_from_process_scope( + scope: &crate::agentic_os::background_process::BackgroundProcessScope, +) -> WorkScope { + match scope { + crate::agentic_os::background_process::BackgroundProcessScope::System => WorkScope::System, + crate::agentic_os::background_process::BackgroundProcessScope::Workspace { + workspace_path, + } => WorkScope::Workspace { + workspace_path: workspace_path.clone(), + }, + crate::agentic_os::background_process::BackgroundProcessScope::Session { .. } + | crate::agentic_os::background_process::BackgroundProcessScope::Path { .. } => { + WorkScope::System + } + } +} + +fn work_status_from_process_status( + status: crate::agentic_os::background_process::BackgroundProcessStatus, +) -> WorkStatus { + use crate::agentic_os::background_process::BackgroundProcessStatus; + match status { + BackgroundProcessStatus::Running | BackgroundProcessStatus::Queued => WorkStatus::Running, + BackgroundProcessStatus::Failed => WorkStatus::Failed, + BackgroundProcessStatus::Cancelled => WorkStatus::Cancelled, + BackgroundProcessStatus::Disabled => WorkStatus::Paused, + BackgroundProcessStatus::Succeeded + | BackgroundProcessStatus::Skipped + | BackgroundProcessStatus::Idle + | BackgroundProcessStatus::Scheduled + | BackgroundProcessStatus::CoolingDown => WorkStatus::Active, + } +} + +fn system_work_objective( + process: &crate::agentic_os::background_process::BackgroundProcess, +) -> String { + format!( + "System-managed {} process. Runtime details stay on the system process surface.", + system_process_kind_key(process.kind) + ) +} + +fn process_summary_text( + process: &crate::agentic_os::background_process::BackgroundProcess, +) -> String { + let mut parts = vec![format!("status={}", format!("{:?}", process.status).to_ascii_lowercase())]; + if let Some(phase) = process.phase { + parts.push(format!("phase={}", format!("{:?}", phase).to_ascii_lowercase())); + } + if let Some(next_run_at) = process.next_run_at { + parts.push(format!("next_run_at={next_run_at}")); + } + if let Some(error) = process.last_error.as_deref().filter(|value| !value.is_empty()) { + parts.push(format!("error={error}")); + } else if let Some(message) = process + .last_result + .as_ref() + .and_then(|result| result.message.as_deref()) + .filter(|value| !value.is_empty()) + { + parts.push(format!("result={message}")); + } + parts.join(" · ") +} + fn now_millis() -> i64 { chrono::Utc::now().timestamp_millis() } @@ -1800,6 +2197,48 @@ fn app_work_status_rank(status: WorkStatus) -> u8 { } } +fn default_title_state_for_policy( + primary_surface_policy: PrimarySurfacePolicy, + primary_surface: Option<&WorkSurfaceRef>, + subject: &WorkSubject, +) -> WorkTitleState { + if let Some(WorkSurfaceRef::ApplicationSurface { product_app_id, .. }) = primary_surface { + return WorkTitleState::application_surface(product_app_id); + } + + match primary_surface_policy { + PrimarySurfacePolicy::ApplicationSurface => subject + .app_ref() + .map(|app| WorkTitleState::application_surface(app.app_id.clone())) + .unwrap_or_else(WorkTitleState::default), + PrimarySurfacePolicy::WorkSession => WorkTitleState::template(), + PrimarySurfacePolicy::WorkCenter => WorkTitleState::default(), + } +} + +fn should_adopt_resolved_default_title_state( + record: &WorkRecord, + requested_title: &str, + requested_title_state: &WorkTitleState, +) -> bool { + if requested_title_state.locked || requested_title_state.source == WorkTitleSource::User { + return false; + } + if !is_implicit_default_title_state(&record.title_state) { + return false; + } + + let record_title = normalize_default_title_label(&record.title); + let requested_title = normalize_default_title_label(requested_title); + !record_title.is_empty() && record_title == requested_title +} + +fn is_implicit_default_title_state(title_state: &WorkTitleState) -> bool { + title_state.source == WorkTitleSource::User + && title_state.locked + && title_state.subject_ref.is_none() +} + fn work_references_agent_session(record: &WorkRecord, session_id: &str) -> bool { record .session_refs @@ -1821,6 +2260,105 @@ fn work_title_can_follow_agent_session(record: &WorkRecord, session_id: &str) -> && work_references_agent_session(record, session_id) } +fn work_title_can_adopt_agent_session_title(record: &WorkRecord, session_id: &str) -> bool { + work_references_agent_session(record, session_id) + && is_implicit_default_title_state(&record.title_state) + && work_title_looks_like_default_agent_session_title(record) +} + +fn work_title_looks_like_default_agent_session_title(record: &WorkRecord) -> bool { + let title = normalize_default_title_label(&record.title); + if title.is_empty() { + return false; + } + + if record + .subject + .app_ref() + .is_some_and(|app| title_matches_identifier_default(&title, &app.app_id)) + { + return true; + } + + if record + .app_refs + .iter() + .any(|relation| title_matches_identifier_default(&title, &relation.app.app_id)) + { + return true; + } + + record + .assignment + .as_ref() + .and_then(|assignment| assignment.agent_type.as_deref()) + .is_some_and(|agent_type| title_matches_identifier_default(&title, agent_type)) +} + +fn title_matches_identifier_default(title: &str, identifier: &str) -> bool { + let compact_title = compact_default_title_label(title); + default_title_identifier_candidates(identifier) + .into_iter() + .any(|candidate| { + let candidate_work = format!("{} work", candidate.trim_end_matches(" work")); + title == candidate + || title == candidate_work + || compact_title == compact_default_title_label(&candidate) + || compact_title == compact_default_title_label(&candidate_work) + }) +} + +fn default_title_identifier_candidates(identifier: &str) -> Vec { + let trimmed = identifier.trim(); + let without_builtin = trimmed.strip_prefix("builtin-").unwrap_or(trimmed); + let normalized_identifier = normalize_default_title_label(without_builtin); + let mut candidates = vec![normalized_identifier.clone()]; + + match normalize_default_title_label(trimmed).as_str() { + "os agent" | "osagent" => { + candidates.push("os work".to_string()); + candidates.push("os agent".to_string()); + } + "app builder" | "appbuilder" => { + candidates.push("app builder".to_string()); + candidates.push("app builder work".to_string()); + } + "runno" => { + candidates.push("runno".to_string()); + candidates.push("runno work".to_string()); + } + _ => {} + } + + candidates.sort(); + candidates.dedup(); + candidates +} + +fn normalize_default_title_label(value: &str) -> String { + let mut spaced = String::new(); + let mut previous_was_lower_or_digit = false; + for character in value.trim().chars() { + if character.is_ascii_alphanumeric() { + if character.is_ascii_uppercase() && previous_was_lower_or_digit { + spaced.push(' '); + } + spaced.push(character.to_ascii_lowercase()); + previous_was_lower_or_digit = + character.is_ascii_lowercase() || character.is_ascii_digit(); + } else { + spaced.push(' '); + previous_was_lower_or_digit = false; + } + } + + spaced.split_whitespace().collect::>().join(" ") +} + +fn compact_default_title_label(value: &str) -> String { + value.split_whitespace().collect::>().join("") +} + fn work_references_application_surface(record: &WorkRecord, application_id: &str) -> bool { record .subject @@ -3744,7 +4282,10 @@ mod tests { WorkBuilderFactStatus, WorkBuilderIssueStatus, WorkRuntimeInstanceStatus, WorkRuntimeIssueSeverity, WorkRuntimeLogLevel, }; - use super::super::hooks::{WorkCleanupAction, WorkCleanupItemStatus, WorkResourceOwnership}; + use super::super::hooks::{ + WorkCleanupAction, WorkCleanupItemStatus, WorkLifecycleHookHandler, WorkLifecycleHookKind, + WorkLifecycleHookOutcome, WorkLifecycleHookPhase, WorkResourceOwnership, + }; use super::super::record::ArtifactRuntimeProvenance; use super::*; use crate::agentic_os::work::store::MemoryWorkStore; @@ -3784,6 +4325,39 @@ mod tests { deleted_sessions: Mutex>, } + #[derive(Debug, Default)] + struct DeletedRecordingHook { + deleted_work_ids: Mutex>, + } + + const AFTER_COMMIT_PHASES: &[WorkLifecycleHookPhase] = &[WorkLifecycleHookPhase::AfterCommit]; + + #[async_trait] + impl WorkLifecycleHookHandler for DeletedRecordingHook { + fn id(&self) -> &'static str { + "deleted_recorder" + } + + fn phases(&self) -> &'static [WorkLifecycleHookPhase] { + AFTER_COMMIT_PHASES + } + + async fn handle( + &self, + context: &WorkLifecycleHookContext, + hook: &WorkLifecycleHookKind, + ) -> CoreResult { + if let WorkLifecycleHookKind::Deleted { report } = hook { + assert_eq!(report.work_id, context.work.id.as_str()); + self.deleted_work_ids + .lock() + .expect("deleted work ids lock") + .push(report.work_id.clone()); + } + Ok(WorkLifecycleHookOutcome::Continue) + } + } + #[async_trait] impl WorkRuntimeBridge for RecordingRuntimeBridge { async fn create_work_session( @@ -3872,7 +4446,8 @@ mod tests { assignment: None, title_state: None, delegation: None, - }) + topic_work_id: None, + }) .await .expect("create work"); @@ -3901,7 +4476,8 @@ mod tests { assignment: Some(WorkAssignmentRef::agent("Runno")), title_state: None, delegation: None, - }) + topic_work_id: None, + }) .await .expect("create work session"); @@ -3910,7 +4486,7 @@ mod tests { } #[tokio::test] - async fn template_work_title_follows_generated_session_title() { + async fn default_work_session_title_follows_generated_session_title() { let service = service(); let record = service .create(CreateWorkRequest { @@ -3926,9 +4502,10 @@ mod tests { primary_surface_policy: PrimarySurfacePolicy::WorkSession, primary_surface: None, assignment: Some(WorkAssignmentRef::agent("Runno")), - title_state: Some(WorkTitleState::template()), + title_state: None, delegation: None, - }) + topic_work_id: None, + }) .await .expect("create work session"); let session_id = record.work_session_id().expect("work session").to_string(); @@ -3967,7 +4544,8 @@ mod tests { assignment: Some(WorkAssignmentRef::agent("Runno")), title_state: Some(WorkTitleState::template()), delegation: None, - }) + topic_work_id: None, + }) .await .expect("create work session"); let session_id = record.work_session_id().expect("work session").to_string(); @@ -4004,9 +4582,10 @@ mod tests { primary_surface_policy: PrimarySurfacePolicy::WorkSession, primary_surface: None, assignment: Some(WorkAssignmentRef::agent("Runno")), - title_state: None, + title_state: Some(WorkTitleState::user_locked()), delegation: None, - }) + topic_work_id: None, + }) .await .expect("create work session"); let session_id = record.work_session_id().expect("work session").to_string(); @@ -4023,6 +4602,54 @@ mod tests { assert_eq!(stored.title_state.source, WorkTitleSource::User); } + #[tokio::test] + async fn legacy_default_agent_session_work_title_can_adopt_generated_session_title() { + let service = service(); + let app = WorkAppRef::product_app("builtin-bitfun-coder", "1.0.0", "sha256:test-lock"); + let record = service + .create(CreateWorkRequest { + kind: WorkKind::AppWorkflow, + title: "BitFun Coder".to_string(), + objective: "Use BitFun Coder".to_string(), + subject: WorkSubject::App { + app: app.clone(), + intent: WorkAppIntent::Run, + }, + app_refs: vec![WorkAppRelation { + app, + role: WorkAppRelationRole::Executor, + surface_id: None, + }], + scope: WorkScope::Workspace { + workspace_path: "D:/workspace/project".to_string(), + }, + visibility: WorkVisibility::Primary, + primary_surface_policy: PrimarySurfacePolicy::WorkSession, + primary_surface: None, + assignment: Some(WorkAssignmentRef::agent("bitfun-coder")), + title_state: Some(WorkTitleState::user_locked()), + delegation: None, + topic_work_id: None, + }) + .await + .expect("create legacy app work session"); + let session_id = record.work_session_id().expect("work session").to_string(); + + let updated = service + .sync_title_from_agent_session(&session_id, "Implement search filters", false) + .await + .expect("sync generated title"); + + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].title, "Implement search filters"); + assert_eq!(updated[0].title_state.source, WorkTitleSource::Session); + assert_eq!( + updated[0].title_state.subject_ref.as_deref(), + Some(session_id.as_str()) + ); + assert!(!updated[0].title_state.locked); + } + #[tokio::test] async fn manual_work_title_update_locks_future_session_sync() { let service = service(); @@ -4042,7 +4669,8 @@ mod tests { assignment: Some(WorkAssignmentRef::agent("Runno")), title_state: Some(WorkTitleState::template()), delegation: None, - }) + topic_work_id: None, + }) .await .expect("create work session"); let session_id = record.work_session_id().expect("work session").to_string(); @@ -4070,6 +4698,62 @@ mod tests { assert_eq!(stored.title, "My custom work title"); } + #[tokio::test] + async fn resolve_app_work_unlocks_legacy_default_work_session_title() { + let service = service(); + let app = WorkAppRef::product_app("builtin-bitfun-coder", "1.0.0", "sha256:test-lock"); + let existing = service + .create(CreateWorkRequest { + kind: WorkKind::AppWorkflow, + title: "BitFun Coder".to_string(), + objective: "Use BitFun Coder".to_string(), + subject: WorkSubject::App { + app: app.clone(), + intent: WorkAppIntent::Run, + }, + app_refs: vec![WorkAppRelation { + app: app.clone(), + role: WorkAppRelationRole::Executor, + surface_id: None, + }], + scope: WorkScope::Workspace { + workspace_path: "D:/workspace/project".to_string(), + }, + visibility: WorkVisibility::Primary, + primary_surface_policy: PrimarySurfacePolicy::WorkSession, + primary_surface: None, + assignment: Some(WorkAssignmentRef::agent("bitfun-coder")), + title_state: Some(WorkTitleState::user_locked()), + delegation: None, + topic_work_id: None, + }) + .await + .expect("create legacy app work session"); + + let resolved = service + .resolve_app_work(ResolveAppWorkRequest { + app, + intent: WorkAppIntent::Run, + title: "BitFun Coder".to_string(), + objective: "Use BitFun Coder".to_string(), + scope: WorkScope::Workspace { + workspace_path: "D:/workspace/project".to_string(), + }, + visibility: WorkVisibility::Primary, + primary_surface_policy: PrimarySurfacePolicy::WorkSession, + primary_surface: None, + assignment: Some(WorkAssignmentRef::agent("bitfun-coder")), + app_refs: Vec::new(), + }) + .await + .expect("resolve legacy app work"); + + assert!(!resolved.created); + assert_eq!(resolved.work.id, existing.id); + assert_eq!(resolved.work.title_state.source, WorkTitleSource::Template); + assert!(!resolved.work.title_state.locked); + } + #[tokio::test] async fn application_surface_work_title_can_follow_application_name() { let service = service(); @@ -4094,7 +4778,8 @@ mod tests { assignment: None, title_state: None, delegation: None, - }) + topic_work_id: None, + }) .await .expect("create application surface work"); @@ -4479,7 +5164,8 @@ mod tests { assignment: None, title_state: None, delegation: None, - }) + topic_work_id: None, + }) .await .expect("create work"); let work_id = work.id.clone(); @@ -6707,7 +7393,8 @@ mod tests { assignment: None, title_state: None, delegation: None, - }) + topic_work_id: None, + }) .await .expect("create work"); @@ -6758,7 +7445,8 @@ mod tests { assignment: Some(WorkAssignmentRef::agent("Runno")), title_state: None, delegation: None, - }) + topic_work_id: None, + }) .await .expect("create work"); let session_id = record @@ -6810,7 +7498,8 @@ mod tests { assignment: None, title_state: None, delegation: None, - }) + topic_work_id: None, + }) .await .expect("create work"); let linked = service @@ -6837,6 +7526,123 @@ mod tests { })); } + #[tokio::test] + async fn delete_work_deletes_linked_sessions_when_requested() { + let bridge = Arc::new(RecordingRuntimeBridge::default()); + let runtime_bridge: Arc = bridge.clone(); + let service = + WorkService::with_runtime_bridge(Arc::new(MemoryWorkStore::new()), runtime_bridge); + let record = service + .create(CreateWorkRequest { + kind: WorkKind::MultiStep, + title: "Investigate".to_string(), + objective: "Delete linked session with work".to_string(), + subject: WorkSubject::Goal, + app_refs: Vec::new(), + scope: WorkScope::Workspace { + workspace_path: "D:/workspace/project".to_string(), + }, + visibility: WorkVisibility::Primary, + primary_surface_policy: PrimarySurfacePolicy::WorkCenter, + primary_surface: None, + assignment: None, + title_state: None, + delegation: None, + topic_work_id: None, + }) + .await + .expect("create work"); + let linked = service + .link_session_to_work(LinkSessionToWorkRequest { + work_id: record.id.clone(), + session_id: "session-linked".to_string(), + workspace_path: Some("D:/workspace/project".to_string()), + surface: Some(WorkSurfaceRef::AgentSession { + session_id: "session-linked".to_string(), + }), + set_primary: true, + }) + .await + .expect("link session"); + + let response = service + .delete_with_options( + &linked.id, + WorkDeleteOptions { + delete_linked_sessions: true, + ..Default::default() + }, + ) + .await + .expect("delete work"); + + assert!(response.deleted); + assert_eq!( + bridge + .deleted_sessions + .lock() + .expect("deleted sessions lock") + .as_slice(), + &[( + "D:/workspace/project".to_string(), + "session-linked".to_string() + )] + ); + assert!(response.cleanup_report.items.iter().any(|report| { + report.item.resource.id == "session-linked" + && report.item.resource.ownership == WorkResourceOwnership::Linked + && report.item.action == WorkCleanupAction::Delete + && report.status == WorkCleanupItemStatus::Succeeded + })); + assert!(!response.cleanup_report.items.iter().any(|report| { + report.item.resource.id == "session-linked" + && report.item.resource.ownership == WorkResourceOwnership::Linked + && report.item.action == WorkCleanupAction::Retain + })); + } + + #[tokio::test] + async fn delete_work_notifies_after_commit_lifecycle_hooks() { + let hook = Arc::new(DeletedRecordingHook::default()); + let hook_handler: Arc = hook.clone(); + let service = WorkService::with_lifecycle_hooks( + Arc::new(MemoryWorkStore::new()), + Arc::new(TestRuntimeBridge), + WorkLifecycleHookBus::default_handlers_with(vec![hook_handler]), + ); + let record = service + .create(CreateWorkRequest { + kind: WorkKind::MultiStep, + title: "Notify delete".to_string(), + objective: "Notify after deletion".to_string(), + subject: WorkSubject::Goal, + app_refs: Vec::new(), + scope: WorkScope::Workspace { + workspace_path: "D:/workspace/project".to_string(), + }, + visibility: WorkVisibility::Primary, + primary_surface_policy: PrimarySurfacePolicy::WorkCenter, + primary_surface: None, + assignment: None, + title_state: None, + delegation: None, + topic_work_id: None, + }) + .await + .expect("create work"); + + let response = service.delete(&record.id).await.expect("delete work"); + + assert!(response.deleted); + assert_eq!( + hook.deleted_work_ids + .lock() + .expect("deleted work ids lock") + .as_slice(), + &[record.id.as_str().to_string()] + ); + } + #[tokio::test] async fn dispatch_new_creates_delegated_work() { let service = service(); @@ -6856,7 +7662,8 @@ mod tests { assignment: None, title_state: None, delegation: None, - }) + topic_work_id: None, + }) .await .expect("parent"); @@ -7648,7 +8455,8 @@ mod tests { assignment: Some(WorkAssignmentRef::agent("Runno")), title_state: Some(WorkTitleState::template()), delegation: None, - }) + topic_work_id: None, + }) .await .expect("create work session"); diff --git a/src/crates/core/src/command/agentic_os/work.rs b/src/crates/core/src/command/agentic_os/work.rs index e5149618..1228ea59 100644 --- a/src/crates/core/src/command/agentic_os/work.rs +++ b/src/crates/core/src/command/agentic_os/work.rs @@ -189,6 +189,14 @@ pub async fn list_works_with_service( service: &WorkService, request: AgenticOsListWorksRequest, ) -> CommandResult { + let processes = crate::agentic_os::background_process::list_background_processes() + .await + .map_err(CommandError::session)?; + service + .ensure_system_works_from_processes(&processes.processes) + .await + .map_err(CommandError::session)?; + let mut works = service.list().await.map_err(CommandError::session)?; if let Some(workspace_path) = request .workspace_path diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index d1bc1c15..57ec9919 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -6,6 +6,7 @@ import { useCurrentModelConfig } from '../hooks/useModelConfigs'; import { ContextMenuRenderer } from '../shared/context-menu-system/components/ContextMenuRenderer'; import { NotificationContainer } from '../shared/notification-system'; import { AnnouncementProvider } from '../shared/announcement-system'; +import DailyLetterArrivalDock from './daily-letter-arrival/components/DailyLetterArrivalDock'; import { ConfirmDialogRenderer } from '@/design-system'; import { createLogger } from '@/shared/utils/logger'; import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; @@ -320,6 +321,9 @@ function App() { {/* Announcement / feature-demo / tips system */} + {/* Daily Letter arrival: bottom-left envelope card / seal chip */} + + {/* Recovery panel for boot failures. */} {isDegraded(bootStage) && ( diff --git a/src/web-ui/src/app/agentic-os/work/data/workApi.ts b/src/web-ui/src/app/agentic-os/work/data/workApi.ts index 4b2b0c5b..bdd2969a 100644 --- a/src/web-ui/src/app/agentic-os/work/data/workApi.ts +++ b/src/web-ui/src/app/agentic-os/work/data/workApi.ts @@ -45,9 +45,16 @@ import type { WorkBuilderPreviewResult, WorkBuilderPreviewSource, WorkBuilderValidationResult, + WorkCleanupAction, + WorkCleanupItemReport, + WorkCleanupItemStatus, + WorkCleanupReport, + WorkDeleteOptions, + WorkDeleteResult, WorkSubject, WorkSurfaceRef, WorkTitleState, + WorkResourceOwnership, } from '../domain/workTypes'; type RawWorkScope = @@ -189,10 +196,44 @@ type RawWorkRecord = { runtime_instances?: RawRuntimeInstanceRef[]; artifact_refs: RawArtifactRef[]; memory_refs: RawMemoryRef[]; + system_managed?: boolean; + system_process_kind?: string | null; + topic_work_id?: string | null; created_at: number; updated_at: number; }; +type RawWorkDeleteOptions = { + cascade_child_works: boolean; + delete_linked_sessions: boolean; +}; + +type RawWorkCleanupResourceRef = { + kind: string; + id: string; + ownership: WorkResourceOwnership; + metadata?: Record | null; +}; + +type RawWorkCleanupItem = { + id: string; + handler_id: string; + resource: RawWorkCleanupResourceRef; + action: WorkCleanupAction; + required?: boolean; +}; + +type RawWorkCleanupItemReport = { + item: RawWorkCleanupItem; + status: WorkCleanupItemStatus; + message?: string | null; +}; + +type RawWorkCleanupReport = { + work_id: string; + items?: RawWorkCleanupItemReport[]; +}; + type RawWorkRuntimeRun = { runId: string; runtimeInstanceId: string; @@ -620,11 +661,46 @@ export function fromRawWorkRecord(record: RawWorkRecord): WorkRecord { runtimeInstances: (record.runtime_instances ?? []).map(fromRawRuntimeInstanceRef), artifactRefs: record.artifact_refs.map(fromRawArtifactRef), memoryRefs: record.memory_refs.map(fromRawMemoryRef), + systemManaged: Boolean(record.system_managed), + systemProcessKind: record.system_process_kind ?? null, + topicWorkId: record.topic_work_id ?? null, createdAt: record.created_at, updatedAt: record.updated_at, }; } +function toRawWorkDeleteOptions(options?: WorkDeleteOptions): RawWorkDeleteOptions { + return { + cascade_child_works: Boolean(options?.cascadeChildWorks), + delete_linked_sessions: Boolean(options?.deleteLinkedSessions), + }; +} + +function fromRawWorkCleanupReport( + report: RawWorkCleanupReport | undefined, + workId: string +): WorkCleanupReport { + return { + workId: report?.work_id ?? workId, + items: (report?.items ?? []).map((itemReport): WorkCleanupItemReport => ({ + item: { + id: itemReport.item.id, + handlerId: itemReport.item.handler_id, + resource: { + kind: itemReport.item.resource.kind, + id: itemReport.item.resource.id, + ownership: itemReport.item.resource.ownership, + metadata: itemReport.item.resource.metadata ?? undefined, + }, + action: itemReport.item.action, + required: itemReport.item.required ?? false, + }, + status: itemReport.status, + message: itemReport.message, + })), + }; +} + function toRawCreateWorkRequest(request: CreateWorkRequest): Record { return { kind: request.kind, @@ -638,6 +714,7 @@ function toRawCreateWorkRequest(request: CreateWorkRequest): Record { + async deleteWork(workId: string, options?: WorkDeleteOptions): Promise { try { - const response = await api.invoke<{ deleted: boolean }>('agentic_os_delete_work', { - request: { work_id: workId }, - }); - return response.deleted; + const response = await api.invoke<{ deleted: boolean; cleanup_report?: RawWorkCleanupReport }>( + 'agentic_os_delete_work', + { + request: { work_id: workId, options: toRawWorkDeleteOptions(options) }, + } + ); + return { + deleted: response.deleted, + cleanupReport: fromRawWorkCleanupReport(response.cleanup_report, workId), + }; } catch (error) { - throw createTauriCommandError('agentic_os_delete_work', error, { workId }); + throw createTauriCommandError('agentic_os_delete_work', error, { workId, options }); } } diff --git a/src/web-ui/src/app/agentic-os/work/data/workStore.ts b/src/web-ui/src/app/agentic-os/work/data/workStore.ts index 7adaee01..91a00bf4 100644 --- a/src/web-ui/src/app/agentic-os/work/data/workStore.ts +++ b/src/web-ui/src/app/agentic-os/work/data/workStore.ts @@ -9,6 +9,8 @@ import type { ResolveAppWorkRequest, ResolveComponentWorkRequest, UpdateWorkRequest, + WorkDeleteOptions, + WorkDeleteResult, WorkRecord, } from '../domain/workTypes'; @@ -31,7 +33,7 @@ interface WorkStoreState { updateWork: (request: UpdateWorkRequest) => Promise; advanceWork: (request: AdvanceWorkRequest) => Promise; controlWork: (request: ControlWorkRequest) => Promise; - deleteWork: (workId: string) => Promise; + deleteWork: (workId: string, options?: WorkDeleteOptions) => Promise; } function upsertWork(works: WorkRecord[], next: WorkRecord): WorkRecord[] { @@ -111,9 +113,9 @@ export const useWorkStore = create((set, get) => ({ return work; }, - deleteWork: async (workId) => { - const deleted = await agenticOsWorkApi.deleteWork(workId); - if (deleted) { + deleteWork: async (workId, options) => { + const result = await agenticOsWorkApi.deleteWork(workId, options); + if (result.deleted) { set({ works: get().works.filter((work) => work.id !== workId), loaded: true, @@ -121,7 +123,7 @@ export const useWorkStore = create((set, get) => ({ error: null, }); } - return deleted; + return result; }, })); diff --git a/src/web-ui/src/app/agentic-os/work/domain/workClassification.test.ts b/src/web-ui/src/app/agentic-os/work/domain/workClassification.test.ts new file mode 100644 index 00000000..83c64546 --- /dev/null +++ b/src/web-ui/src/app/agentic-os/work/domain/workClassification.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + getWorkCategory, + getWorkPriorityGroup, + getWorkRailSection, + isDockEligibleWork, +} from './workClassification'; + +describe('workClassification', () => { + it('treats app_workflow as immediate, not long-term', () => { + expect(getWorkCategory('app_workflow')).toBe('immediate'); + expect(getWorkRailSection({ kind: 'app_workflow' })).toBe('immediate'); + expect(getWorkPriorityGroup('app_workflow', 'active')).toBe('immediate'); + }); + + it('keeps explicit continuity kinds in long-term or topic/recurring rails', () => { + expect(getWorkCategory('tracking')).toBe('long_term'); + expect(getWorkCategory('long_running_session')).toBe('long_term'); + expect(getWorkRailSection({ kind: 'topic' })).toBe('topic'); + expect(getWorkRailSection({ kind: 'recurring' })).toBe('recurring'); + }); + + it('routes system-managed recurring works to the system rail', () => { + expect(getWorkRailSection({ kind: 'recurring', systemManaged: true })).toBe('system'); + }); + + it('excludes system and recurring works from the Work Dock', () => { + expect(isDockEligibleWork({ kind: 'multi_step' })).toBe(true); + expect(isDockEligibleWork({ kind: 'app_workflow' })).toBe(true); + expect(isDockEligibleWork({ kind: 'recurring' })).toBe(false); + expect(isDockEligibleWork({ kind: 'recurring', systemManaged: true })).toBe(false); + expect(isDockEligibleWork({ kind: 'multi_step', systemManaged: true })).toBe(false); + }); +}); diff --git a/src/web-ui/src/app/agentic-os/work/domain/workClassification.ts b/src/web-ui/src/app/agentic-os/work/domain/workClassification.ts index da99dd9f..4cffe5cf 100644 --- a/src/web-ui/src/app/agentic-os/work/domain/workClassification.ts +++ b/src/web-ui/src/app/agentic-os/work/domain/workClassification.ts @@ -1,6 +1,14 @@ import type { WorkKind, WorkStatus } from './workTypes'; export type WorkCategory = 'immediate' | 'long_term' | 'recurring'; + +export type WorkRailSection = + | 'immediate' + | 'long_term' + | 'topic' + | 'recurring' + | 'system'; + export type WorkPriorityGroup = | 'needs_attention' | 'running' @@ -9,16 +17,27 @@ export type WorkPriorityGroup = | 'immediate' | 'done'; +/** Explicit continuity kinds only. App attachment (`app_workflow`) is immediate by default. */ const LONG_TERM_WORK_KINDS = new Set([ 'long_running_session', 'tracking', - 'topic', - 'app_workflow', ]); export function getWorkCategory(kind: WorkKind): WorkCategory { if (kind === 'recurring') return 'recurring'; - if (LONG_TERM_WORK_KINDS.has(kind)) return 'long_term'; + if (kind === 'topic' || LONG_TERM_WORK_KINDS.has(kind)) return 'long_term'; + // one_shot | multi_step | delegated_work | app_workflow → immediate + return 'immediate'; +} + +export function getWorkRailSection(input: { + kind: WorkKind; + systemManaged?: boolean; +}): WorkRailSection { + if (input.systemManaged) return 'system'; + if (input.kind === 'topic') return 'topic'; + if (input.kind === 'recurring') return 'recurring'; + if (LONG_TERM_WORK_KINDS.has(input.kind)) return 'long_term'; return 'immediate'; } @@ -56,6 +75,24 @@ export function isWorkTerminalStatus(status: WorkStatus): boolean { || status === 'archived'; } +export function isQueueEligibleWork(input: { + systemManaged?: boolean; + visibility?: string | null; +}): boolean { + return !input.systemManaged && input.visibility !== 'hidden'; +} + +/** Work Dock shows actionable user work only — not system matters or recurring cadence items. */ +export function isDockEligibleWork(input: { + kind: WorkKind; + systemManaged?: boolean; + visibility?: string | null; +}): boolean { + if (!isQueueEligibleWork(input)) return false; + if (input.kind === 'recurring') return false; + return true; +} + export function getWorkPriorityGroup(kind: WorkKind, status: WorkStatus): WorkPriorityGroup { if (isWorkAttentionStatus(status)) return 'needs_attention'; if (isWorkRunningStatus(status)) return 'running'; @@ -66,3 +103,13 @@ export function getWorkPriorityGroup(kind: WorkKind, status: WorkStatus): WorkPr if (category === 'long_term') return 'long_term'; return 'immediate'; } + +export const RECLASSIFY_KIND_OPTIONS: WorkKind[] = [ + 'multi_step', + 'tracking', + 'long_running_session', + 'topic', + 'recurring', + 'app_workflow', + 'one_shot', +]; diff --git a/src/web-ui/src/app/agentic-os/work/domain/workStatus.test.ts b/src/web-ui/src/app/agentic-os/work/domain/workStatus.test.ts index a65510ae..3309b0b7 100644 --- a/src/web-ui/src/app/agentic-os/work/domain/workStatus.test.ts +++ b/src/web-ui/src/app/agentic-os/work/domain/workStatus.test.ts @@ -38,6 +38,7 @@ function workWithBindings( runtimeInstances: [], artifactRefs: [], memoryRefs: [], + systemManaged: false, createdAt: 1, updatedAt: 1, }; @@ -81,4 +82,18 @@ describe('resolveEffectiveWorkStatus', () => { expect(workHasRunningExecution(work)).toBe(true); expect(resolveEffectiveWorkStatus(work)).toBe('running'); }); + + it('keeps system-managed running status without execution bindings', () => { + const work = { + ...workWithBindings('running', []), + kind: 'recurring' as const, + systemManaged: true, + systemProcessKind: 'daily_letter', + subject: { kind: 'goal' as const }, + primarySurface: { kind: 'work_center' as const, workId: 'sysbp_daily_letter' }, + }; + + expect(workHasRunningExecution(work)).toBe(false); + expect(resolveEffectiveWorkStatus(work)).toBe('running'); + }); }); diff --git a/src/web-ui/src/app/agentic-os/work/domain/workStatus.ts b/src/web-ui/src/app/agentic-os/work/domain/workStatus.ts index 194a3da6..b05838fc 100644 --- a/src/web-ui/src/app/agentic-os/work/domain/workStatus.ts +++ b/src/web-ui/src/app/agentic-os/work/domain/workStatus.ts @@ -15,7 +15,14 @@ export function workHasRunningExecution(work: WorkRecord): boolean { } export function resolveEffectiveWorkStatus(work: WorkRecord): WorkStatus { + // System matters sync status from BackgroundProcess and have no agent + // execution bindings. Trust the persisted status so running system jobs + // (daily letter, memory consolidation, etc.) stay visible in Running Work. + if (work.systemManaged) { + return work.status; + } if (workHasRunningExecution(work)) return 'running'; + // Stale running without a live binding: treat as idle/active for user work. if (work.status === 'running') return 'active'; return work.status; } diff --git a/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts b/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts index 90fd15bd..6ea5ca85 100644 --- a/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts +++ b/src/web-ui/src/app/agentic-os/work/domain/workTypes.ts @@ -402,10 +402,53 @@ export interface WorkRecord { runtimeInstances: RuntimeInstanceRef[]; artifactRefs: ArtifactRef[]; memoryRefs: MemoryRef[]; + systemManaged: boolean; + systemProcessKind?: string | null; + topicWorkId?: WorkId | null; createdAt: number; updatedAt: number; } +export interface WorkDeleteOptions { + cascadeChildWorks?: boolean; + deleteLinkedSessions?: boolean; +} + +export type WorkResourceOwnership = 'owned' | 'linked' | 'derived' | 'external'; +export type WorkCleanupAction = 'delete' | 'detach' | 'retain' | 'archive' | 'stop'; +export type WorkCleanupItemStatus = 'planned' | 'succeeded' | 'failed' | 'retained' | 'skipped'; + +export interface WorkCleanupResourceRef { + kind: string; + id: string; + ownership: WorkResourceOwnership; + metadata?: Record; +} + +export interface WorkCleanupItem { + id: string; + handlerId: string; + resource: WorkCleanupResourceRef; + action: WorkCleanupAction; + required: boolean; +} + +export interface WorkCleanupItemReport { + item: WorkCleanupItem; + status: WorkCleanupItemStatus; + message?: string | null; +} + +export interface WorkCleanupReport { + workId: string; + items: WorkCleanupItemReport[]; +} + +export interface WorkDeleteResult { + deleted: boolean; + cleanupReport: WorkCleanupReport; +} + export type PrimarySurfacePolicy = 'work_center' | 'work_session' | 'application_surface'; export interface CreateWorkRequest { @@ -420,6 +463,7 @@ export interface CreateWorkRequest { primarySurface?: WorkSurfaceRef | null; assignment?: WorkAssignmentRef | null; titleState?: WorkTitleState | null; + topicWorkId?: WorkId | null; } export interface StartWorkRequest { @@ -444,6 +488,10 @@ export interface UpdateWorkRequest { status?: WorkStatus; primarySurface?: WorkSurfaceRef; titleState?: WorkTitleState | null; + kind?: WorkKind; + topicWorkId?: WorkId | null; + clearTopicWorkId?: boolean; + visibility?: WorkVisibility; } export interface ResolveAppWorkRequest { diff --git a/src/web-ui/src/app/agentic-os/work/hooks/useScopedWorks.ts b/src/web-ui/src/app/agentic-os/work/hooks/useScopedWorks.ts index c8b921c5..398664fb 100644 --- a/src/web-ui/src/app/agentic-os/work/hooks/useScopedWorks.ts +++ b/src/web-ui/src/app/agentic-os/work/hooks/useScopedWorks.ts @@ -6,7 +6,8 @@ import { useWorks } from './useWorks'; import { filterWorkProjections } from '../data/workSelectors'; import type { WorkProjection } from '../projections/workProjection'; import { - getWorkCategory, + getWorkRailSection, + isQueueEligibleWork, isWorkAttentionStatus, isWorkArchivedStatus, isWorkCompletedStatus, @@ -32,22 +33,48 @@ export function useScopedWorks( const { projections } = useWorks(); return useMemo(() => { + // Running is the global live list: every running Work, including system matters. + const isGlobalRunningScope = scope.kind === 'running'; + const scoped = projections.filter((work) => { - if (scope.kind === 'open') return isWorkOpenStatus(work.status); - if (scope.kind === 'attention') return isWorkAttentionStatus(work.status); - if (scope.kind === 'running') return isWorkRunningStatus(work.status); - if (scope.kind === 'all' || scope.kind === 'workspaces') return isWorkUnarchivedStatus(work.status); - if (scope.kind === 'completed') return isWorkCompletedStatus(work.status); - if (scope.kind === 'archived') return isWorkArchivedStatus(work.status); + if (scope.kind === 'open') { + return isWorkOpenStatus(work.status) + && isQueueEligibleWork(work); + } + if (scope.kind === 'attention') { + return isWorkAttentionStatus(work.status) && isQueueEligibleWork(work); + } + if (scope.kind === 'running') { + return isWorkRunningStatus(work.status); + } + if (scope.kind === 'all' || scope.kind === 'workspaces') { + return isWorkUnarchivedStatus(work.status) && !work.systemManaged; + } + if (scope.kind === 'completed') { + return isWorkCompletedStatus(work.status) && !work.systemManaged; + } + if (scope.kind === 'archived') { + return isWorkArchivedStatus(work.status) && !work.systemManaged; + } + if (scope.kind === 'topic') { + return isWorkOpenStatus(work.status) && getWorkRailSection(work) === 'topic'; + } + if (scope.kind === 'system') { + return getWorkRailSection(work) === 'system'; + } if (scope.kind === 'category') { - return isWorkOpenStatus(work.status) && getWorkCategory(work.kind) === scope.category; + return isWorkOpenStatus(work.status) + && !work.systemManaged + && getWorkRailSection(work) === scope.category; } return true; }).filter((work) => { + if (isGlobalRunningScope) return true; if (workspaceFilter.kind === 'all') return true; const workspace = workspaces.find((item) => item.id === workspaceFilter.id); return Boolean(workspace && work.workspacePath === workspace.rootPath); }).filter((work) => { + if (isGlobalRunningScope) return true; if (appFilter.kind === 'all') return true; return work.appRefs.some((relation) => ( sameAppRef(relation.app, appFilter.app) diff --git a/src/web-ui/src/app/agentic-os/work/navigation/openWork.test.ts b/src/web-ui/src/app/agentic-os/work/navigation/openWork.test.ts index 0ef01ecf..08006778 100644 --- a/src/web-ui/src/app/agentic-os/work/navigation/openWork.test.ts +++ b/src/web-ui/src/app/agentic-os/work/navigation/openWork.test.ts @@ -16,7 +16,6 @@ vi.mock('@/app/scenes/apps/product-app-runtime/productAppRuntimeService', () => function resetWorkDockStore() { useWorkDockStore.setState({ workCenterScope: { kind: 'open' }, - workCenterView: 'work', workCenterWorkspaceFilter: { kind: 'all' }, workCenterAppFilter: { kind: 'all' }, workCenterGrouping: 'priority', diff --git a/src/web-ui/src/app/agentic-os/work/projections/workProjection.ts b/src/web-ui/src/app/agentic-os/work/projections/workProjection.ts index e182b38a..b8feefad 100644 --- a/src/web-ui/src/app/agentic-os/work/projections/workProjection.ts +++ b/src/web-ui/src/app/agentic-os/work/projections/workProjection.ts @@ -23,6 +23,10 @@ export interface WorkProjection { primarySurface: WorkSurfaceRef; surfaces?: WorkSurfaceRef[]; sessionId?: string; + systemManaged: boolean; + systemProcessKind?: string | null; + topicWorkId?: string | null; + visibility: WorkRecord['visibility']; updatedAt: number; } @@ -48,6 +52,10 @@ export function projectWork(work: WorkRecord): WorkProjection { primarySurface.kind === 'work_session' || primarySurface.kind === 'agent_session' ? primarySurface.sessionId : undefined, + systemManaged: Boolean(work.systemManaged), + systemProcessKind: work.systemProcessKind, + topicWorkId: work.topicWorkId, + visibility: work.visibility, updatedAt: work.updatedAt, }; } diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss index d1230f43..5272b402 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss @@ -1,239 +1,408 @@ /** - * About dialog styles. - * Uses design system Dialog for overlay and shell; only content layout here. + * About dialog — "ignition plaque" surface. + * Zone A brand stage (story) -> Zone B spec sheet (facts) -> Zone C link rail (index). */ -// Match settings content panel: no modal chrome border on this dialog only. .ds-dialog-overlay.sparo-about-dialog-overlay { padding: var(--ds-space-6) var(--ds-space-8); .ds-dialog { - border: none; + border: 1px solid var(--ds-color-border-base); border-radius: var(--ds-radius-xl); overflow: hidden; - background: var(--ds-color-bg-app); + background: var(--ds-color-bg-elevated); box-shadow: var(--ds-shadow-modal); + max-width: 440px; } .ds-dialog__header { border-bottom: none; } + + .ds-dialog__header-shell--close-only { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + z-index: var(--ds-z-local); + min-height: 0; + width: auto; + pointer-events: none; + + .ds-dialog__close { + pointer-events: auto; + } + } + + .sparo-about-dialog__dialog-body { + padding: 0; + } } @media (max-width: 480px) { .ds-dialog-overlay.sparo-about-dialog-overlay { padding: var(--ds-space-4) var(--ds-space-5); + + .ds-dialog { + max-width: none; + } } } -// ==================== Content area (inside Modal) ==================== +// ==================== Shell ==================== -.sparo-about-dialog__content { +.sparo-about-dialog { + position: relative; display: flex; flex-direction: column; - min-height: 400px; - max-height: min(680px, calc(100vh - 48px)); overflow: hidden; } -// ==================== Hero section ==================== +// ==================== Zone A — brand stage ==================== -.sparo-about-dialog__hero { - flex-shrink: 0; +.sparo-about-dialog__stage { + position: relative; display: flex; flex-direction: column; align-items: center; - padding: 32px 32px 24px; + padding: 40px 32px 32px; text-align: center; + background: var(--ds-color-bg-scene); +} + +// ---- Ignition mark ---- + +.sparo-about-dialog__mark { position: relative; - overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + margin-bottom: 18px; + padding: 0; + border: none; + background: none; + cursor: pointer; + animation: sparo-about-pop 260ms var(--ds-easing-standard, ease) both; + + &:focus-visible { + outline: 2px solid var(--ds-focus-ring); + outline-offset: 4px; + border-radius: var(--ds-radius-full, 50%); + } +} + +.sparo-about-dialog__logo { + width: 56px; + height: 56px; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; +} + +.sparo-about-dialog__mark-ring { + position: absolute; + inset: -4px; + border-radius: 50%; + border: 1.5px solid var(--ds-color-border-medium); + opacity: 0; +} + +.sparo-about-dialog__mark--active .sparo-about-dialog__mark-ring { + animation: sparo-mark-ring 480ms ease-out; } +// ---- Identity copy ---- + .sparo-about-dialog__title { + position: relative; + z-index: var(--ds-z-local); + margin: 0; font-family: var(--ds-font-family-sans); font-size: 28px; - font-weight: 800; - margin: 0 0 10px 0; - letter-spacing: 0; + font-weight: 700; + letter-spacing: -0.03em; + line-height: 1.15; color: var(--ds-color-text-primary); + animation: sparo-about-rise 200ms var(--ds-easing-standard, ease) 40ms both; +} + +.sparo-about-dialog__tagline { position: relative; z-index: var(--ds-z-local); - line-height: 1.2; + margin: 8px 0 0; + max-width: 280px; + font-size: 13px; + font-weight: 400; + line-height: 1.45; + letter-spacing: 0.01em; + color: var(--ds-color-text-secondary); + animation: sparo-about-rise 220ms var(--ds-easing-standard, ease) 70ms both; } .sparo-about-dialog__version-badge { - display: inline-flex; - align-items: center; - color: var(--ds-color-text-muted); - font-size: 11px; - font-weight: 400; - font-family: var(--ds-font-family-mono); - letter-spacing: 0.02em; position: relative; z-index: var(--ds-z-local); + margin-top: 16px; + font-family: var(--ds-font-family-mono); + font-size: 11px; + letter-spacing: 0.03em; + animation: sparo-about-rise 240ms var(--ds-easing-standard, ease) 90ms both; } -// Decorative divider -.sparo-about-dialog__divider { - width: 60px; - height: 1px; - background: var(--ds-color-border-subtle); - margin-top: 18px; +// ==================== Zone B — spec sheet ==================== + +.sparo-about-dialog__specsheet { position: relative; z-index: var(--ds-z-local); + margin: -18px 24px 0; + border-radius: var(--ds-radius-lg); + box-shadow: var(--ds-shadow-card); + animation: sparo-about-rise 260ms var(--ds-easing-standard, ease) 120ms both; } -// ==================== Scrollable area ==================== +.sparo-about-dialog__specsheet-body { + padding: 12px 16px; +} -.sparo-about-dialog__scrollable { - flex: 1; - min-height: 0; +.sparo-about-dialog__specsheet-header { display: flex; - flex-direction: column; - overflow-y: auto; - overflow-x: hidden; - // Inset rounded panel: side margins + rounded rectangle - margin: 0 20px; - padding: 12px 16px 16px; - border-radius: var(--ds-radius-lg); - background: var(--ds-color-bg-scene); + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; } -// ==================== Info section ==================== - -.sparo-about-dialog__info-section { - flex-shrink: 0; - // Vertically center within remaining scrollable height; if content overflows, margins yield and scrolling still works - margin-block: auto; +.sparo-about-dialog__specsheet-title { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ds-color-text-muted); } -.sparo-about-dialog__info-card { - background: transparent; - border: none; - border-radius: var(--ds-radius-md); +.sparo-about-dialog__specsheet-copy { + color: var(--ds-color-text-muted); } -.sparo-about-dialog__info-card-body { - padding: 10px 14px; +.sparo-about-dialog__specsheet-list { + margin: 0; } -.sparo-about-dialog__info-row { +.sparo-about-dialog__specsheet-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; - padding: 5px 0; - min-height: 28px; - + padding: 7px 0; + border-top: 1px solid var(--ds-color-border-subtle); + &:first-child { - padding-top: 0; + border-top: none; } - - &:last-child { - padding-bottom: 0; + + dt { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: 12px; + font-weight: 500; + color: var(--ds-color-text-secondary); + flex-shrink: 0; + + svg { + color: var(--ds-color-text-muted); + } + } + + dd { + margin: 0; + font-size: 12px; + font-weight: 400; + color: var(--ds-color-text-primary); + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } } -.sparo-about-dialog__info-label { - flex-shrink: 0; - font-size: 12px; - color: var(--ds-color-text-secondary); +.sparo-about-dialog__specsheet-value--mono { + font-family: var(--ds-font-family-mono); + font-size: 11px; font-weight: 500; + letter-spacing: 0.02em; } -.sparo-about-dialog__info-value { - font-size: 12px; - color: var(--ds-color-text-primary); - font-weight: 400; - text-align: right; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - &--mono { - font-family: var(--ds-font-family-mono); - font-size: 11px; - font-weight: 500; - } -} +// ==================== Zone C — link rail + footer ==================== -.sparo-about-dialog__info-value-group { +.sparo-about-dialog__links { + position: relative; + z-index: var(--ds-z-local); display: flex; align-items: center; - gap: 6px; - min-width: 0; + justify-content: center; + gap: 4px; + margin: 18px 0 0; + animation: sparo-about-rise 280ms var(--ds-easing-standard, ease) 140ms both; } -// ==================== Footer ==================== +.sparo-about-dialog__link-btn { + color: var(--ds-color-text-muted); + transition: color var(--ds-motion-fast, 0.15s) var(--ds-easing-standard, ease); + + &:hover { + color: var(--ds-color-accent-500); + } +} .sparo-about-dialog__footer { - flex-shrink: 0; - padding: 16px 28px 20px; - text-align: center; position: relative; + z-index: var(--ds-z-local); + margin-top: 18px; + padding: 0 28px 28px; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + animation: sparo-about-rise 300ms var(--ds-easing-standard, ease) 150ms both; } .sparo-about-dialog__license { + padding: 0; + border: none; + background: none; + font-family: inherit; font-size: 11px; + font-weight: 500; color: var(--ds-color-text-secondary); - line-height: 1.5; - margin: 0 0 6px 0; + text-decoration: none; + letter-spacing: 0.02em; + cursor: pointer; + transition: color var(--ds-motion-fast, 0.15s) var(--ds-easing-standard, ease); + + &:hover { + color: var(--ds-color-text-primary); + } + + &:focus-visible { + outline: 2px solid var(--ds-focus-ring); + outline-offset: 2px; + border-radius: var(--ds-radius-xs); + } } .sparo-about-dialog__copyright { - font-size: 10px; - color: var(--ds-color-text-muted); - opacity: 0.6; margin: 0; + font-size: 10px; letter-spacing: 0.02em; + color: var(--ds-color-text-muted); + opacity: 0.75; } -// ==================== Responsive layout ==================== +// ==================== Motion ==================== -@media (max-width: 580px) { - .sparo-about-dialog__content { - min-height: 360px; - max-height: calc(100vh - 32px); +@keyframes sparo-about-rise { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes sparo-about-pop { + from { + opacity: 0; + transform: scale(0.9); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes sparo-mark-ring { + 0% { + opacity: 0.6; + transform: scale(0.85); + } + + 100% { + opacity: 0; + transform: scale(1.15); } +} - .sparo-about-dialog__hero { - padding: 24px 20px 20px; +@media (prefers-reduced-motion: reduce) { + .sparo-about-dialog__mark, + .sparo-about-dialog__title, + .sparo-about-dialog__tagline, + .sparo-about-dialog__version-badge, + .sparo-about-dialog__specsheet, + .sparo-about-dialog__links, + .sparo-about-dialog__footer, + .sparo-about-dialog__mark-ring { + animation: none !important; + } +} + +// ==================== Responsive ==================== + +@media (max-width: 580px) { + .sparo-about-dialog__stage { + padding: 32px 24px 28px; } .sparo-about-dialog__title { - font-size: 20px; + font-size: 22px; + } + + .sparo-about-dialog__tagline { + max-width: 240px; + font-size: 12px; } - .sparo-about-dialog__scrollable { - margin: 0 12px; - padding: 12px 14px 14px; - border-radius: var(--ds-radius-md); + .sparo-about-dialog__specsheet { + margin: -14px 16px 0; } .sparo-about-dialog__footer { - padding: 14px 20px 16px; + padding: 0 20px 22px; } } -@media (max-height: 500px) { - .sparo-about-dialog__content { - min-height: 320px; +@media (max-height: 560px) { + .sparo-about-dialog__stage { + padding: 28px 24px 24px; } - .sparo-about-dialog__hero { - padding: 20px 24px 16px; + .sparo-about-dialog__mark { + width: 60px; + height: 60px; + margin-bottom: 12px; + } + + .sparo-about-dialog__logo { + width: 48px; + height: 48px; } .sparo-about-dialog__title { font-size: 20px; - margin-bottom: 8px; } - .sparo-about-dialog__divider { + .sparo-about-dialog__links { margin-top: 12px; } .sparo-about-dialog__footer { - padding: 12px 24px 14px; + margin-top: 12px; } } diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx index 1cbe06f2..f56dba22 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx @@ -1,13 +1,13 @@ /** - * About dialog component. - * Shows app version and license info. - * Uses the design-system Dialog primitive. + * About dialog — "ignition plaque" for Sparo OS. + * Three zones: brand stage (story), spec sheet (facts), link rail (index). */ -import React, { useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { useI18n } from '@/infrastructure/i18n'; +import { systemAPI } from '@/infrastructure/api'; import { Badge, Dialog, IconButton, Panel, PanelBody } from '@/design-system'; -import { Copy, Check } from 'lucide-react'; +import { Bug, BookOpen, Calendar, Check, Copy, Github, GitBranch, GitCommit } from 'lucide-react'; import { getAboutInfo, formatVersion, @@ -25,26 +25,67 @@ interface AboutDialogProps { onClose: () => void; } +/** Reset delay for the click-to-pulse mark interaction. */ +const MARK_PULSE_RESET_MS = 500; + export const AboutDialog: React.FC = ({ isOpen, onClose }) => { const { t } = useI18n('common'); - const [copiedItem, setCopiedItem] = useState(null); + const [copied, setCopied] = useState(false); + const [pulsing, setPulsing] = useState(false); + const pulseTimeoutRef = useRef | null>(null); const aboutInfo = getAboutInfo(); - const { version, license } = aboutInfo; + const { version, license, links } = aboutInfo; + const versionLabel = formatVersion(version.version, version.isDev); + const buildDateLabel = formatBuildDate(version.buildDate); + + const diagnosticParts = [ + `Sparo OS ${versionLabel}`, + buildDateLabel, + version.gitCommit, + version.gitBranch + ].filter(Boolean); + const diagnosticText = diagnosticParts.join(' · '); - const copyToClipboard = async (text: string, itemId: string) => { + const copyDiagnostics = async () => { try { - await navigator.clipboard.writeText(text); - setCopiedItem(itemId); - setTimeout(() => setCopiedItem(null), 2000); + await navigator.clipboard.writeText(diagnosticText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); } catch (err) { - log.error('Failed to copy to clipboard', err); + log.error('Failed to copy diagnostics', err); + } + }; + + const handleMarkClick = useCallback(() => { + // Restart the animation even if it is already playing. + setPulsing(false); + requestAnimationFrame(() => setPulsing(true)); + + if (pulseTimeoutRef.current) { + clearTimeout(pulseTimeoutRef.current); + } + pulseTimeoutRef.current = setTimeout(() => setPulsing(false), MARK_PULSE_RESET_MS); + }, []); + + const openLink = (href?: string) => { + if (!href) { + return; } + systemAPI.openExternal(href).catch((error) => { + log.error('Failed to open external link', { href, error }); + }); }; + const linkItems = [ + { key: 'repository', href: links.repository, icon: Github, label: t('about.links.repository') }, + { key: 'documentation', href: links.documentation, icon: BookOpen, label: t('about.links.documentation') }, + { key: 'issues', href: links.issues, icon: Bug, label: t('about.links.issues') } + ].filter((item): item is { key: string; href: string; icon: typeof Github; label: string } => Boolean(item.href)); + return ( = ({ }} showCloseButton={true} size="medium" + ariaLabel={t('about.productTitle')} + closeLabel={t('about.close')} overlayClassName="sparo-about-dialog-overlay" + contentClassName="sparo-about-dialog__dialog-body" > -
- {/* Hero section - product info */} -
+
+ {/* Zone A — brand stage */} +
+ +

{t('about.productTitle')}

- - {t('about.version', { version: formatVersion(version.version, version.isDev) })} +

{t('about.tagline')}

+ + {t('about.version', { version: versionLabel })} -
- {/* Scrollable area */} -
-
- - -
- {t('about.buildDate')} - - {formatBuildDate(version.buildDate)} - + {/* Zone B — spec sheet */} + + +
+ {t('about.specSheetTitle')} + + {copied ? : } + +
+ +
+
+
+
+
{buildDateLabel}
+
+ {version.gitCommit && ( +
+
+
+
+ {version.gitCommit.slice(0, 7)} +
+ )} + {version.gitBranch && ( +
+
+
+
{version.gitBranch}
+
+ )} +
+
+
- {version.gitCommit && ( -
- {t('about.commit')} -
- - {version.gitCommit} - - copyToClipboard(version.gitCommit || '', 'commit')} - > - {copiedItem === 'commit' ? : } - -
-
- )} - - {version.gitBranch && ( -
- {t('about.branch')} - {version.gitBranch} -
- )} - - + {/* Zone C — link rail + footer */} + {linkItems.length > 0 && ( +
+ {linkItems.map(({ key, href, icon: Icon, label }) => ( + openLink(href)} + > + + ))}
-
+ )} - {/* Footer */} -
-

{license.text}

-

- {t('about.copyright')} -

-
+
+ +

{t('about.copyright')}

+
); diff --git a/src/web-ui/src/app/components/WorkDock/NewWorkDialog.scss b/src/web-ui/src/app/components/WorkDock/NewWorkDialog.scss index 1cd1ec92..e55d650a 100644 --- a/src/web-ui/src/app/components/WorkDock/NewWorkDialog.scss +++ b/src/web-ui/src/app/components/WorkDock/NewWorkDialog.scss @@ -295,6 +295,46 @@ line-height: 1.4; } +.new-work-dialog__classify { + display: flex; + flex-wrap: wrap; + gap: var(--ds-space-2); +} + +.new-work-dialog__classify-option { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 var(--ds-space-3); + border: 1px solid var(--ds-color-border-subtle); + border-radius: var(--ds-radius-sm); + background: transparent; + color: var(--ds-color-text-secondary); + font-size: var(--ds-font-size-xs); + line-height: 1.2; + cursor: pointer; + transition: + border-color var(--ds-motion-duration-fast) var(--ds-easing-standard), + background-color var(--ds-motion-duration-fast) var(--ds-easing-standard), + color var(--ds-motion-duration-fast) var(--ds-easing-standard); + + &:hover { + border-color: var(--ds-color-border-medium); + color: var(--ds-color-text-primary); + } + + &.is-selected { + border-color: color-mix(in srgb, var(--ds-color-accent-500) 45%, var(--ds-color-border-subtle)); + background: color-mix(in srgb, var(--ds-color-accent-500) 10%, transparent); + color: var(--ds-color-text-primary); + } + + &:focus-visible { + outline: 2px solid var(--ds-focus-ring-subtle); + outline-offset: 2px; + } +} + .new-work-dialog__divider { flex-shrink: 0; height: 1px; diff --git a/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx b/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx index 9abd6448..03d9cd7d 100644 --- a/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx +++ b/src/web-ui/src/app/components/WorkDock/NewWorkDialog.tsx @@ -29,7 +29,7 @@ import { useSessionModeStore } from '@/app/stores/sessionModeStore'; import type { SessionMode } from '@/app/stores/sessionModeStore'; import { useWorkStore } from '@/app/agentic-os/work/data/workStore'; import { openWork } from '@/app/agentic-os/work/navigation/openWork'; -import type { WorkAppRef, WorkRecord } from '@/app/agentic-os/work/domain/workTypes'; +import type { WorkAppRef, WorkKind, WorkRecord, WorkTitleState } from '@/app/agentic-os/work/domain/workTypes'; import { nativeAppWorkRef, productAppWorkRef } from '@/app/agentic-os/work/domain/productAppRefs'; import type { WorkspaceInfo } from '@/shared/types'; import { notificationService } from '@/shared/notification-system'; @@ -51,6 +51,11 @@ const NATIVE_AGENT_APP_IDS: Record = { type NewWorkStartMode = 'manual' | 'agentic-os'; +export type NewWorkClassifyKind = Extract< + WorkKind, + 'multi_step' | 'topic' | 'tracking' | 'recurring' +>; + export type NewWorkAgentChoice = | 'OSAgent' | 'Runno' @@ -161,10 +166,22 @@ export async function launchWorkForChoice(params: { rememberWorkspace: (workspaceId: string) => Promise; title?: string; objective?: string; + titleState?: WorkTitleState | null; appRef?: WorkAppRef; workResolutionMode?: ProductAppWorkResolutionMode; + classifyKind?: NewWorkClassifyKind; }): Promise { - const { agentChoice, workspace, rememberWorkspace, title, objective, appRef, workResolutionMode } = params; + const { + agentChoice, + workspace, + rememberWorkspace, + title, + objective, + titleState, + appRef, + workResolutionMode, + classifyKind = 'multi_step', + } = params; const productAppId = parseProductAppWorkChoice(agentChoice); let resolvedAgentChoice = agentChoice; let resolvedAppRef = appRef; @@ -219,9 +236,7 @@ export async function launchWorkForChoice(params: { scope: resolvedWorkScope, visibility: 'primary', primarySurfacePolicy: 'application_surface', - titleState: title?.trim() - ? { source: 'user', locked: true } - : { source: 'application_surface', locked: false, subjectRef: targetProductAppId }, + titleState: titleState ?? { source: 'application_surface', locked: false, subjectRef: targetProductAppId }, assignment, }) : (await workStore.resolveAppWork({ @@ -283,7 +298,7 @@ export async function launchWorkForChoice(params: { } const work = await useWorkStore.getState().createWork({ - kind: resolvedAppRef ? 'app_workflow' : 'multi_step', + kind: productAppId ? 'app_workflow' : classifyKind, title: workTitle, objective: workObjective, subject: resolvedAppRef @@ -293,9 +308,7 @@ export async function launchWorkForChoice(params: { scope: resolvedWorkScope, visibility: 'primary', primarySurfacePolicy: 'work_session', - titleState: title?.trim() - ? { source: 'user', locked: true } - : { source: 'template', locked: false }, + titleState: titleState ?? { source: 'template', locked: false }, assignment: { kind: 'agent', agentType: backendAgentType, @@ -326,6 +339,7 @@ export const NewWorkDialog: React.FC = ({ const [agentChoice, setAgentChoice] = useState('Runno'); const [startMode, setStartMode] = useState('manual'); + const [classifyKind, setClassifyKind] = useState('multi_step'); const [workspaceId, setWorkspaceId] = useState(null); const [browsedWorkspacePath, setBrowsedWorkspacePath] = useState(null); const [objective, setObjective] = useState(''); @@ -380,6 +394,7 @@ export const NewWorkDialog: React.FC = ({ setAgentChoice(normalizedInitialChoice ?? storedAgent ?? 'Runno'); setStartMode('manual'); + setClassifyKind('multi_step'); setBrowsedWorkspacePath(null); setObjective(''); setWorkspaceId( @@ -529,6 +544,16 @@ export const NewWorkDialog: React.FC = ({ title: t('nav.workDock.modeAgenticOs'), }, ], [t]); + const classifyOptions = useMemo>(() => [ + { value: 'multi_step', label: t('newWork.classify.immediate') }, + { value: 'topic', label: t('newWork.classify.topic') }, + { value: 'tracking', label: t('newWork.classify.tracking') }, + { value: 'recurring', label: t('newWork.classify.recurring') }, + ], [t]); + const showClassifyControls = startMode === 'manual' && !selectedProductAppId; const modeLede = startMode === 'manual' ? t('nav.workDock.modeManualLede') : t('nav.workDock.modeAgenticOsLede'); @@ -673,6 +698,7 @@ export const NewWorkDialog: React.FC = ({ agentChoice, workspace, rememberWorkspace, + classifyKind: selectedProductAppId ? undefined : classifyKind, }); try { @@ -703,11 +729,13 @@ export const NewWorkDialog: React.FC = ({ }, [ agentChoice, browsedWorkspacePath, + classifyKind, objective, onClose, openWorkspace, openedWorkspacesList, rememberWorkspace, + selectedProductAppId, startMode, t, workspaceId, @@ -872,6 +900,44 @@ export const NewWorkDialog: React.FC = ({ })}

+ + {showClassifyControls ? ( + <> +
+ +
+
+ + 03 + +

+ {t('newWork.classify.label')} +

+
+
+ {classifyOptions.map((option) => { + const selected = classifyKind === option.value; + return ( + + ); + })} +
+
+ + ) : null} )}
diff --git a/src/web-ui/src/app/components/WorkDock/WorkDock.tsx b/src/web-ui/src/app/components/WorkDock/WorkDock.tsx index 46c339e4..61df4a9a 100644 --- a/src/web-ui/src/app/components/WorkDock/WorkDock.tsx +++ b/src/web-ui/src/app/components/WorkDock/WorkDock.tsx @@ -7,7 +7,11 @@ import { useWorkStore } from '@/app/agentic-os/work/data/workStore'; import { openWork, openWorkCenterHome, openWorkInCenter } from '@/app/agentic-os/work/navigation/openWork'; import type { WorkStatus } from '@/app/agentic-os/work/domain/workTypes'; import type { WorkProjection } from '@/app/agentic-os/work/projections/workProjection'; -import { isWorkAttentionStatus, isWorkRunningStatus } from '@/app/agentic-os/work/domain/workClassification'; +import { + isDockEligibleWork, + isWorkAttentionStatus, + isWorkRunningStatus, +} from '@/app/agentic-os/work/domain/workClassification'; import { useWorkDockStore } from '@/app/stores/workDockStore'; import { useWorkspaceSurfaceStore } from '@/app/navigation/workspaceSurfaceStore'; import { notificationService } from '@/shared/notification-system'; @@ -116,11 +120,13 @@ const WorkDock: React.FC = () => { const workById = useMemo(() => new Map(works.map((work) => [work.id, work])), [works]); const runningWorks = useMemo( - () => projections.filter((work) => isDockVisibleStatus(work.status)).slice(0, RUNNING_WORK_COLLAPSED_LIMIT), + () => projections + .filter((work) => isDockEligibleWork(work) && isDockVisibleStatus(work.status)) + .slice(0, RUNNING_WORK_COLLAPSED_LIMIT), [projections] ); const runningCount = useMemo( - () => projections.filter((work) => isDockVisibleStatus(work.status)).length, + () => projections.filter((work) => isDockEligibleWork(work) && isDockVisibleStatus(work.status)).length, [projections] ); diff --git a/src/web-ui/src/app/components/WorkList/workListSelection.ts b/src/web-ui/src/app/components/WorkList/workListSelection.ts index 095bcefa..ce0db5eb 100644 --- a/src/web-ui/src/app/components/WorkList/workListSelection.ts +++ b/src/web-ui/src/app/components/WorkList/workListSelection.ts @@ -1,6 +1,10 @@ import { AppWindow, Brush, Clock3, Code2, ListChecks, ListTodo, MessageSquare, Sparkles } from 'lucide-react'; import { filterWorkProjections } from '@/app/agentic-os/work/data/workSelectors'; -import { isWorkAttentionStatus, isWorkRunningStatus } from '@/app/agentic-os/work/domain/workClassification'; +import { + isDockEligibleWork, + isWorkAttentionStatus, + isWorkRunningStatus, +} from '@/app/agentic-os/work/domain/workClassification'; import type { WorkKind, WorkStatus } from '@/app/agentic-os/work/domain/workTypes'; import type { WorkProjection } from '@/app/agentic-os/work/projections/workProjection'; @@ -98,13 +102,12 @@ function kindContinuityPriority(kind: WorkKind): number { case 'tracking': case 'topic': return 1; - case 'app_workflow': - return 2; case 'multi_step': case 'delegated_work': - return 3; + case 'app_workflow': + return 2; case 'one_shot': - return 4; + return 3; } } @@ -129,6 +132,7 @@ export function selectWorksForDockList( }: WorkListSelectionOptions = {} ): WorkProjection[] { const filtered = filterWorkProjections(projections, query) + .filter((work) => isDockEligibleWork(work)) .filter((work) => { const running = isFocusStatus(work.status); if (runningFilter === 'running') return running; diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.scss b/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.scss index 05cdd867..65bc89ff 100644 --- a/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.scss +++ b/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.scss @@ -272,6 +272,23 @@ } } +.sparo-workspace-footer__icon-with-dot { + position: relative; + display: inline-flex; + flex-shrink: 0; +} + +.sparo-workspace-footer__unread-dot { + position: absolute; + top: -2px; + right: -3px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ds-color-accent); + box-shadow: 0 0 0 1.5px var(--ds-color-bg-elevated); +} + .sparo-workspace-footer__action-label { min-width: 0; overflow: hidden; diff --git a/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.tsx b/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.tsx index 744ce7ba..753520fd 100644 --- a/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.tsx +++ b/src/web-ui/src/app/components/WorkspaceFooterActions/WorkspaceFooterActions.tsx @@ -30,6 +30,7 @@ import { projectRuntimeScopeFromWorkspace, systemRuntimeScope, } from '@/shared/types/runtime-scope'; +import { useDailyLetterArrivalStore } from '@/app/daily-letter-arrival/store/dailyLetterArrivalStore'; import './WorkspaceFooterActions.scss'; const log = createLogger('WorkspaceFooterActions'); @@ -96,6 +97,8 @@ const WorkspaceFooterActions: React.FC = () => { return t(`welcome.${key}`); }, [t]); + const hasUnreadDailyLetter = useDailyLetterArrivalStore((s) => s.hasUnread); + const isMemoryActive = activeSceneId === 'memory'; const isWorkCenterActive = activeSceneId === 'work-center'; const isDailyLetterActive = activeSceneId === 'daily-letter'; @@ -333,7 +336,14 @@ const WorkspaceFooterActions: React.FC = () => { } + icon={( + + + {hasUnreadDailyLetter && ( + + )} movingHoverHandlers={menuHover.getItemHandlers()} testId="workspace-footer-daily-letter-button" onClick={handleOpenDailyLetter} diff --git a/src/web-ui/src/app/daily-letter-arrival/components/DailyLetterArrivalDock.scss b/src/web-ui/src/app/daily-letter-arrival/components/DailyLetterArrivalDock.scss new file mode 100644 index 00000000..73c37743 --- /dev/null +++ b/src/web-ui/src/app/daily-letter-arrival/components/DailyLetterArrivalDock.scss @@ -0,0 +1,251 @@ +@use '@/design-system/foundation/tokens/tokens.scss' as *; + +$dla-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, 'Noto Serif CJK SC', serif; +$dla-width: 296px; + +// Reserve the corner for the letter first. AnnouncementToast reads this +// variable to make room above the dock instead of overlapping it — see +// the `bottom: calc(64px + var(--sparo-letter-dock-offset, 0px))` rule +// in AnnouncementToast.scss. +:root { + --sparo-letter-dock-offset: 0px; +} + +:root[data-daily-letter-dock='is-card'] { + --sparo-letter-dock-offset: 200px; +} + +:root[data-daily-letter-dock='is-chip'] { + --sparo-letter-dock-offset: 64px; +} + +.dla-dock { + position: fixed; + bottom: 64px; + left: 20px; + z-index: var(--ds-z-notification); + pointer-events: none; + + > * { + pointer-events: auto; + } +} + +// ─── Envelope card (arrival / writing) ─────────────────────────────────────── +.dla-envelope { + position: relative; + width: $dla-width; + padding: $size-gap-4 $size-gap-4 $size-gap-4; + border: 1px solid color-mix(in srgb, var(--ds-color-border-strong) 55%, var(--ds-color-border-subtle)); + border-radius: $size-radius-lg; + // Same "lifted paper" longhand as the full letter sheet, so the corner + // card and the letter it opens into feel like the same material. + background-color: var(--ds-color-bg-scene); + background-image: + linear-gradient(178deg, color-mix(in srgb, var(--ds-color-accent) 6%, transparent) 0%, transparent 45%), + linear-gradient(var(--ds-color-bg-elevated), var(--ds-color-bg-elevated)); + box-shadow: 0 8px 24px var(--ds-shadow-color-card), 0 2px 6px var(--ds-shadow-color-soft); + animation: dla-drop-in 420ms cubic-bezier(0.34, 1.56, 0.64, 1) both; + transition: transform 0.18s $easing-ease-out, opacity 0.18s $easing-ease-out; + + &.is-opening { + animation: dla-envelope-open 260ms $easing-ease-out both; + } +} + +// Postage stamp: hangs slightly off the top edge, dashed like a real +// perforation, and never intercepts clicks. +.dla-envelope__stamp { + position: absolute; + top: -9px; + right: 22px; + width: 26px; + height: 32px; + border: 1.5px dashed color-mix(in srgb, var(--ds-color-accent) 55%, transparent); + border-radius: 3px; + background: color-mix(in srgb, var(--ds-color-accent) 12%, var(--ds-color-bg-elevated)); + transform: rotate(-5deg); + pointer-events: none; + + &::after { + content: ''; + position: absolute; + inset: 4px; + border: 1px solid color-mix(in srgb, var(--ds-color-accent) 35%, transparent); + border-radius: 2px; + } +} + +.dla-envelope__close { + position: absolute; + top: $size-gap-2; + right: $size-gap-2; + + svg { + width: 12px; + height: 12px; + } +} + +.dla-envelope__body { + display: flex; + flex-direction: column; + gap: $size-gap-2; + padding-right: 22px; +} + +.dla-envelope__title-row { + display: flex; + align-items: center; + gap: $size-gap-2; +} + +.dla-envelope__icon-badge { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + color: color-mix(in srgb, var(--ds-color-accent) 85%, var(--ds-color-text-secondary)); + background: color-mix(in srgb, var(--ds-color-accent) 12%, transparent); + animation: dla-seal-breathe 3.2s ease-in-out infinite; +} + +.dla-envelope__title { + min-width: 0; + color: var(--ds-color-text-primary); + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + letter-spacing: -0.01em; +} + +.dla-envelope__desc { + margin: 0; + padding-left: 30px; + color: var(--ds-color-text-secondary); + font-family: $dla-serif; + font-size: 13px; + line-height: 1.6; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.dla-envelope__note { + padding-left: 30px; + color: var(--ds-color-text-warning); + font-size: $font-size-xs; +} + +.dla-envelope__action { + align-self: flex-end; + margin-top: $size-gap-1; +} + +// ─── Wax-seal chip (collapsed state) ───────────────────────────────────────── +.dla-chip-wrap { + position: relative; + width: fit-content; + + // The close affordance only appears on hover/focus — the chip itself + // stays quiet and uncluttered the rest of the time. + .dla-chip-wrap__close { + opacity: 0; + transform: scale(0.7); + transition: opacity 0.15s $easing-ease-out, transform 0.15s $easing-ease-out; + } + + &:hover .dla-chip-wrap__close, + &:focus-within .dla-chip-wrap__close { + opacity: 1; + transform: scale(1); + } +} + +.dla-chip { + color: color-mix(in srgb, var(--ds-color-accent) 85%, var(--ds-color-text-secondary)); + background: color-mix(in srgb, var(--ds-color-accent) 14%, var(--ds-color-bg-elevated)); + border: 1.5px solid color-mix(in srgb, var(--ds-color-accent) 45%, transparent); + box-shadow: 0 6px 18px var(--ds-shadow-color-card); + animation: dla-drop-in 320ms cubic-bezier(0.34, 1.56, 0.64, 1) both, dla-seal-breathe 3.6s ease-in-out infinite; +} + +.dla-chip-wrap__close { + position: absolute; + top: -6px; + right: -6px; + background: var(--ds-color-bg-elevated); + box-shadow: 0 2px 6px var(--ds-shadow-color-soft); + + svg { + width: 11px; + height: 11px; + } +} + +@keyframes dla-drop-in { + from { + opacity: 0; + transform: translate(46px, 34px) rotate(9deg) scale(0.9); + } + + 60% { + opacity: 1; + transform: translate(-4px, -3px) rotate(-1.5deg) scale(1.02); + } + + to { + opacity: 1; + transform: translate(0, 0) rotate(0deg) scale(1); + } +} + +@keyframes dla-envelope-open { + from { + opacity: 1; + transform: perspective(600px) rotateX(0deg) scale(1); + } + + to { + opacity: 0; + transform: perspective(600px) rotateX(-45deg) scale(0.94); + } +} + +@keyframes dla-seal-breathe { + 0%, + 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--ds-color-accent) 0%, transparent); + } + + 50% { + box-shadow: 0 0 0 5px color-mix(in srgb, var(--ds-color-accent) 14%, transparent); + } +} + +@media (prefers-reduced-motion: reduce) { + .dla-envelope, + .dla-chip, + .dla-envelope__icon-badge { + animation: none; + } + + .dla-envelope.is-opening { + animation: none; + opacity: 0; + } +} + +@media (max-width: 520px) { + .dla-dock { + left: $size-gap-3; + right: $size-gap-3; + } + + .dla-envelope { + width: auto; + } +} diff --git a/src/web-ui/src/app/daily-letter-arrival/components/DailyLetterArrivalDock.tsx b/src/web-ui/src/app/daily-letter-arrival/components/DailyLetterArrivalDock.tsx new file mode 100644 index 00000000..44cdb312 --- /dev/null +++ b/src/web-ui/src/app/daily-letter-arrival/components/DailyLetterArrivalDock.tsx @@ -0,0 +1,204 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Feather, Mail, X } from 'lucide-react'; +import { Button, IconButton } from '@/design-system'; +import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; +import { createLogger } from '@/shared/utils/logger'; +import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { useLastUsedWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { dailyLetterApi } from '@/app/scenes/daily-letter/dailyLetterApi'; +import { LetterPaper } from '@/app/scenes/daily-letter/LetterPaper'; +import { markDailyLetterAcknowledged, useDailyLetterArrivalStore } from '../store/dailyLetterArrivalStore'; +import './DailyLetterArrivalDock.scss'; + +const log = createLogger('DailyLetterArrivalDock'); +const POLL_INTERVAL_MS = 20_000; +const OPENING_TRANSITION_MS = 260; + +function pendingReceiptCountOf(letter: { receiptCandidates: { status: string }[] } | null): number { + if (!letter) return 0; + return letter.receiptCandidates.filter((candidate) => candidate.status === 'pending').length; +} + +const DailyLetterArrivalDock: React.FC = () => { + const { t, formatDate } = useI18n('scenes/daily-letter'); + const { workspacePath } = useLastUsedWorkspace(); + + const phase = useDailyLetterArrivalStore((s) => s.phase); + const letter = useDailyLetterArrivalStore((s) => s.letter); + const pendingReceiptCount = useDailyLetterArrivalStore((s) => s.pendingReceiptCount); + const paperOpen = useDailyLetterArrivalStore((s) => s.paperOpen); + const tick = useDailyLetterArrivalStore((s) => s.tick); + const expand = useDailyLetterArrivalStore((s) => s.expand); + const dismiss = useDailyLetterArrivalStore((s) => s.dismiss); + const openLetter = useDailyLetterArrivalStore((s) => s.openLetter); + const closePaper = useDailyLetterArrivalStore((s) => s.closePaper); + const suspendAutoCollapse = useDailyLetterArrivalStore((s) => s.suspendAutoCollapse); + const resumeAutoCollapse = useDailyLetterArrivalStore((s) => s.resumeAutoCollapse); + + const [enabled, setEnabled] = useState(true); + const [opening, setOpening] = useState(false); + const openingTimerRef = useRef | null>(null); + + useEffect(() => { + let cancelled = false; + void aiExperienceConfigService.getSettingsAsync().then((settings) => { + if (!cancelled) setEnabled(settings.enable_daily_letter); + }); + return aiExperienceConfigService.addChangeListener((settings) => { + setEnabled(settings.enable_daily_letter); + }); + }, []); + + useEffect(() => { + if (!enabled) return undefined; + void tick(workspacePath || null); + const id = window.setInterval(() => { + void tick(workspacePath || null); + }, POLL_INTERVAL_MS); + return () => window.clearInterval(id); + }, [enabled, tick, workspacePath]); + + useEffect(() => () => { + if (openingTimerRef.current) clearTimeout(openingTimerRef.current); + }, []); + + const handleOpen = useCallback(() => { + suspendAutoCollapse(); + setOpening(true); + openingTimerRef.current = setTimeout(() => { + setOpening(false); + openLetter(); + }, OPENING_TRANSITION_MS); + }, [openLetter, suspendAutoCollapse]); + + const handleSeal = useCallback(async () => { + if (!letter) return; + try { + const updated = await dailyLetterApi.seal({ + recordId: letter.id, + workspacePath: letter.workspace?.path ?? null, + }); + useDailyLetterArrivalStore.setState({ + letter: updated, + pendingReceiptCount: pendingReceiptCountOf(updated), + }); + } catch (error) { + log.error('Failed to seal daily letter from arrival dock', { error }); + } + }, [letter]); + + const canSeal = Boolean(letter && pendingReceiptCount === 0 && letter.status !== 'sealed'); + + // The corner card/chip always plays, even while the Daily Letter scene + // itself is open and focused — the scene no longer pops its own full-text + // dialog on generation, so this is the one consistent "letter arrived" + // ceremony for every trigger (auto or manual). + const dockOffsetClass = phase === 'card' + ? 'is-card' + : phase === 'chip' + ? 'is-chip' + : ''; + + useEffect(() => { + document.documentElement.setAttribute('data-daily-letter-dock', dockOffsetClass || 'none'); + return () => { + document.documentElement.removeAttribute('data-daily-letter-dock'); + }; + }, [dockOffsetClass]); + + if (!enabled) return null; + + return ( + <> + {phase === 'card' && letter && !paperOpen && ( +
+
+
+
+ )} + + {phase === 'chip' && letter && !paperOpen && ( +
+
+ + + + + +
+
+ )} + + { + if (!next) closePaper(); + }} + pendingCount={pendingReceiptCount} + canSeal={canSeal} + onSeal={() => void handleSeal()} + formatDate={formatDate} + t={t} + originCorner + onFirstOpen={markDailyLetterAcknowledged} + /> + + ); +}; + +export default DailyLetterArrivalDock; diff --git a/src/web-ui/src/app/daily-letter-arrival/store/dailyLetterArrivalStore.ts b/src/web-ui/src/app/daily-letter-arrival/store/dailyLetterArrivalStore.ts new file mode 100644 index 00000000..a6cc3713 --- /dev/null +++ b/src/web-ui/src/app/daily-letter-arrival/store/dailyLetterArrivalStore.ts @@ -0,0 +1,288 @@ +import { create } from 'zustand'; +import { createLogger } from '@/shared/utils/logger'; +import { dailyLetterApi } from '@/app/scenes/daily-letter/dailyLetterApi'; +import type { DailyLetterRecord } from '@/app/scenes/daily-letter/dailyLetterTypes'; + +const log = createLogger('DailyLetterArrivalStore'); + +const STORAGE_KEY = 'sparo.dailyLetterArrival.v1'; +const CHIP_COLLAPSE_MS = 15_000; + +export type DailyLetterArrivalPhase = 'hidden' | 'card' | 'chip'; + +interface PersistedArrivalState { + /** Highest `lastAttemptFinishedAtMs` already processed by the poller, of any outcome. */ + seenFinishedAtMs: number; + /** + * Identity of the most recent record the dock has surfaced (arrived), for + * the footer unread dot. Keyed on `${recordId}:${updatedAtMs}` rather than + * just the record id — a manual re-generation reuses the same record id + * for the same date/scope, but produces a new `updatedAtMs`. Keying on the + * pair means re-generating an already-read letter is treated as a fresh + * arrival instead of being silently swallowed as "already acknowledged". + */ + lastArrivedKey: string | null; + /** 'unseen' right after arrival, 'dismissed' once folded away unread, 'read' once opened. */ + lastArrivedState: 'unseen' | 'dismissed' | 'read'; +} + +function arrivalKeyOf(record: DailyLetterRecord): string { + return `${record.id}:${record.updatedAtMs}`; +} + +function loadPersisted(): PersistedArrivalState { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) throw new Error('no-value'); + const parsed = JSON.parse(raw) as Partial & { lastArrivedRecordId?: string | null }; + return { + seenFinishedAtMs: parsed.seenFinishedAtMs ?? 0, + // `lastArrivedRecordId` is the pre-migration field name; drop it rather + // than mapping it in, so a letter re-generated across the migration + // is treated as unseen instead of possibly matching by id alone. + lastArrivedKey: parsed.lastArrivedKey ?? null, + lastArrivedState: parsed.lastArrivedState ?? 'read', + }; + } catch { + return { seenFinishedAtMs: 0, lastArrivedKey: null, lastArrivedState: 'read' }; + } +} + +function savePersisted(state: PersistedArrivalState) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // Best effort only — a lost arrival marker just means the letter may + // resurface once more, which is harmless. + } +} + +interface DailyLetterArrivalStoreState { + phase: DailyLetterArrivalPhase; + letter: DailyLetterRecord | null; + pendingReceiptCount: number; + hasUnread: boolean; + paperOpen: boolean; + + /** Internal: guards concurrent polling ticks. */ + _polling: boolean; + _chipTimer: ReturnType | null; + + tick: (workspacePath: string | null) => Promise; + expand: () => void; + collapseToChip: () => void; + dismiss: () => void; + openLetter: () => void; + closePaper: () => void; + suspendAutoCollapse: () => void; + resumeAutoCollapse: () => void; +} + +function pendingReceiptCountOf(letter: DailyLetterRecord | null): number { + if (!letter) return 0; + return letter.receiptCandidates.filter((candidate) => candidate.status === 'pending').length; +} + +async function findRecordForDate( + date: string, + workspacePath: string | null, +): Promise { + try { + const requests = [dailyLetterApi.list({ scope: 'agentic_os', limit: 8 })]; + if (workspacePath) { + requests.push(dailyLetterApi.list({ scope: 'workspace', workspacePath, limit: 8 })); + } + const results = await Promise.all(requests); + const candidates = results.flat().filter((record) => record.date === date); + if (!candidates.length) return null; + return candidates.find((record) => record.scope === 'workspace') ?? candidates[0]; + } catch (error) { + log.warn('Failed to resolve arrived daily letter record', { date, error }); + return null; + } +} + +let persisted = loadPersisted(); + +export const useDailyLetterArrivalStore = create((set, get) => ({ + phase: 'hidden', + letter: null, + pendingReceiptCount: 0, + hasUnread: persisted.lastArrivedState !== 'read' && Boolean(persisted.lastArrivedKey), + paperOpen: false, + _polling: false, + _chipTimer: null, + + tick: async (workspacePath) => { + if (get()._polling) return; + set({ _polling: true }); + try { + const state = await dailyLetterApi.state(); + + // Writing in progress is intentionally silent here — the corner is + // reserved for the moment the letter actually arrives, not the + // composing process. (The Daily Letter scene still shows its own + // "writing" chip when it happens to be open.) + if (state.lastAttemptStatus === 'running') { + return; + } + + const finishedAt = state.lastAttemptFinishedAtMs ?? null; + if (!finishedAt || finishedAt <= persisted.seenFinishedAtMs) { + return; + } + + persisted = { ...persisted, seenFinishedAtMs: finishedAt }; + savePersisted(persisted); + + if (state.lastAttemptStatus !== 'ok' || !state.lastCompletedDate) { + return; + } + + const record = await findRecordForDate(state.lastCompletedDate, workspacePath); + if (!record) return; + + surfaceArrivedLetter(record); + } catch (error) { + log.warn('Failed to poll daily letter arrival state', { error }); + } finally { + set({ _polling: false }); + } + }, + + expand: () => { + if (get().letter) { + set({ phase: 'card' }); + get().resumeAutoCollapse(); + } + }, + + collapseToChip: () => { + if (get().letter && get().phase === 'card') { + set({ phase: 'chip' }); + } + }, + + dismiss: () => { + const { letter } = get(); + get().suspendAutoCollapse(); + if (letter && persisted.lastArrivedKey === arrivalKeyOf(letter)) { + persisted = { ...persisted, lastArrivedState: 'dismissed' }; + savePersisted(persisted); + } + // Terminal for this arrival: drop the letter reference too, so no + // leftover hover/collapse timer or stale callback can act on it and + // bring the card back after it has been put away. + set({ phase: 'hidden', letter: null }); + }, + + openLetter: () => { + get().suspendAutoCollapse(); + // Hide the card/chip the instant the letter is opened, synchronously + // in the same action — not indirectly via `onFirstOpen` once the paper + // has mounted. That side-channel still marks the letter "read" in + // storage, but the *visual* close must not depend on it or on any + // in-flight hover/collapse timer from the card being torn down. + set({ paperOpen: true, phase: 'hidden' }); + }, + + closePaper: () => { + // Closing the paper is terminal for this arrival: clear the letter + // reference so nothing can resurface the same card/chip afterward. + set({ paperOpen: false, phase: 'hidden', letter: null }); + }, + + suspendAutoCollapse: () => { + const timer = get()._chipTimer; + if (timer) { + clearTimeout(timer); + set({ _chipTimer: null }); + } + }, + + resumeAutoCollapse: () => { + get().suspendAutoCollapse(); + const timer = setTimeout(() => { + get().collapseToChip(); + }, CHIP_COLLAPSE_MS); + set({ _chipTimer: timer }); + }, +})); + +/** + * Surfaces a completed letter as the corner arrival card/chip, unless this + * exact version (by `arrivalKeyOf`) has already been surfaced this session. + * Shared by the poller (`tick`) and by direct callers that already know a + * letter just finished writing (`announceDailyLetterArrival`), so both + * paths — which can race each other for the same manual run — agree on the + * same "have I already shown this one" bookkeeping. + * + * The check is unconditional on `lastArrivedKey` alone (not also gated on + * `lastArrivedState`): once a given version has been surfaced once, it must + * never be surfaced again in this session, whether it is still showing, + * already read, or already dismissed/collapsed. Re-checking the state too + * would let a delayed, redundant caller pop an already-settled card back + * open. A later re-generation of the same day's letter produces a new + * `updatedAtMs` and therefore a new arrival key, so it is still treated as a + * genuinely fresh arrival. + */ +function surfaceArrivedLetter(record: DailyLetterRecord): void { + const arrivalKey = arrivalKeyOf(record); + if (persisted.lastArrivedKey === arrivalKey) { + return; + } + + persisted = { ...persisted, lastArrivedKey: arrivalKey, lastArrivedState: 'unseen' }; + savePersisted(persisted); + + useDailyLetterArrivalStore.setState({ + phase: 'card', + letter: record, + pendingReceiptCount: pendingReceiptCountOf(record), + hasUnread: true, + }); + useDailyLetterArrivalStore.getState().resumeAutoCollapse(); +} + +/** + * Marks a letter as read, regardless of who opened it (the global arrival + * card, the arrival chip, or the Daily Letter scene browsing its own rail). + * Centralizing this in one place means every entry point shares the same + * "have I seen this one" bookkeeping. + * + * This only touches persisted state when the opened letter is the one + * currently tracked as "the latest arrival" — opening some unrelated older + * letter from the scene's history list must not clobber that pointer. + */ +export function markDailyLetterAcknowledged(letter: DailyLetterRecord): void { + const arrivalKey = arrivalKeyOf(letter); + if (persisted.lastArrivedKey !== arrivalKey) return; + + persisted = { ...persisted, lastArrivedState: 'read' }; + savePersisted(persisted); + + const store = useDailyLetterArrivalStore.getState(); + if (store.letter?.id === letter.id) { + store.suspendAutoCollapse(); + useDailyLetterArrivalStore.setState({ phase: 'hidden', hasUnread: false }); + } else { + useDailyLetterArrivalStore.setState({ hasUnread: false }); + } +} + +/** + * Announces a letter that just finished writing — called directly by the + * Daily Letter scene right after a manual "urge the letter" completes (or + * after its background-run poll detects a completion), instead of waiting + * for the arrival poller's next ~20s tick. The corner card/chip ceremony + * always plays, even while the scene itself is open and focused: the scene + * no longer pops the full-text paper on its own, so this is the only place + * that announces a fresh letter. + * + * Safe to call redundantly — it shares the same "already acknowledged" + * bookkeeping as the poller via `surfaceArrivedLetter`, so if the poller (or + * another surface) already claimed this exact version, this is a no-op. + */ +export function announceDailyLetterArrival(record: DailyLetterRecord): void { + surfaceArrivedLetter(record); +} diff --git a/src/web-ui/src/app/navigation/navigationController.test.ts b/src/web-ui/src/app/navigation/navigationController.test.ts new file mode 100644 index 00000000..b797469b --- /dev/null +++ b/src/web-ui/src/app/navigation/navigationController.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { openHome } from './navigationController'; +import { useWorkspaceSurfaceStore } from './workspaceSurfaceStore'; +import { createAgenticOsHomeSurface } from './workspaceSurfaceTypes'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { SessionMetadata } from '@/shared/types/session-history'; + +const sessionApiMock = vi.hoisted(() => ({ + listSessions: vi.fn(), + loadSessionMetadata: vi.fn(), +})); + +const createSessionMock = vi.hoisted(() => vi.fn()); +const activateSessionDataMock = vi.hoisted(() => vi.fn(async () => {})); +const syncSessionToModernStoreMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ + sessionAPI: sessionApiMock, +})); + +vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ + configManager: { + getConfig: vi.fn(async () => ({})), + }, +})); + +vi.mock('@/infrastructure/services/business/workspaceManager', () => ({ + workspaceManager: { + getState: vi.fn(() => ({ openedWorkspaces: new Map() })), + }, +})); + +vi.mock('@/flow_chat/services/FlowChatManager', () => ({ + flowChatManager: { + createChatSession: createSessionMock, + activateSessionData: activateSessionDataMock, + }, +})); + +vi.mock('@/flow_chat/services/storeSync', () => ({ + syncSessionToModernStore: syncSessionToModernStoreMock, +})); + +function agenticOsMetadata(overrides: Partial = {}): SessionMetadata { + return { + sessionId: 'os-empty-1', + sessionName: 'Agentic OS', + agentType: 'OSAgent', + modelName: 'primary', + createdAt: 1_000, + lastActiveAt: 1_000, + turnCount: 0, + messageCount: 0, + toolCallCount: 0, + status: 'active', + tags: [], + workspacePath: 'C:/Users/HUAWEI/AppData/Roaming/sparo_os/agentic_os', + storageScope: 'agentic_os', + ...overrides, + }; +} + +function resetStores(): void { + for (const sessionId of Array.from(flowChatStore.getState().sessions.keys())) { + flowChatStore.removeSession(sessionId); + } + useWorkspaceSurfaceStore.setState({ + activeSurface: createAgenticOsHomeSurface(), + previousSurface: null, + currentOsSessionId: null, + sceneHistory: [], + surfaceContext: null, + }); +} + +describe('navigationController openHome', () => { + beforeEach(() => { + sessionApiMock.listSessions.mockReset(); + sessionApiMock.loadSessionMetadata.mockReset(); + createSessionMock.mockReset(); + activateSessionDataMock.mockClear(); + syncSessionToModernStoreMock.mockClear(); + createSessionMock.mockResolvedValue('os-new-1'); + vi.stubGlobal('window', { + dispatchEvent: vi.fn(), + }); + vi.stubGlobal('CustomEvent', class { + public readonly type: string; + public readonly detail: unknown; + + constructor(type: string, init?: CustomEventInit) { + this.type = type; + this.detail = init?.detail; + } + }); + resetStores(); + }); + + afterEach(() => { + resetStores(); + vi.unstubAllGlobals(); + }); + + it('reuses a persisted empty Agentic OS session before creating a new one', async () => { + sessionApiMock.listSessions.mockResolvedValue([ + agenticOsMetadata(), + ]); + + const sessionId = await openHome(); + + expect(sessionId).toBe('os-empty-1'); + expect(sessionApiMock.listSessions).toHaveBeenCalledWith(undefined, 'agentic_os'); + expect(createSessionMock).not.toHaveBeenCalled(); + expect(useWorkspaceSurfaceStore.getState().currentOsSessionId).toBe('os-empty-1'); + expect(flowChatStore.getState().sessions.get('os-empty-1')?.dialogTurns).toEqual([]); + expect(activateSessionDataMock).toHaveBeenCalledWith('os-empty-1'); + }); + + it('creates a new Agentic OS session when the latest persisted OS session has turns', async () => { + sessionApiMock.listSessions.mockResolvedValue([ + agenticOsMetadata({ + sessionId: 'os-empty-old', + createdAt: 1_000, + lastActiveAt: 1_000, + }), + agenticOsMetadata({ + sessionId: 'os-with-history', + createdAt: 2_000, + lastActiveAt: 2_000, + turnCount: 1, + messageCount: 2, + }), + ]); + + const sessionId = await openHome(); + + expect(sessionId).toBe('os-new-1'); + expect(createSessionMock).toHaveBeenCalledWith( + { storageScope: 'agentic_os', navigate: false }, + expect.objectContaining({ + hostKind: 'system-agentic-os', + identityId: 'agentic-os', + }), + ); + expect(useWorkspaceSurfaceStore.getState().currentOsSessionId).toBe('os-new-1'); + }); + + it('does not treat other agentic_os scoped sessions as the Agentic OS home session', async () => { + sessionApiMock.listSessions.mockResolvedValue([ + agenticOsMetadata({ + sessionId: 'app-builder-empty-1', + sessionName: 'App Builder', + agentType: 'AppBuilder', + }), + ]); + + const sessionId = await openHome(); + + expect(sessionId).toBe('os-new-1'); + expect(createSessionMock).toHaveBeenCalledWith( + { storageScope: 'agentic_os', navigate: false }, + expect.objectContaining({ + hostKind: 'system-agentic-os', + identityId: 'agentic-os', + }), + ); + expect(useWorkspaceSurfaceStore.getState().currentOsSessionId).toBe('os-new-1'); + }); +}); diff --git a/src/web-ui/src/app/navigation/navigationController.ts b/src/web-ui/src/app/navigation/navigationController.ts index 957b4d8d..e681f4b4 100644 --- a/src/web-ui/src/app/navigation/navigationController.ts +++ b/src/web-ui/src/app/navigation/navigationController.ts @@ -9,6 +9,7 @@ import { createAgenticOsHomeSurface } from './workspaceSurfaceTypes'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { syncSessionToModernStore } from '@/flow_chat/services/storeSync'; import { + descriptorFromAgentType, getAgenticOsSessionDescriptor, isSystemAgenticOsSession, } from '@/flow_chat/domain/sessionDescriptor'; @@ -24,7 +25,7 @@ import { } from '@/shared/types/runtime-scope'; import type { AppScope } from '@/shared/types/app-scope'; import type { ProductAppRuntimeContext } from '@/shared/types/product-app-runtime'; -import type { SessionStorageScope } from '@/shared/types/session-history'; +import type { SessionMetadata, SessionStorageScope } from '@/shared/types/session-history'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('NavigationController'); @@ -61,13 +62,73 @@ function resolveSceneScope(options: OpenWorkspaceSceneOptions): RuntimeScope { return projectRuntimeScopeFromWorkspacePath(options.workspacePath) ?? systemRuntimeScope(); } -function findLatestAgenticOsSessionId(): string | null { - return Array.from(flowChatStore.getState().sessions.values()) - .filter((session) => isSystemAgenticOsSession(session.descriptor)) +function isTopLevelAgenticOsSession(session: Session): boolean { + return ( + isSystemAgenticOsSession(session.descriptor) && + !session.parentSessionId && + session.sessionKind === 'normal' + ); +} + +function isKnownEmptyAgenticOsSession(session: Session): boolean { + if (!isTopLevelAgenticOsSession(session) || session.dialogTurns.length > 0) { + return false; + } + return session.loadPhase === 'live' || session.loadPhase === 'hydrated'; +} + +function isTopLevelAgenticOsMetadata(metadata: SessionMetadata): boolean { + return ( + isSystemAgenticOsSession(descriptorFromAgentType(metadata.agentType)) && + metadata.sessionKind !== 'subagent' && + !metadata.customMetadata?.parentSessionId + ); +} + +function isEmptyAgenticOsMetadata(metadata: SessionMetadata): boolean { + return ( + metadata.turnCount === 0 && + metadata.messageCount === 0 && + metadata.toolCallCount === 0 + ); +} + +function findLatestKnownEmptyAgenticOsSessionId(): string | null { + const latestSession = Array.from(flowChatStore.getState().sessions.values()) + .filter(isTopLevelAgenticOsSession) .sort( (a, b) => (b.lastActiveAt ?? b.createdAt ?? 0) - (a.lastActiveAt ?? a.createdAt ?? 0) - )[0]?.sessionId ?? null; + )[0] ?? null; + return latestSession && isKnownEmptyAgenticOsSession(latestSession) + ? latestSession.sessionId + : null; +} + +async function resolveReusableEmptyAgenticOsSessionId(): Promise { + try { + const metadata = await sessionAPI.listSessions(undefined, 'agentic_os'); + await flowChatStore.hydrateWorkspaceSessionsMetadata(metadata, '', 'agentic_os'); + + const latestMetadata = metadata + .filter(isTopLevelAgenticOsMetadata) + .sort( + (a, b) => + (b.lastActiveAt ?? b.createdAt ?? 0) - (a.lastActiveAt ?? a.createdAt ?? 0) + )[0] ?? null; + + if (!latestMetadata) { + return findLatestKnownEmptyAgenticOsSessionId(); + } + + return isEmptyAgenticOsMetadata(latestMetadata) + ? latestMetadata.sessionId + : null; + } catch (error) { + log.warn('Failed to load Agentic OS sessions before opening home', error); + } + + return findLatestKnownEmptyAgenticOsSessionId(); } async function loadSessionMetadataAttempt( @@ -236,7 +297,7 @@ export async function openHome(options?: { const resolvedOsSessionId = options?.currentOsSessionId ?? state.currentOsSessionId ?? - findLatestAgenticOsSessionId(); + await resolveReusableEmptyAgenticOsSessionId(); if (resolvedOsSessionId) { if (epoch !== navEpoch) { diff --git a/src/web-ui/src/app/navigation/workspaceTopBarContext.test.ts b/src/web-ui/src/app/navigation/workspaceTopBarContext.test.ts index a691c62a..a0ffd92b 100644 --- a/src/web-ui/src/app/navigation/workspaceTopBarContext.test.ts +++ b/src/web-ui/src/app/navigation/workspaceTopBarContext.test.ts @@ -21,6 +21,7 @@ function createWork(): WorkRecord { runtimeInstances: [], artifactRefs: [], memoryRefs: [], + systemManaged: false, createdAt: 1, updatedAt: 2, }; diff --git a/src/web-ui/src/app/scenes/apps/app-detail/AppDetailScene.tsx b/src/web-ui/src/app/scenes/apps/app-detail/AppDetailScene.tsx index d3287f88..a79e6cbf 100644 --- a/src/web-ui/src/app/scenes/apps/app-detail/AppDetailScene.tsx +++ b/src/web-ui/src/app/scenes/apps/app-detail/AppDetailScene.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react'; import { ChevronRight, Download, + History, Play, Plus, Square, @@ -655,7 +656,7 @@ export const AppDetailScene: React.FC = ({ const PrimaryActionIcon = (() => { if (!isNative && !isInstalled) return Download; if (useStopAction) return Square; - if (canContinueLatestWork) return Play; + if (canContinueLatestWork) return History; return launchBehavior.supportsMultipleWorks ? Plus : Play; })(); diff --git a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss index 2431a2fe..46bb5f45 100644 --- a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss +++ b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.scss @@ -853,413 +853,9 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' } } -// Letter paper overlay: a dark slate scrim with a soft vignette that -// focuses the eye on the sheet, in both light and dark themes. -.dl-paper-overlay { - background: - radial-gradient( - 130% 110% at 50% 36%, - transparent 30%, - color-mix(in srgb, var(--ds-shadow-color-strong) 72%, transparent) 100% - ), - var(--ds-overlay-scrim-strong); - backdrop-filter: blur(4px); -} - -.dl-paper-dialog { - max-width: min(720px, calc(100vw - 48px)); - border: 1px solid color-mix(in srgb, var(--ds-color-border-strong) 72%, var(--ds-color-border-subtle)); - border-radius: $size-radius-lg; - // Layered longhand keeps the paper fully opaque even when the theme's - // elevated surface carries alpha: scene color at the bottom, elevated - // surface above it, then a faint accent wash at the top edge. - background-color: var(--ds-color-bg-scene); - background-image: - linear-gradient( - 178deg, - color-mix(in srgb, var(--ds-color-accent) 5%, transparent) 0%, - transparent 30% - ), - linear-gradient(var(--ds-color-bg-elevated), var(--ds-color-bg-elevated)); - // Contact, key, and ambient layers: the sheet should feel lifted off - // the desk rather than printed onto the scrim. - box-shadow: - 0 1px 2px var(--ds-shadow-color-soft), - 0 16px 40px var(--ds-shadow-color-modal), - 0 64px 140px var(--ds-shadow-color-strong); - animation: dl-paper-unfold 420ms cubic-bezier(0.16, 1, 0.3, 1); - - // Letterhead ribbon: a hairline of accent light along the top edge. - &::after { - content: ''; - position: absolute; - top: 0; - right: 0; - left: 0; - height: 2px; - pointer-events: none; - background: - linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--ds-color-accent) 70%, transparent) 24%, - color-mix(in srgb, var(--ds-color-accent) 70%, transparent) 76%, - transparent 100% - ); - } - - // The paper draws its own masthead; float the close control over both - // the sheet and the compact letterhead bar. - .ds-dialog__header-shell { - position: absolute; - inset-block-start: 0; - inset-inline-end: 0; - z-index: var(--ds-z-floating, 50); - min-height: 0; - } - - .ds-dialog__close { - inset-block-start: $size-gap-3; - inset-inline-end: $size-gap-3; - width: 28px; - height: 28px; - border-radius: 50%; - color: var(--ds-color-text-tertiary); - background: color-mix(in srgb, var(--ds-color-bg-elevated) 82%, var(--ds-color-text-tertiary)); - - &:hover { - color: var(--ds-color-text-primary); - background: color-mix(in srgb, var(--ds-color-bg-elevated) 68%, var(--ds-color-text-tertiary)); - border-color: transparent; - } - } -} - -// The dialog body stops scrolling itself; the shell owns a viewport so the -// letterhead bar, progress ink, and tray can react to scroll position. -.dl-paper-scroll { - display: flex; - flex-direction: column; - padding: 0; - overflow: hidden; -} - -.dl-paper-shell { - position: relative; - display: flex; - flex: 1; - min-height: 0; - flex-direction: column; -} - -.dl-paper-viewport { - flex: 1; - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; -} - -// Compact letterhead: slides in once the masthead scrolls away, carrying -// the letter identity and a thin ink line that fills as you read. -.dl-paper-topbar { - position: absolute; - top: 0; - right: 0; - left: 0; - // The letter body keeps a filled transform animation (a persistent - // stacking context), so the letterhead needs an explicit layer above it. - z-index: var(--ds-z-sticky, 20); - display: flex; - align-items: center; - gap: $size-gap-2; - height: 44px; - padding: 0 56px 0 $size-gap-5; - border-bottom: 1px solid var(--ds-color-border-subtle); - background-color: var(--ds-color-bg-scene); - background-image: linear-gradient(var(--ds-color-bg-elevated), var(--ds-color-bg-elevated)); - font-family: var(--ds-font-family-sans); - opacity: 0; - transform: translateY(-100%); - transition: transform $motion-base $easing-ease-out, opacity $motion-base $easing-ease-out; - pointer-events: none; -} - -.dl-paper-shell.is-collapsed .dl-paper-topbar { - opacity: 1; - transform: translateY(0); -} - -.dl-paper-topbar__seed { - width: 7px; - height: 7px; - flex: 0 0 auto; - border-radius: 50%; - background: var(--ds-color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--ds-color-accent) 16%, transparent); -} - -.dl-paper-topbar__eyebrow { - flex: 0 0 auto; - font-size: $font-size-sm; - font-weight: $font-weight-semibold; -} - -.dl-paper-topbar__date { - min-width: 0; - margin-left: auto; - overflow: hidden; - color: var(--ds-color-text-tertiary); - font-size: $font-size-xs; - font-variant-numeric: tabular-nums; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dl-paper-topbar__progress { - position: absolute; - right: 0; - bottom: -1px; - left: 0; - height: 2px; - transform-origin: left center; - background: - linear-gradient( - 90deg, - var(--ds-color-accent) 0%, - color-mix(in srgb, var(--ds-color-accent) 55%, transparent) 100% - ); - transition: transform 90ms linear; -} - -.dl-paper { - --dl-paper-inset: clamp(#{$size-gap-6}, 9%, 64px); - - position: relative; - padding: $size-gap-8 var(--dl-paper-inset) 0; - font-family: $dl-serif; -} - -.dl-paper__postmark { - position: absolute; - top: $size-gap-6; - left: var(--dl-paper-inset); - display: inline-flex; - flex-direction: column; - gap: 2px; - padding: $size-gap-2 $size-gap-3; - border: 1px dashed color-mix(in srgb, var(--ds-color-accent) 45%, var(--ds-color-border-subtle)); - border-radius: $size-radius-base; - color: color-mix(in srgb, var(--ds-color-accent) 80%, var(--ds-color-text-secondary)); - font-family: var(--ds-font-family-sans); - transform: rotate(-3deg); - opacity: 0.85; - - span { - font-size: 10px; - font-weight: $font-weight-semibold; - letter-spacing: 0.08em; - text-transform: uppercase; - } - - strong { - font-size: $font-size-sm; - font-weight: $font-weight-semibold; - font-variant-numeric: tabular-nums; - } -} - -.dl-paper__masthead { - margin-top: $size-gap-8; - text-align: center; - animation: dl-paper-rise 480ms $easing-ease-out both; - animation-delay: 80ms; - - h2 { - margin: 0 0 $size-gap-2; - font-size: $font-size-2xl; - font-weight: $font-weight-medium; - letter-spacing: 0.01em; - } -} - -.dl-paper__eyebrow { - margin: 0 0 $size-gap-2; - color: var(--ds-color-text-tertiary); - font-family: var(--ds-font-family-sans); - font-size: $font-size-xs; - font-weight: $font-weight-semibold; - letter-spacing: 0.22em; - text-transform: uppercase; -} - -.dl-paper__meta { - margin: 0; - color: var(--ds-color-text-tertiary); - font-family: var(--ds-font-family-sans); - font-size: $font-size-xs; -} - -.dl-paper__rule { - position: relative; - display: block; - width: 180px; - height: 1px; - margin: $size-gap-5 auto 0; - background: - linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--ds-color-text-tertiary) 45%, transparent) 18%, - color-mix(in srgb, var(--ds-color-text-tertiary) 45%, transparent) 82%, - transparent 100% - ); - - &::after { - content: ''; - position: absolute; - top: -2.5px; - left: 50%; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--ds-color-accent); - transform: translateX(-50%) rotate(45deg); - } -} - -.dl-paper__content { - max-width: 560px; - margin: $size-gap-6 auto 0; - animation: dl-paper-rise 520ms $easing-ease-out both; - animation-delay: 160ms; - - .markdown-renderer { - color: var(--ds-color-text-primary); - font-family: inherit; - font-size: 15px; - line-height: 2; - - p { - margin: 0 0 $size-gap-4; - } - - ul, - ol { - margin: 0 0 $size-gap-4; - } - - li { - margin-bottom: $size-gap-1; - } - - h1, - h2, - h3 { - font-family: inherit; - font-weight: $font-weight-semibold; - } - } -} - -.dl-paper__sign { - position: relative; - display: flex; - align-items: baseline; - justify-content: flex-end; - gap: $size-gap-3; - max-width: 560px; - margin: $size-gap-6 auto 0; - animation: dl-paper-rise 520ms $easing-ease-out both; - animation-delay: 240ms; -} - -.dl-paper__sign-name { - color: var(--ds-color-text-secondary); - font-size: $font-size-base; - font-style: italic; - - &::before { - content: '— '; - color: var(--ds-color-accent); - font-style: normal; - } -} - -.dl-paper__sign-date { - color: var(--ds-color-text-tertiary); - font-family: var(--ds-font-family-sans); - font-size: $font-size-xs; - font-variant-numeric: tabular-nums; -} - -.dl-paper__seal { - position: absolute; - top: 50%; - left: 0; - display: inline-flex; - align-items: center; - justify-content: center; - width: 64px; - height: 64px; - border: 2px solid color-mix(in srgb, var(--ds-color-success) 55%, transparent); - border-radius: 50%; - color: color-mix(in srgb, var(--ds-color-success) 80%, var(--ds-color-text-secondary)); - font-family: var(--ds-font-family-sans); - font-size: $font-size-xs; - font-weight: $font-weight-semibold; - letter-spacing: 0.06em; - transform: translateY(-50%) rotate(-12deg); - opacity: 0.75; - pointer-events: none; - - &::before { - content: ''; - position: absolute; - inset: 4px; - border: 1px solid color-mix(in srgb, var(--ds-color-success) 35%, transparent); - border-radius: 50%; - } -} - -// Action tray: stays reachable at the bottom of the scroll like the -// edge of a writing desk, bleeding to the sheet's full width. -.dl-paper__actions { - position: sticky; - bottom: 0; - z-index: var(--ds-z-raised, 2); - display: flex; - align-items: center; - justify-content: space-between; - gap: $size-gap-3; - margin: $size-gap-8 calc(-1 * var(--dl-paper-inset)) 0; - padding: $size-gap-3 var(--dl-paper-inset) $size-gap-4; - border-top: 1px solid var(--ds-color-border-subtle); - background-color: var(--ds-color-bg-scene); - background-image: linear-gradient(var(--ds-color-bg-elevated), var(--ds-color-bg-elevated)); - font-family: var(--ds-font-family-sans); - transition: box-shadow $motion-base $easing-ease-out; -} - -// While there is still letter below the fold, the tray lifts slightly so -// it reads as a desk edge rather than the end of the sheet. -.dl-paper-shell:not(.is-at-end) .dl-paper__actions { - box-shadow: 0 -14px 28px -14px var(--ds-shadow-color-modal); -} - -.dl-paper__actions-note { - min-width: 0; -} - -.dl-paper__pending { - color: var(--ds-color-text-warning); - font-size: $font-size-xs; -} - -.dl-paper__actions-buttons { - display: flex; - align-items: center; - gap: $size-gap-2; -} +// NOTE: the letter-paper overlay/dialog styling (`.dl-paper*`) now lives +// in LetterPaper.scss, imported by LetterPaper.tsx wherever it renders +// (this scene, or the global daily-letter arrival dock). .dl-edit-receipt { width: 100%; @@ -1302,47 +898,12 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' } } -@keyframes dl-paper-unfold { - from { - opacity: 0; - transform: perspective(1200px) translateY(26px) rotateX(4deg) scale(0.985); - } - - to { - opacity: 1; - transform: perspective(1200px) translateY(0) rotateX(0deg) scale(1); - } -} - -@keyframes dl-paper-rise { - from { - opacity: 0; - transform: translateY(10px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - @media (prefers-reduced-motion: reduce) { .dl-rail__date-chip, .dl-rail__today-writing, - .dl-chip__pulse, - .dl-paper-dialog, - .dl-paper__masthead, - .dl-paper__content, - .dl-paper__sign { + .dl-chip__pulse { animation: none; } - - .dl-paper-topbar, - .dl-paper-topbar__progress, - .dl-paper__actions { - transition: none; - } - } @media (max-width: 1120px) { @@ -1410,25 +971,6 @@ $dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, ' padding: $size-gap-4; } - .dl-paper { - --dl-paper-inset: #{$size-gap-5}; - - padding-top: $size-gap-6; - } - - .dl-paper-topbar { - padding: 0 48px 0 $size-gap-4; - } - - .dl-paper__actions { - align-items: stretch; - flex-direction: column; - } - - .dl-paper__actions-buttons { - flex-wrap: wrap; - } - .dl-receipt__footer, .dl-receipt__actions { align-items: stretch; diff --git a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx index 3c508405..75d9946a 100644 --- a/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx +++ b/src/web-ui/src/app/scenes/daily-letter/DailyLetterScene.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Archive, CalendarDays, CalendarRange, Check, @@ -25,6 +24,18 @@ import { Markdown } from '@/shared/markdown'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { createLogger } from '@/shared/utils/logger'; import { dailyLetterApi } from './dailyLetterApi'; +import { + defaultDateFilterRange, + formatDateKey, + parseDateKey, + startOfLocalToday, + todayKey, +} from './dailyLetterDateUtils'; +import { LetterPaper } from './LetterPaper'; +import { + announceDailyLetterArrival, + markDailyLetterAcknowledged, +} from '@/app/daily-letter-arrival/store/dailyLetterArrivalStore'; import type { DailyLetterReceiptCandidate, DailyLetterRecord, @@ -47,36 +58,7 @@ interface DailyLetterSceneProps { const WRITING_POLL_INTERVAL_MS = 2500; -function todayKey(): string { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -function parseDateKey(date: string): Date { - return new Date(`${date}T00:00:00`); -} - -function formatDateKey(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -} - -function startOfLocalToday(): Date { - const today = new Date(); - return new Date(today.getFullYear(), today.getMonth(), today.getDate()); -} - -function defaultDateFilterRange(): DateRangeValue { - const endDate = startOfLocalToday(); - const startDate = new Date(endDate); - startDate.setDate(startDate.getDate() - 30); - return { startDate, endDate }; -} +type TFn = (key: string, options?: Record) => string; function pendingReceiptCount(letter: DailyLetterRecord): number { return letter.receiptCandidates.filter((candidate) => candidate.status === 'pending').length; @@ -91,20 +73,6 @@ function generationReasonMessage(reason: string | null | undefined, t: (key: str return t('messages.generateNoResult'); } -/** - * The paper overlay renders its own masthead, so a duplicated leading - * markdown H1 ("# 今日来信 · date") is dropped from the body. - */ -function letterBodyForPaper(markdown: string): string { - const lines = markdown.split('\n'); - let index = 0; - while (index < lines.length && !lines[index].trim()) index += 1; - if (index < lines.length && /^#\s/.test(lines[index].trim())) { - return lines.slice(index + 1).join('\n').replace(/^\s+/, ''); - } - return markdown; -} - const DailyLetterScene: React.FC = ({ workspacePath }) => { const { t, formatDate, currentLanguage } = useI18n('scenes/daily-letter'); const [letters, setLetters] = useState([]); @@ -198,7 +166,13 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => const revealLetter = useCallback((record: DailyLetterRecord) => { updateLetter(record); setSelectedId(record.id); - setPaperOpen(true); + // Select it into the reading pane, but don't force the full-text dialog + // open — the corner arrival card/chip is the one consistent "letter + // arrived" ceremony now, for every trigger and regardless of whether + // this scene happens to be focused. Announcing it directly (rather than + // waiting for the poller's next ~20s tick) makes the card appear right + // away instead of after a noticeable delay. + announceDailyLetterArrival(record); }, [updateLetter]); // While a letter is being written, poll the runtime state so both manual @@ -223,6 +197,10 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => return; } setWriting(false); + // Claim the target before doing anything async below — if + // `generateToday`'s own direct `await` also resolves around the + // same time, whichever of the two paths reads this ref first wins, + // and the other will see it already cleared and skip revealing. const target = writingTargetRef.current; writingTargetRef.current = null; if (state.lastAttemptStatus === 'error') { @@ -324,7 +302,14 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => const targetScope: DailyLetterScope = workspacePath ? 'workspace' : 'agentic_os'; const date = todayKey(); const startedAtMs = Date.now(); - writingTargetRef.current = { date, scope: targetScope, trigger: 'manual' }; + // A distinct object identity per call, not just a value — the writing-poll + // effect below independently watches the same run via its own state() + // polling and can race this direct `await` to detect completion first. + // Comparing identity (not just nullness) lets whichever path notices + // completion first "claim" the reveal, so the other reliably no-ops + // instead of double-revealing/double-announcing the same letter. + const myTarget: WritingTarget = { date, scope: targetScope, trigger: 'manual' }; + writingTargetRef.current = myTarget; watchSinceMsRef.current = startedAtMs; setWriting(true); setWritingStartedAtMs(startedAtMs); @@ -339,8 +324,10 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => }); if (summary.record) { setWriting(false); - writingTargetRef.current = null; - revealLetter(summary.record); + if (writingTargetRef.current === myTarget) { + writingTargetRef.current = null; + revealLetter(summary.record); + } return; } if (summary.reason?.includes('already active')) { @@ -348,7 +335,9 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => return; } setWriting(false); - writingTargetRef.current = null; + if (writingTargetRef.current === myTarget) { + writingTargetRef.current = null; + } const reasonMessage = generationReasonMessage(summary.reason, t); if (reasonMessage) { setError(reasonMessage); @@ -357,7 +346,9 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => } catch (generateError) { log.error('Failed to generate daily letter', { error: generateError }); setWriting(false); - writingTargetRef.current = null; + if (writingTargetRef.current === myTarget) { + writingTargetRef.current = null; + } setError(t('messages.generateFailed')); } }, [loadLetters, revealLetter, t, workspacePath]); @@ -614,6 +605,7 @@ const DailyLetterScene: React.FC = ({ workspacePath }) => onSeal={() => void sealLetter()} formatDate={formatDate} t={t} + onFirstOpen={markDailyLetterAcknowledged} /> = ({ workspacePath }) => ); }; -type TFn = (key: string, options?: Record) => string; -type FormatDateFn = (date: Date | number, options?: Intl.DateTimeFormatOptions) => string; - -interface PaperScrollState { - collapsed: boolean; - progress: number; - atEnd: boolean; -} - -function LetterPaper({ - letter, - open, - onOpenChange, - pendingCount, - canSeal, - onSeal, - formatDate, - t, -}: { - letter: DailyLetterRecord | null; - open: boolean; - onOpenChange: (open: boolean) => void; - pendingCount: number; - canSeal: boolean; - onSeal: () => void; - formatDate: FormatDateFn; - t: TFn; -}) { - const viewportRef = useRef(null); - const [scroll, setScroll] = useState({ collapsed: false, progress: 0, atEnd: false }); - - const readScroll = useCallback(() => { - const el = viewportRef.current; - if (!el) return; - const max = el.scrollHeight - el.clientHeight; - const top = el.scrollTop; - setScroll({ - // Collapse the masthead into the compact letterhead bar once the - // large title has mostly left the viewport. - collapsed: top > 132, - progress: max > 0 ? Math.min(1, top / max) : 1, - atEnd: max - top < 24, - }); - }, []); - - // Start every letter from the top and refresh the tray/topbar state - // once the content has been laid out. - useEffect(() => { - if (!open) return; - const el = viewportRef.current; - if (el) el.scrollTop = 0; - const frame = window.requestAnimationFrame(readScroll); - return () => window.cancelAnimationFrame(frame); - }, [letter?.id, open, readScroll]); - - if (!letter) return null; - - const scopeLabel = letter.scope === 'agentic_os' ? t('scope.agenticOs') : t('scope.workspace'); - const postmarkDate = `${letter.date.slice(5, 7)} · ${letter.date.slice(8, 10)}`; - let dateLine = letter.date; - try { - dateLine = formatDate(parseDateKey(letter.date), { dateStyle: 'full' }); - } catch { - // keep the raw date key - } - const writtenAt = formatDate(new Date(letter.createdAtMs), { timeStyle: 'short' }); - - return ( - -
-
-
-
-
- - -
-

{t('paper.eyebrow')}

-

{dateLine}

-

- {letter.workspace ? `${letter.workspace.name} · ` : ''} - {t('paper.writtenAt', { time: writtenAt })} -

-
- -
- -
- -
- {letter.status === 'sealed' && ( - - )} - {t('paper.signature')} - {letter.date} -
- -
-
- {pendingCount > 0 && ( - {t('paper.pending', { count: pendingCount })} - )} -
-
- - -
-
-
-
-
-
- ); -} - function LetterContent({ letter, onAccept, diff --git a/src/web-ui/src/app/scenes/daily-letter/LetterPaper.scss b/src/web-ui/src/app/scenes/daily-letter/LetterPaper.scss new file mode 100644 index 00000000..d7b3513b --- /dev/null +++ b/src/web-ui/src/app/scenes/daily-letter/LetterPaper.scss @@ -0,0 +1,496 @@ +@use '@/design-system/foundation/tokens/tokens.scss' as *; + +// Reading stack for letter prose: editorial serif with CJK fallbacks. +$dl-serif: georgia, 'Iowan Old Style', 'Times New Roman', 'Songti SC', simsun, 'Noto Serif CJK SC', serif; + +// Letter paper overlay: a dark slate scrim with a soft vignette that +// focuses the eye on the sheet, in both light and dark themes. +.dl-paper-overlay { + background: + radial-gradient( + 130% 110% at 50% 36%, + transparent 30%, + color-mix(in srgb, var(--ds-shadow-color-strong) 72%, transparent) 100% + ), + var(--ds-overlay-scrim-strong); + backdrop-filter: blur(4px); +} + +.dl-paper-dialog { + max-width: min(720px, calc(100vw - 48px)); + border: 1px solid color-mix(in srgb, var(--ds-color-border-strong) 72%, var(--ds-color-border-subtle)); + border-radius: $size-radius-lg; + // Layered longhand keeps the paper fully opaque even when the theme's + // elevated surface carries alpha: scene color at the bottom, elevated + // surface above it, then a faint accent wash at the top edge. + background-color: var(--ds-color-bg-scene); + background-image: + linear-gradient( + 178deg, + color-mix(in srgb, var(--ds-color-accent) 5%, transparent) 0%, + transparent 30% + ), + linear-gradient(var(--ds-color-bg-elevated), var(--ds-color-bg-elevated)); + // Contact, key, and ambient layers: the sheet should feel lifted off + // the desk rather than printed onto the scrim. + box-shadow: + 0 1px 2px var(--ds-shadow-color-soft), + 0 16px 40px var(--ds-shadow-color-modal), + 0 64px 140px var(--ds-shadow-color-strong); + animation: dl-paper-unfold 420ms cubic-bezier(0.16, 1, 0.3, 1); + + // Letterhead ribbon: a hairline of accent light along the top edge. + &::after { + content: ''; + position: absolute; + top: 0; + right: 0; + left: 0; + height: 2px; + pointer-events: none; + background: + linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--ds-color-accent) 70%, transparent) 24%, + color-mix(in srgb, var(--ds-color-accent) 70%, transparent) 76%, + transparent 100% + ); + } + + // The paper draws its own masthead; float the close control over both + // the sheet and the compact letterhead bar. + .ds-dialog__header-shell { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + z-index: var(--ds-z-floating, 50); + min-height: 0; + } + + .ds-dialog__close { + inset-block-start: $size-gap-3; + inset-inline-end: $size-gap-3; + width: 28px; + height: 28px; + border-radius: 50%; + color: var(--ds-color-text-tertiary); + background: color-mix(in srgb, var(--ds-color-bg-elevated) 82%, var(--ds-color-text-tertiary)); + + &:hover { + color: var(--ds-color-text-primary); + background: color-mix(in srgb, var(--ds-color-bg-elevated) 68%, var(--ds-color-text-tertiary)); + border-color: transparent; + } + } +} + +// Opened from the bottom-left arrival card: grow from that corner +// instead of the viewport center, so the sheet reads as "this is the +// letter I just clicked" rather than appearing out of nowhere. +.dl-paper-dialog--from-corner { + transform-origin: 64px calc(100% - 64px); + animation-name: dl-paper-unfold-from-corner; +} + +// The dialog body stops scrolling itself; the shell owns a viewport so the +// letterhead bar, progress ink, and tray can react to scroll position. +.dl-paper-scroll { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +.dl-paper-shell { + position: relative; + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; +} + +.dl-paper-viewport { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +// Compact letterhead: slides in once the masthead scrolls away, carrying +// the letter identity and a thin ink line that fills as you read. +.dl-paper-topbar { + position: absolute; + top: 0; + right: 0; + left: 0; + // The letter body keeps a filled transform animation (a persistent + // stacking context), so the letterhead needs an explicit layer above it. + z-index: var(--ds-z-sticky, 20); + display: flex; + align-items: center; + gap: $size-gap-2; + height: 44px; + padding: 0 56px 0 $size-gap-5; + border-bottom: 1px solid var(--ds-color-border-subtle); + background-color: var(--ds-color-bg-scene); + background-image: linear-gradient(var(--ds-color-bg-elevated), var(--ds-color-bg-elevated)); + font-family: var(--ds-font-family-sans); + opacity: 0; + transform: translateY(-100%); + transition: transform $motion-base $easing-ease-out, opacity $motion-base $easing-ease-out; + pointer-events: none; +} + +.dl-paper-shell.is-collapsed .dl-paper-topbar { + opacity: 1; + transform: translateY(0); +} + +.dl-paper-topbar__seed { + width: 7px; + height: 7px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--ds-color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ds-color-accent) 16%, transparent); +} + +.dl-paper-topbar__eyebrow { + flex: 0 0 auto; + font-size: $font-size-sm; + font-weight: $font-weight-semibold; +} + +.dl-paper-topbar__date { + min-width: 0; + margin-left: auto; + overflow: hidden; + color: var(--ds-color-text-tertiary); + font-size: $font-size-xs; + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dl-paper-topbar__progress { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + transform-origin: left center; + background: + linear-gradient( + 90deg, + var(--ds-color-accent) 0%, + color-mix(in srgb, var(--ds-color-accent) 55%, transparent) 100% + ); + transition: transform 90ms linear; +} + +.dl-paper { + --dl-paper-inset: clamp(#{$size-gap-6}, 9%, 64px); + + position: relative; + padding: $size-gap-8 var(--dl-paper-inset) 0; + font-family: $dl-serif; +} + +.dl-paper__postmark { + position: absolute; + top: $size-gap-6; + left: var(--dl-paper-inset); + display: inline-flex; + flex-direction: column; + gap: 2px; + padding: $size-gap-2 $size-gap-3; + border: 1px dashed color-mix(in srgb, var(--ds-color-accent) 45%, var(--ds-color-border-subtle)); + border-radius: $size-radius-base; + color: color-mix(in srgb, var(--ds-color-accent) 80%, var(--ds-color-text-secondary)); + font-family: var(--ds-font-family-sans); + transform: rotate(-3deg); + opacity: 0.85; + + span { + font-size: 10px; + font-weight: $font-weight-semibold; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + strong { + font-size: $font-size-sm; + font-weight: $font-weight-semibold; + font-variant-numeric: tabular-nums; + } +} + +.dl-paper__masthead { + margin-top: $size-gap-8; + text-align: center; + animation: dl-paper-rise 480ms $easing-ease-out both; + animation-delay: 80ms; + + h2 { + margin: 0 0 $size-gap-2; + font-size: $font-size-2xl; + font-weight: $font-weight-medium; + letter-spacing: 0.01em; + } +} + +.dl-paper__eyebrow { + margin: 0 0 $size-gap-2; + color: var(--ds-color-text-tertiary); + font-family: var(--ds-font-family-sans); + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + letter-spacing: 0.22em; + text-transform: uppercase; +} + +.dl-paper__meta { + margin: 0; + color: var(--ds-color-text-tertiary); + font-family: var(--ds-font-family-sans); + font-size: $font-size-xs; +} + +.dl-paper__rule { + position: relative; + display: block; + width: 180px; + height: 1px; + margin: $size-gap-5 auto 0; + background: + linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--ds-color-text-tertiary) 45%, transparent) 18%, + color-mix(in srgb, var(--ds-color-text-tertiary) 45%, transparent) 82%, + transparent 100% + ); + + &::after { + content: ''; + position: absolute; + top: -2.5px; + left: 50%; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ds-color-accent); + transform: translateX(-50%) rotate(45deg); + } +} + +.dl-paper__content { + max-width: 560px; + margin: $size-gap-6 auto 0; + animation: dl-paper-rise 520ms $easing-ease-out both; + animation-delay: 160ms; + + .markdown-renderer { + color: var(--ds-color-text-primary); + font-family: inherit; + font-size: 15px; + line-height: 2; + + p { + margin: 0 0 $size-gap-4; + } + + ul, + ol { + margin: 0 0 $size-gap-4; + } + + li { + margin-bottom: $size-gap-1; + } + + h1, + h2, + h3 { + font-family: inherit; + font-weight: $font-weight-semibold; + } + } +} + +.dl-paper__sign { + position: relative; + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: $size-gap-3; + max-width: 560px; + margin: $size-gap-6 auto 0; + animation: dl-paper-rise 520ms $easing-ease-out both; + animation-delay: 240ms; +} + +.dl-paper__sign-name { + color: var(--ds-color-text-secondary); + font-size: $font-size-base; + font-style: italic; + + &::before { + content: '— '; + color: var(--ds-color-accent); + font-style: normal; + } +} + +.dl-paper__sign-date { + color: var(--ds-color-text-tertiary); + font-family: var(--ds-font-family-sans); + font-size: $font-size-xs; + font-variant-numeric: tabular-nums; +} + +.dl-paper__seal { + position: absolute; + top: 50%; + left: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border: 2px solid color-mix(in srgb, var(--ds-color-success) 55%, transparent); + border-radius: 50%; + color: color-mix(in srgb, var(--ds-color-success) 80%, var(--ds-color-text-secondary)); + font-family: var(--ds-font-family-sans); + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + letter-spacing: 0.06em; + transform: translateY(-50%) rotate(-12deg); + opacity: 0.75; + pointer-events: none; + + &::before { + content: ''; + position: absolute; + inset: 4px; + border: 1px solid color-mix(in srgb, var(--ds-color-success) 35%, transparent); + border-radius: 50%; + } +} + +// Action tray: stays reachable at the bottom of the scroll like the +// edge of a writing desk, bleeding to the sheet's full width. +.dl-paper__actions { + position: sticky; + bottom: 0; + z-index: var(--ds-z-raised, 2); + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-3; + margin: $size-gap-8 calc(-1 * var(--dl-paper-inset)) 0; + padding: $size-gap-3 var(--dl-paper-inset) $size-gap-4; + border-top: 1px solid var(--ds-color-border-subtle); + background-color: var(--ds-color-bg-scene); + background-image: linear-gradient(var(--ds-color-bg-elevated), var(--ds-color-bg-elevated)); + font-family: var(--ds-font-family-sans); + transition: box-shadow $motion-base $easing-ease-out; +} + +// While there is still letter below the fold, the tray lifts slightly so +// it reads as a desk edge rather than the end of the sheet. +.dl-paper-shell:not(.is-at-end) .dl-paper__actions { + box-shadow: 0 -14px 28px -14px var(--ds-shadow-color-modal); +} + +.dl-paper__actions-note { + min-width: 0; +} + +.dl-paper__pending { + color: var(--ds-color-text-warning); + font-size: $font-size-xs; +} + +.dl-paper__actions-buttons { + display: flex; + align-items: center; + gap: $size-gap-2; +} + +@keyframes dl-paper-unfold { + from { + opacity: 0; + transform: perspective(1200px) translateY(26px) rotateX(4deg) scale(0.985); + } + + to { + opacity: 1; + transform: perspective(1200px) translateY(0) rotateX(0deg) scale(1); + } +} + +@keyframes dl-paper-unfold-from-corner { + from { + opacity: 0; + transform: perspective(1200px) translateY(18px) scale(0.6); + } + + 60% { + opacity: 1; + } + + to { + opacity: 1; + transform: perspective(1200px) translateY(0) scale(1); + } +} + +@keyframes dl-paper-rise { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .dl-paper-dialog, + .dl-paper__masthead, + .dl-paper__content, + .dl-paper__sign { + animation: none; + } + + .dl-paper-topbar, + .dl-paper-topbar__progress, + .dl-paper__actions { + transition: none; + } +} + +@media (max-width: 820px) { + .dl-paper { + --dl-paper-inset: #{$size-gap-5}; + + padding-top: $size-gap-6; + } + + .dl-paper-topbar { + padding: 0 48px 0 $size-gap-4; + } + + .dl-paper__actions { + align-items: stretch; + flex-direction: column; + } + + .dl-paper__actions-buttons { + flex-wrap: wrap; + } +} diff --git a/src/web-ui/src/app/scenes/daily-letter/LetterPaper.tsx b/src/web-ui/src/app/scenes/daily-letter/LetterPaper.tsx new file mode 100644 index 00000000..67ff48d0 --- /dev/null +++ b/src/web-ui/src/app/scenes/daily-letter/LetterPaper.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Archive, Check } from 'lucide-react'; +import { Button, Dialog, type DialogProps } from '@/design-system'; +import { Markdown } from '@/shared/markdown'; +import { parseDateKey } from './dailyLetterDateUtils'; +import type { DailyLetterRecord } from './dailyLetterTypes'; +import './LetterPaper.scss'; + +type TFn = (key: string, options?: Record) => string; +type FormatDateFn = (date: Date | number, options?: Intl.DateTimeFormatOptions) => string; + +interface PaperScrollState { + collapsed: boolean; + progress: number; + atEnd: boolean; +} + +/** + * The paper overlay renders its own masthead, so a duplicated leading + * markdown H1 ("# 今日来信 · date") is dropped from the body. + */ +function letterBodyForPaper(markdown: string): string { + const lines = markdown.split('\n'); + let index = 0; + while (index < lines.length && !lines[index].trim()) index += 1; + if (index < lines.length && /^#\s/.test(lines[index].trim())) { + return lines.slice(index + 1).join('\n').replace(/^\s+/, ''); + } + return markdown; +} + +export interface LetterPaperProps { + letter: DailyLetterRecord | null; + open: boolean; + onOpenChange: (open: boolean) => void; + pendingCount: number; + canSeal: boolean; + onSeal: () => void; + formatDate: FormatDateFn; + t: TFn; + /** + * When the paper is opened from a corner-anchored surface (e.g. the + * bottom-left arrival card), the unfold animation grows from that + * corner instead of the viewport center — a cheap way to keep the + * "this letter came from where I just clicked" continuity without a + * real shared-element transition. + */ + originCorner?: boolean; + /** + * Called once, the first time this record is shown open. Lets callers + * (the arrival dock, the scene) mark the letter as read in one place + * regardless of who triggered the open. + */ + onFirstOpen?: (letter: DailyLetterRecord) => void; + dialogProps?: Partial; +} + +export function LetterPaper({ + letter, + open, + onOpenChange, + pendingCount, + canSeal, + onSeal, + formatDate, + t, + originCorner = false, + onFirstOpen, + dialogProps, +}: LetterPaperProps) { + const viewportRef = useRef(null); + const [scroll, setScroll] = useState({ collapsed: false, progress: 0, atEnd: false }); + const announcedIdRef = useRef(null); + + const readScroll = useCallback(() => { + const el = viewportRef.current; + if (!el) return; + const max = el.scrollHeight - el.clientHeight; + const top = el.scrollTop; + setScroll({ + // Collapse the masthead into the compact letterhead bar once the + // large title has mostly left the viewport. + collapsed: top > 132, + progress: max > 0 ? Math.min(1, top / max) : 1, + atEnd: max - top < 24, + }); + }, []); + + // Start every letter from the top and refresh the tray/topbar state + // once the content has been laid out. + useEffect(() => { + if (!open) return; + const el = viewportRef.current; + if (el) el.scrollTop = 0; + const frame = window.requestAnimationFrame(readScroll); + return () => window.cancelAnimationFrame(frame); + }, [letter?.id, open, readScroll]); + + // A letter counts as "read" the moment its paper is actually shown, + // no matter whether the scene or the global arrival card opened it. + useEffect(() => { + if (!open || !letter) return; + if (announcedIdRef.current === letter.id) return; + announcedIdRef.current = letter.id; + onFirstOpen?.(letter); + }, [letter, onFirstOpen, open]); + + if (!letter) return null; + + const scopeLabel = letter.scope === 'agentic_os' ? t('scope.agenticOs') : t('scope.workspace'); + const postmarkDate = `${letter.date.slice(5, 7)} · ${letter.date.slice(8, 10)}`; + let dateLine = letter.date; + try { + dateLine = formatDate(parseDateKey(letter.date), { dateStyle: 'full' }); + } catch { + // keep the raw date key + } + const writtenAt = formatDate(new Date(letter.createdAtMs), { timeStyle: 'short' }); + + return ( + +
+
+
+
+
+ + +
+

{t('paper.eyebrow')}

+

{dateLine}

+

+ {letter.workspace ? `${letter.workspace.name} · ` : ''} + {t('paper.writtenAt', { time: writtenAt })} +

+
+ +
+ +
+ +
+ {letter.status === 'sealed' && ( + + )} + {t('paper.signature')} + {letter.date} +
+ +
+
+ {pendingCount > 0 && ( + {t('paper.pending', { count: pendingCount })} + )} +
+
+ + +
+
+
+
+
+
+ ); +} + +export default LetterPaper; diff --git a/src/web-ui/src/app/scenes/daily-letter/dailyLetterDateUtils.ts b/src/web-ui/src/app/scenes/daily-letter/dailyLetterDateUtils.ts new file mode 100644 index 00000000..d72a9f97 --- /dev/null +++ b/src/web-ui/src/app/scenes/daily-letter/dailyLetterDateUtils.ts @@ -0,0 +1,32 @@ +import type { DateRangeValue } from '@/design-system'; + +export function todayKey(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function parseDateKey(date: string): Date { + return new Date(`${date}T00:00:00`); +} + +export function formatDateKey(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function startOfLocalToday(): Date { + const today = new Date(); + return new Date(today.getFullYear(), today.getMonth(), today.getDate()); +} + +export function defaultDateFilterRange(): DateRangeValue { + const endDate = startOfLocalToday(); + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - 30); + return { startDate, endDate }; +} diff --git a/src/web-ui/src/app/scenes/work-center/ScopeRail/ScopeRail.tsx b/src/web-ui/src/app/scenes/work-center/ScopeRail/ScopeRail.tsx index e3c23f17..a1963089 100644 --- a/src/web-ui/src/app/scenes/work-center/ScopeRail/ScopeRail.tsx +++ b/src/web-ui/src/app/scenes/work-center/ScopeRail/ScopeRail.tsx @@ -2,12 +2,11 @@ import React from 'react'; import { Plus } from 'lucide-react'; import { IconButton } from '@/design-system'; import { useI18n } from '@/infrastructure/i18n'; -import type { WorkCenterScope, WorkCenterView } from '@/app/stores/workDockStore'; -import type { WorkCategory } from '@/app/agentic-os/work/domain/workClassification'; +import type { WorkCenterScope } from '@/app/stores/workDockStore'; +import type { WorkRailSection } from '@/app/agentic-os/work/domain/workClassification'; import './ScopeRail.scss'; interface ScopeRailProps { - view: WorkCenterView; scope: WorkCenterScope; openTotal: number; attentionTotal: number; @@ -18,12 +17,8 @@ interface ScopeRailProps { activeWorkspaceCount: number; workspaceHistoryCount: number; activeWorkspaceRunningTotal: number; - backgroundTotal: number; - backgroundAttentionTotal: number; - backgroundRunningTotal: number; - categoryCounts: Map; + railCounts: Map; onScopeChange: (scope: WorkCenterScope) => void; - onViewChange: (view: WorkCenterView) => void; onQuickCreateWork: () => void; } @@ -39,10 +34,6 @@ interface ScopeItemProps { onClick: () => void; } -/** - * One navigation entry — goal-list style: signal dot, title, bare mono numeral. - * Hierarchy comes from ink opacity, the seed dot, and a soft fill when selected. - */ const ScopeItem: React.FC = ({ title, count, @@ -71,8 +62,13 @@ const ScopeItem: React.FC = ({ ); +function isRailSelected(scope: WorkCenterScope, section: WorkRailSection): boolean { + if (section === 'topic') return scope.kind === 'topic'; + if (section === 'system') return scope.kind === 'system'; + return scope.kind === 'category' && scope.category === section; +} + const ScopeRail: React.FC = ({ - view, scope, openTotal, attentionTotal, @@ -83,21 +79,12 @@ const ScopeRail: React.FC = ({ activeWorkspaceCount, workspaceHistoryCount, activeWorkspaceRunningTotal, - backgroundTotal, - backgroundAttentionTotal, - backgroundRunningTotal, - categoryCounts, + railCounts, onScopeChange, - onViewChange, onQuickCreateWork, }) => { const { t } = useI18n('scenes/work-center'); - const categoryItems: WorkCategory[] = ['long_term', 'recurring']; - const backgroundCountLabel = [ - t('scope.total', { count: backgroundTotal }), - backgroundRunningTotal > 0 ? t('scope.running', { count: backgroundRunningTotal }) : null, - backgroundAttentionTotal > 0 ? t('scope.needsAttention', { count: backgroundAttentionTotal }) : null, - ].filter(Boolean).join(' / '); + const continuityItems: WorkRailSection[] = ['long_term', 'topic', 'recurring', 'system']; const workspaceCountLabel = [ t('scope.openWorkspaces', { count: activeWorkspaceCount }), @@ -108,7 +95,6 @@ const ScopeRail: React.FC = ({ return ( ); diff --git a/src/web-ui/src/app/scenes/work-center/WorkBoard/BoardHeader.tsx b/src/web-ui/src/app/scenes/work-center/WorkBoard/BoardHeader.tsx index 9fbb2abd..e9048a27 100644 --- a/src/web-ui/src/app/scenes/work-center/WorkBoard/BoardHeader.tsx +++ b/src/web-ui/src/app/scenes/work-center/WorkBoard/BoardHeader.tsx @@ -58,18 +58,20 @@ const BoardHeader: React.FC = ({ const { t } = useI18n('scenes/work-center'); const scopeLabel = (() => { if (scope.kind === 'category') { - return t(`category.${scope.category}`); + return t(`rail.${scope.category}`); } if (scope.kind === 'open') return t('scope.openWork'); if (scope.kind === 'attention') return t('scope.attention'); if (scope.kind === 'running') return t('scope.runningWorks'); + if (scope.kind === 'topic') return t('rail.topic'); + if (scope.kind === 'system') return t('rail.system'); if (scope.kind === 'all') return t('scope.unarchivedWork'); if (scope.kind === 'completed') return t('scope.completedWork'); if (scope.kind === 'archived') return t('scope.archivedWork'); if (scope.kind === 'workspaces') return t('scope.workspaces'); return t('scope.openWork'); })(); - + const showScopeFilters = showWorkspaceFilter && scope.kind !== 'running'; const groupingOptions: WorkCenterGrouping[] = ['priority', 'status', 'kind', 'time']; const workspaceOptions = useMemo(() => [ { label: t('workspaceFilter.all'), value: 'all' }, @@ -129,7 +131,7 @@ const BoardHeader: React.FC = ({ size="small" clearable /> - {showWorkspaceFilter && workspaces.length > 0 ? ( + {showScopeFilters && workspaces.length > 0 ? ( <>