merge: sync orchestration before queue 00001KVSKJ0EA

This commit is contained in:
2026-06-23 15:47:17 +09:00
25 changed files with 1453 additions and 394 deletions
+7 -1
View File
@@ -4,8 +4,14 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
[features]
default = ["stream"]
stream = ["dep:tokio"]
typescript = ["dep:ts-rs"]
[dependencies]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["io-util"] }
tokio = { workspace = true, features = ["io-util"], optional = true }
ts-rs = { version = "12.0.1", optional = true }
uuid = { workspace = true, features = ["serde"] }
@@ -0,0 +1,16 @@
#[cfg(feature = "typescript")]
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = protocol::typescript::generated_typescript_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, protocol::typescript::generated_protocol_types())?;
println!("wrote {}", path.display());
Ok(())
}
#[cfg(not(feature = "typescript"))]
fn main() {
eprintln!("enable the `typescript` feature to generate protocol TypeScript bindings");
std::process::exit(2);
}
+36
View File
@@ -1,4 +1,7 @@
#[cfg(feature = "stream")]
pub mod stream;
#[cfg(feature = "typescript")]
pub mod typescript;
use std::path::PathBuf;
@@ -21,6 +24,7 @@ fn is_false(value: &bool) -> bool {
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
pub enum Method {
Run {
@@ -103,6 +107,7 @@ pub enum Method {
/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same
/// child Pod).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PodEvent {
/// Child finished one turn and is back to IDLE.
@@ -175,6 +180,7 @@ impl PodEvent {
/// placeholder into the LLM context so neither user nor LLM is blind to
/// the dropped intent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Segment {
/// Free-form text. The fallback every client can produce.
@@ -266,6 +272,7 @@ impl Method {
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
pub enum Event {
/// A user input message was accepted, persisted as
@@ -294,6 +301,7 @@ pub enum Event {
/// One event per `LogEntry::SystemItem` commit. Disk-side and
/// wire-side are 1:1.
SystemItem {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
item: serde_json::Value,
},
/// A new self-driving cycle has begun (IDLE → active transition).
@@ -453,6 +461,7 @@ pub enum Event {
/// role-specific entry events (`SegmentRotated` / `SystemItem`) —
/// there is no generic "every committed entry" broadcast.
Snapshot {
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
entries: Vec<serde_json::Value>,
greeting: Greeting,
#[serde(default)]
@@ -471,6 +480,7 @@ pub enum Event {
///
/// Payload is the JSON form of `session_store::LogEntry::SegmentStart`.
SegmentRotated {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
entry: serde_json::Value,
},
/// Current Pod controller status. Broadcast on every controller-level
@@ -495,6 +505,7 @@ pub enum Event {
/// A rewind has truncated the authoritative session. `entries` is the
/// retained session-log prefix clients should use to reseed display state.
RewindApplied {
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
entries: Vec<serde_json::Value>,
input: Vec<Segment>,
summary: RewindSummary,
@@ -503,14 +514,17 @@ pub enum Event {
/// crate can evolve discovery fields without introducing a protocol
/// dependency on session-store.
PodsListed {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
pods: serde_json::Value,
},
/// Reply to `Method::RestorePod`.
PodRestored {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
result: serde_json::Value,
},
/// Reply to `Method::RegisterPeer`.
PeerRegistered {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
result: serde_json::Value,
},
Alert(Alert),
@@ -530,6 +544,7 @@ pub enum Event {
/// `new_segment_id` is the UUID of the freshly created session that
/// replaced the old history.
CompactDone {
#[cfg_attr(feature = "typescript", ts(type = "string"))]
new_segment_id: uuid::Uuid,
},
/// Compaction failed. The session is unchanged.
@@ -546,6 +561,7 @@ pub enum Event {
/// surfaced to the person driving the client. Keep messages short and
/// human-readable.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct Alert {
pub level: AlertLevel,
pub source: AlertSource,
@@ -555,6 +571,7 @@ pub struct Alert {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct MemoryWorkerEvent {
pub worker: String,
pub status: String,
@@ -568,6 +585,7 @@ pub struct MemoryWorkerEvent {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum AlertLevel {
Warn,
@@ -575,6 +593,7 @@ pub enum AlertLevel {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum AlertSource {
Pod,
@@ -591,6 +610,7 @@ pub enum AlertSource {
/// nailed down here so the TUI side can ship without waiting for
/// the memory / workflow tickets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompletionKind {
File,
@@ -605,6 +625,7 @@ pub enum CompletionKind {
/// keep a trailing `/` after a directory selection so the user can
/// drill in without re-typing the prefix.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct CompletionEntry {
pub value: String,
#[serde(default)]
@@ -612,12 +633,15 @@ pub struct CompletionEntry {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct RewindTargetId {
#[cfg_attr(feature = "typescript", ts(type = "string"))]
pub segment_id: uuid::Uuid,
pub user_input_entry_index: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct RewindTarget {
pub id: RewindTargetId,
pub expected_head_entries: usize,
@@ -633,6 +657,7 @@ pub struct RewindTarget {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct RewindSummary {
pub truncated_to_entries: usize,
pub discarded_entries: usize,
@@ -647,6 +672,7 @@ pub struct RewindSummary {
/// history. Finalized assistant items continue to come from ordinary snapshot
/// entries.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct InFlightSnapshot {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocks: Vec<InFlightBlock>,
@@ -659,6 +685,7 @@ impl InFlightSnapshot {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InFlightBlock {
Text {
@@ -681,6 +708,7 @@ pub enum InFlightBlock {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum InFlightToolCallState {
#[default]
@@ -701,6 +729,7 @@ impl InFlightToolCallState {
/// transmitted alongside `Event::Snapshot` so clients don't need
/// their own view of the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct Greeting {
pub pod_name: String,
pub cwd: String,
@@ -721,6 +750,7 @@ pub struct Greeting {
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PodStatus {
#[default]
@@ -730,6 +760,7 @@ pub enum PodStatus {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum TurnResult {
Finished,
@@ -743,6 +774,7 @@ pub enum TurnResult {
/// notify message, pod event body) is delivered by the immediately
/// following Turn entry, not by the marker itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum InvokeKind {
/// `Method::Run` — a user submission.
@@ -762,6 +794,7 @@ pub enum InvokeKind {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RunResult {
Finished,
@@ -775,6 +808,7 @@ pub enum RunResult {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
AlreadyRunning,
@@ -796,6 +830,7 @@ pub enum ErrorCode {
/// A single allow or deny rule inside a scope configuration.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct ScopeRule {
/// Target path. Must be absolute by the time a `Scope` is built from
/// this rule — relative paths are resolved per-layer against the
@@ -822,6 +857,7 @@ fn default_recursive() -> bool {
/// everything below); deny rules cap the effective level **strictly
/// below** the stated level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "lowercase")]
pub enum Permission {
Read,
+97
View File
@@ -0,0 +1,97 @@
use std::path::PathBuf;
use ts_rs::{Config, TS};
use crate::{
Alert, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting,
InFlightBlock, InFlightSnapshot, InFlightToolCallState, InvokeKind, MemoryWorkerEvent, Method,
Permission, PodEvent, PodStatus, RewindSummary, RewindTarget, RewindTargetId, RunResult,
ScopeRule, Segment, TurnResult,
};
const GENERATED_RELATIVE_PATH: &str = "../../web/workspace/src/lib/generated/protocol.ts";
/// Repository-relative destination for the Workspace web protocol bindings.
pub fn generated_typescript_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_RELATIVE_PATH)
}
/// Render Workspace web TypeScript bindings for the Pod wire protocol DTOs.
///
/// Rust DTOs in this crate remain the source of truth; this function is used by
/// both the checked-in artifact generator and the stale-output drift test.
pub fn generated_protocol_types() -> String {
let cfg = Config::new().with_large_int("number");
let mut output = String::from(
"// @generated by `cargo run -p protocol --example generate_typescript --features typescript`\n\
// Do not edit by hand. Rust DTO authority lives in `crates/protocol`.\n\
// Large integer fields are JSON numbers on the wire and are emitted as TypeScript `number`.\n\n",
);
push_decl::<AlertLevel>(&cfg, &mut output);
push_decl::<AlertSource>(&cfg, &mut output);
push_decl::<CompletionKind>(&cfg, &mut output);
push_decl::<PodStatus>(&cfg, &mut output);
push_decl::<TurnResult>(&cfg, &mut output);
push_decl::<InvokeKind>(&cfg, &mut output);
push_decl::<RunResult>(&cfg, &mut output);
push_decl::<ErrorCode>(&cfg, &mut output);
push_decl::<Permission>(&cfg, &mut output);
push_decl::<InFlightToolCallState>(&cfg, &mut output);
push_decl::<ScopeRule>(&cfg, &mut output);
push_decl::<CompletionEntry>(&cfg, &mut output);
push_decl::<RewindTargetId>(&cfg, &mut output);
push_decl::<RewindTarget>(&cfg, &mut output);
push_decl::<RewindSummary>(&cfg, &mut output);
push_decl::<InFlightBlock>(&cfg, &mut output);
push_decl::<InFlightSnapshot>(&cfg, &mut output);
push_decl::<Greeting>(&cfg, &mut output);
push_decl::<Alert>(&cfg, &mut output);
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
push_decl::<Segment>(&cfg, &mut output);
push_decl::<PodEvent>(&cfg, &mut output);
push_decl::<Method>(&cfg, &mut output);
push_decl::<Event>(&cfg, &mut output);
normalize_typescript(output)
}
fn normalize_typescript(output: String) -> String {
let mut lines = output.lines().map(str::trim_end).collect::<Vec<_>>();
while lines.last() == Some(&"") {
lines.pop();
}
lines.join("\n") + "\n"
}
fn push_decl<T: TS>(cfg: &Config, output: &mut String) {
let decl = T::decl(cfg);
output.push_str(&export_decl(&decl));
output.push_str("\n\n");
}
fn export_decl(decl: &str) -> String {
for prefix in ["type ", "interface ", "enum "] {
if decl.starts_with(prefix) {
return format!("export {decl}");
}
}
decl.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_protocol_types_are_current() {
let expected = generated_protocol_types();
let path = generated_typescript_path();
let actual = std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
assert_eq!(
actual, expected,
"generated TypeScript protocol bindings are stale; run `cargo run -p protocol --example generate_typescript --features typescript`"
);
}
}
-52
View File
@@ -1437,31 +1437,6 @@ impl DashboardApp {
}
}
pub(crate) fn selected_open_disabled_reason(&self) -> Option<String> {
if let Some(row) = self
.selected_panel_row()
.filter(|row| row.is_ticket_action())
{
return Some(
row.disabled_reason
.clone()
.or_else(|| row.key_hint.clone())
.unwrap_or_else(|| {
"Enter dispatches this Ticket action after re-checking current Ticket authority."
.to_string()
}),
);
}
if let Some(entry) = self.selected_pod_entry() {
if entry.actions.can_open {
return None;
}
return Some(open_disabled_reason(entry));
}
self.selected_panel_row()
.and_then(|row| row.disabled_reason.clone().or_else(|| row.key_hint.clone()))
}
pub(crate) fn select_next(&mut self) {
let visible = visible_panel_keys(&self.panel, &self.list);
if visible.is_empty() {
@@ -5037,33 +5012,6 @@ fn segments_are_blank(segments: &[Segment]) -> bool {
})
}
fn open_disabled_reason(entry: &PodListEntry) -> String {
if let Some(live) = entry.live.as_ref() {
if !live.reachable {
return "Selected live Pod is unreachable.".to_string();
}
return match live.status {
Some(PodStatus::Running) => {
"Selected Pod is running; Enter opens/attaches for inspection.".to_string()
}
Some(PodStatus::Paused) => {
"Selected Pod is paused; open it explicitly to resume or start a new turn."
.to_string()
}
Some(PodStatus::Idle) => "Selected Pod can be opened/attached.".to_string(),
None => "Selected Pod did not report a live status.".to_string(),
};
}
if entry.stored.is_some() {
return "Selected Pod is stopped; Enter restores/opens for inspection.".to_string();
}
entry
.actions
.disabled_reason
.clone()
.unwrap_or_else(|| "Selected Pod cannot be opened from this row.".to_string())
}
fn selected_ticket_notice(row: Option<&PanelRow>) -> String {
match row {
Some(row) if row.is_ticket_action() => {
+15 -261
View File
@@ -57,24 +57,14 @@ pub(super) fn input_area_height(render: &crate::input::InputRender, terminal_hei
}
pub(super) fn draw_title(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) {
let guidance = if app
.panel
.composer
.is_available(ComposerTarget::TicketIntake)
{
" Row selection: blank Enter opens/dispatches · text Enter uses target · Tab target"
} else if app.panel.header.ticket_configured {
" Row selection: blank Enter opens/dispatches · text Enter sends to Companion"
} else {
" Pod-centric view · Row selection: blank Enter opens · text Enter sends to Companion"
};
let mut spans = vec![
Span::styled(
"workspace dashboard",
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(guidance, Style::default().fg(Color::DarkGray)),
];
frame.render_widget(Paragraph::new(title_line(app)), area);
}
pub(super) fn title_line(app: &DashboardApp) -> Line<'static> {
let mut spans = vec![Span::styled(
"workspace dashboard",
Style::default().add_modifier(Modifier::BOLD),
)];
if let Some(companion) = &app.panel.header.companion {
spans.push(Span::styled(
" · companion ",
@@ -101,7 +91,7 @@ pub(super) fn draw_title(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect)
orchestrator_status_style(orchestrator.status),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
Line::from(spans)
}
pub(super) fn companion_status_style(status: CompanionPanelStatus) -> Style {
@@ -688,115 +678,8 @@ pub(super) fn draw_target_status(frame: &mut Frame<'_>, app: &DashboardApp, area
frame.render_widget(Paragraph::new(target_status_line(app)), area);
}
pub(super) fn target_status_line(app: &DashboardApp) -> Line<'static> {
if !app.composer_is_blank() {
return Line::from(vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(" · draft Enter ", Style::default().fg(Color::DarkGray)),
Span::styled(
composer_enter_status_text(app),
Style::default().fg(Color::Green),
),
Span::styled(
" · row selection waits until composer is blank",
Style::default().fg(Color::DarkGray),
),
]);
}
if let Some(row) = app
.selected_panel_row()
.filter(|row| row.is_ticket_action())
{
let action = row
.next_action
.map(|action| panel_ticket_action_label(row, action))
.unwrap_or("View");
let mut spans = vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::styled(" · selected Ticket ", Style::default().fg(Color::DarkGray)),
Span::styled(row.status.clone(), panel_priority_style(row.priority)),
Span::styled(" · blank Enter ", Style::default().fg(Color::DarkGray)),
Span::styled(action, Style::default().fg(Color::Magenta)),
];
if let Some(reason) = panel_ticket_reason(row) {
spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
truncate_with_ellipsis(reason, 100),
ticket_detail_style(row),
));
}
Line::from(spans)
} else if let Some(row) = app
.selected_panel_row()
.filter(|row| row.kind == PanelRowKind::TicketIntakePod)
{
let ticket_id = panel_ticket_reference(row);
let action = if row.next_action == Some(NextUserAction::OpenPod) {
"open/attach"
} else {
"unavailable"
};
Line::from(vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" · selected Intake Pod ",
Style::default().fg(Color::DarkGray),
),
Span::styled(row.status.clone(), intake_status_style(&row.status)),
Span::styled(
format!(" · Ticket {ticket_id} · blank Enter {action}"),
Style::default().fg(Color::DarkGray),
),
])
} else if let Some(entry) = app.selected_pod_entry() {
let (status, status_style) = row_status_label(entry);
Line::from(vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(" · selected Pod ", Style::default().fg(Color::DarkGray)),
Span::styled(status.to_string(), status_style),
Span::styled(
" · blank Enter open/attach",
Style::default().fg(Color::DarkGray),
),
])
} else {
Line::from(vec![
Span::styled("composer target ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.composer_target().label(),
Style::default().fg(Color::DarkGray),
),
Span::styled(
" · no row selected · ↑/↓ selects a row",
Style::default().fg(Color::DarkGray),
),
])
}
pub(super) fn target_status_line(_app: &DashboardApp) -> Line<'static> {
Line::from(Span::raw(""))
}
pub(super) fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) {
@@ -817,103 +700,6 @@ pub(super) fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRend
}
}
pub(super) fn composer_enter_status_text(app: &DashboardApp) -> String {
match app.composer_target() {
ComposerTarget::Companion => companion_enter_status_text(app),
ComposerTarget::TicketIntake
if app.selected_ticket_action() == Some(NextUserAction::Queue) =>
{
"return selected ready Ticket to planning".to_string()
}
ComposerTarget::TicketIntake => "launch Intake with composer text".to_string(),
}
}
pub(super) fn composer_enter_actionbar_text(app: &DashboardApp) -> String {
match app.composer_target() {
ComposerTarget::Companion => companion_enter_actionbar_text(app),
ComposerTarget::TicketIntake if app.selected_ticket_action() == Some(NextUserAction::Queue) => {
"Ticket Intake target: Enter records instructions and returns selected ready Ticket to planning".to_string()
}
ComposerTarget::TicketIntake => {
"Ticket Intake target: Enter launches Intake with composer text".to_string()
}
}
}
pub(super) fn companion_enter_status_text(app: &DashboardApp) -> String {
match companion_send_availability(app) {
CompanionSendAvailability::Ready => "send composer text to workspace Companion".to_string(),
CompanionSendAvailability::Unavailable(reason) => format!("keep draft; {reason}"),
}
}
pub(super) fn companion_enter_actionbar_text(app: &DashboardApp) -> String {
match companion_send_availability(app) {
CompanionSendAvailability::Ready => {
"Companion target: Enter sends composer text to workspace Companion".to_string()
}
CompanionSendAvailability::Unavailable(reason) => {
format!("Companion target: Enter keeps draft; {reason}")
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum CompanionSendAvailability {
Ready,
Unavailable(String),
}
pub(super) fn companion_send_availability(app: &DashboardApp) -> CompanionSendAvailability {
let Some(companion) = app.panel.header.companion.as_ref() else {
return CompanionSendAvailability::Unavailable(
"workspace Companion is unavailable".to_string(),
);
};
if matches!(
companion.status,
CompanionPanelStatus::Unavailable
| CompanionPanelStatus::Missing
| CompanionPanelStatus::Stopped
) {
return CompanionSendAvailability::Unavailable(format!(
"workspace Companion is {}",
companion.status.label()
));
}
let Some(entry) = app
.list
.entries
.iter()
.find(|entry| entry.name == companion.pod_name)
else {
return CompanionSendAvailability::Unavailable(format!(
"workspace Companion `{}` is not in the Pod list",
companion.pod_name
));
};
let Some(live) = entry.live.as_ref() else {
return CompanionSendAvailability::Unavailable(format!(
"workspace Companion `{}` is stopped",
companion.pod_name
));
};
if !live.reachable {
return CompanionSendAvailability::Unavailable(format!(
"workspace Companion `{}` is unreachable",
companion.pod_name
));
}
if live.status == Some(PodStatus::Running) {
return CompanionSendAvailability::Unavailable(format!(
"workspace Companion `{}` is running",
companion.pod_name
));
}
CompanionSendAvailability::Ready
}
pub(super) fn actionbar_left_text(app: &DashboardApp) -> String {
if app.sending && app.composer_target() == ComposerTarget::TicketIntake {
"launching Ticket Intake…".to_string()
@@ -927,52 +713,20 @@ pub(super) fn actionbar_left_text(app: &DashboardApp) -> String {
Some(notice) => format!("{notice} Refreshing workspace…"),
None => "Refreshing workspace…".to_string(),
}
} else if !app.composer_is_blank() {
composer_enter_actionbar_text(app)
} else if let Some(notice) = app.notice.as_deref() {
notice.to_string()
} else if let Some(reason) = app.selected_open_disabled_reason() {
reason
} else {
match app.composer_target() {
ComposerTarget::Companion => {
"Composer target: Companion; type text to send, or use ↑/↓ then blank Enter for rows"
.to_string()
}
ComposerTarget::TicketIntake => {
if app.selected_ticket_action() == Some(NextUserAction::Queue) {
"Composer target: Ticket Intake; text + Enter returns selected ready Ticket to planning".to_string()
} else {
"Composer target: Ticket Intake; type a request, then Enter launches Intake".to_string()
}
}
}
String::new()
}
}
pub(super) fn actionbar_right_text(app: &DashboardApp) -> &'static str {
if app.panel_diagnostic_open {
"F2/Esc close details Ctrl+C quit"
"F2/Esc close details"
} else if app.panel_diagnostic.is_some() {
"F2 details ↑/↓ select row Enter selected row Tab target Esc clear selection Left/Right cursor Ctrl+C quit"
} else if !app.composer_is_blank() {
if app
.panel
.composer
.is_available(ComposerTarget::TicketIntake)
{
"↑/↓ draft lines Left/Right cursor Enter composer target Tab target Esc clear selection Ctrl+C quit"
} else {
"↑/↓ draft lines Left/Right cursor Enter composer target Esc clear selection Ctrl+C quit"
}
} else if app
.panel
.composer
.is_available(ComposerTarget::TicketIntake)
{
"↑/↓ select row Enter selected row Tab target Esc clear selection Left/Right cursor Ctrl+C quit"
"F2 details"
} else {
"↑/↓ select row Enter selected row Esc clear selection Left/Right cursor Ctrl+C quit"
""
}
}
+45 -20
View File
@@ -1072,7 +1072,9 @@ fn mouse_click_selects_panel_row_for_blank_enter_action() {
);
assert_eq!(app.selected_panel_row().unwrap().title, "Queued");
assert_eq!(app.selected_ticket_action(), Some(NextUserAction::Wait));
assert!(plain_line(&target_status_line(&app)).contains("blank Enter Wait"));
let selected_title =
plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]);
assert!(selected_title.starts_with("▶ queued"));
assert!(matches!(
app.handle_key(key(KeyCode::Enter)),
DashboardAction::DispatchTicketAction(request) if request.ticket_id == "TICKET-2"
@@ -1152,7 +1154,7 @@ fn mouse_click_does_not_override_existing_composer_keyboard_behavior() {
}
#[test]
fn selected_ticket_row_with_non_empty_composer_shows_composer_enter_behavior() {
fn selected_ticket_row_with_non_empty_composer_hides_redundant_status_hints() {
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
panel.header.companion = Some(CompanionPanelState::new(
"yoi",
@@ -1185,14 +1187,12 @@ fn selected_ticket_row_with_non_empty_composer_shows_composer_enter_behavior() {
let actionbar_right = actionbar_right_text(&app);
let target_status = plain_line(&target_status_line(&app));
assert!(actionbar_left.contains("Companion target: Enter sends composer text"));
assert!(actionbar_right.contains("Enter composer target"));
assert!(!actionbar_left.contains("Queue"));
assert!(!actionbar_right.contains("selected row"));
assert!(target_status.contains("composer target Companion"));
assert!(target_status.contains("draft Enter send composer text to workspace Companion"));
assert!(target_status.contains("row selection waits until composer is blank"));
assert!(!target_status.contains("blank Enter Queue"));
assert_eq!(actionbar_left, "");
assert_eq!(actionbar_right, "");
assert_eq!(target_status, "");
let selected_title =
plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]);
assert!(selected_title.starts_with("▶ ready"));
}
#[test]
@@ -1529,7 +1529,6 @@ fn dashboard_idle_live_selected_target_is_open_eligible() {
let app = test_app(vec![live_info("idle", PodStatus::Idle)]);
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
assert!(app.selected_open_disabled_reason().is_none());
}
#[test]
@@ -1557,6 +1556,35 @@ fn dashboard_status_labels_preserve_explicit_live_statuses() {
}
}
#[test]
fn dashboard_title_omits_redundant_key_hint_guidance() {
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
panel.header.ticket_configured = true;
panel.header.companion = Some(CompanionPanelState::new(
"yoi",
CompanionPanelStatus::Live,
Some("idle".to_string()),
));
let app = app_with_panel(
PodList::from_sources(
PodVisibilitySource::ResumePicker,
vec![],
vec![live_info("yoi", PodStatus::Idle)],
None,
10,
),
panel,
);
let title = plain_line(&title_line(&app));
assert!(title.contains("workspace dashboard"));
assert!(title.contains("companion live"));
assert!(!title.contains("Row selection"));
assert!(!title.contains("blank Enter"));
assert!(!title.contains("Tab target"));
}
#[test]
fn panel_ticket_rows_render_state_title_then_detail_line() {
let row = panel_test_ticket_row(
@@ -1725,7 +1753,7 @@ fn panel_ticket_intake_child_rows_render_as_indented_single_line() {
}
#[test]
fn selected_ticket_intake_child_status_is_not_rendered_as_generic_ticket_or_pod() {
fn selected_ticket_intake_child_keeps_row_marker_without_status_line() {
let ticket_id = "00001TICKET";
let pod_name = "intake-live";
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
@@ -1750,11 +1778,11 @@ fn selected_ticket_intake_child_status_is_not_rendered_as_generic_ticket_or_pod(
let status = plain_line(&target_status_line(&app));
assert!(status.contains("selected Intake Pod live"));
assert!(status.contains("Ticket 00001TICKET"));
assert!(status.contains("blank Enter open/attach"));
assert!(!status.contains("selected Ticket"));
assert!(!status.contains("selected Pod live"));
assert_eq!(status, "");
let title_line = plain_line(&panel_row_lines(app.selected_panel_row().unwrap(), true, 80)[0]);
assert!(title_line.starts_with(" ▶ live"));
assert!(title_line.contains("Intake"));
}
#[test]
@@ -1828,15 +1856,12 @@ fn dashboard_running_paused_and_stopped_targets_are_open_eligible() {
app.ensure_selection_visible();
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
assert!(app.selected_open_disabled_reason().is_none());
app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "paused");
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
assert!(app.selected_open_disabled_reason().is_none());
app.select_next();
assert_eq!(app.list.selected_entry().unwrap().name, "stopped");
assert_eq!(app.selected_open_eligibility(), OpenEligibility::OpenNow);
assert!(app.selected_open_disabled_reason().is_none());
}
#[test]
+2 -2
View File
@@ -250,11 +250,11 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
store: "sqlite".to_string(),
event_stream: ExtensionPointState {
status: "reserved".to_string(),
note: "No event stream is exposed in this bootstrap; route/state seams are reserved.".to_string(),
note: "No browser-to-Pod socket path is exposed in this bootstrap; any future stream must be a Workspace server proxy that resolves Worker identity and enforces method allow/block boundaries.".to_string(),
},
host_worker_bridge: ExtensionPointState {
status: "read_only_local".to_string(),
note: "Local Hosts and Workers are exposed as a read-only bridge over existing Pod metadata; no scheduling or lifecycle control is implemented.".to_string(),
note: "Local Hosts and Workers are exposed as a read-only bridge over existing Pod metadata; no direct Pod socket, scheduling, or lifecycle control is implemented.".to_string(),
},
},
}))