Skip to repository content2604 lines · 103.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:47:59.097Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
panel.rs
1//! Sarah workroom dock panel (`OMEGA-SW-03` / `OMEGA-SW-04` / `OMEGA-SW-06` /
2//! `SARAH-CW-08`).
3//!
4//! Projection + command entry only. Durable state lives in the record behind
5//! supervised `omega-effectd`. Owner-private conversation header is "Sarah".
6//! Community room header is "Community" — same dock pane, never a second pane.
7//!
8//! OMEGA-SW-04: interaction states (pending send, running after claim, ordered
9//! tool ladder, answer block + completion, terminal reason, interrupt
10//! pending→applied). Transport is SARAH-NR-06. Honest liveness is the tool
11//! ladder — never fake token streaming.
12//!
13//! OMEGA-SW-06: local unread count + attention marker. Proactive tick turns
14//! share the transcript projection (no new source). Read state is local only.
15//!
16//! SARAH-CW-08: switch between owner-private and community rooms in this pane.
17//! Membership, work units, and experience rank are community-only projections.
18//! Two-room rule: rooms never share membership or history.
19
20use std::time::Duration;
21
22use anyhow::Result;
23use editor::Editor;
24use gpui::{
25 App, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
26 InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Task, WeakEntity,
27 Window, div, px,
28};
29use omega_effectd::{
30 BindingProjection, BindingState, Issue31GrantProjection, OpenAgentsBinding,
31 SharedOmegaEffectdSupervisor, shared_supervisor, try_openagents_binding,
32};
33use serde_json::{Value, json};
34use ui::{Button, ButtonStyle, Label, LabelSize, prelude::*};
35use util::ResultExt as _;
36use uuid::Uuid;
37use workspace::{
38 Workspace,
39 dock::{DockPosition, Panel, PanelEvent},
40};
41use zed_actions::workroom::{FocusComposer, InterruptTurn, OpenPanel, SendMessage};
42
43use crate::attention::{AttentionMarker, OMEGA_AUTONOMOUS_TICK_ENABLED, empty_room_is_honest};
44use crate::community::{
45 COMMUNITY_ROOM_HEADER, COMMUNITY_ROOM_SUBTITLE, CommunityRoomProjection, EXPERIENCE_LABEL,
46 OWNER_PRIVATE_ROOM_HEADER, RoomKind, V1_NO_PAY_FIRST_RUN_COPY, V1_NO_PAY_ROOM_DESCRIPTION,
47};
48use crate::full_auto::WorkroomFullAutoRun;
49use crate::interaction::{AnswerState, InteractionEvent, InteractionState, TerminalOutcome};
50use crate::projections::{
51 ActivityProjection, ActivityRow, Freshness, GapState, InterruptIntentState, MessageAck,
52 ProjectionMeta, ReceiptRow, ReceiptsProjection, RoomProjection, RunPhase, RunStateProjection,
53 TranscriptProjection, TranscriptRow, WorkroomProjection, sources,
54};
55
56const PANEL_KEY: &str = "SarahWorkroomPanel";
57const MAX_NOSTR_RECORD_ROWS: usize = 64;
58
59#[derive(Clone, Debug)]
60struct NostrRecordRow {
61 event_id: String,
62 kind: u16,
63 record_kind: String,
64 author_fingerprint: String,
65 created_at: String,
66 source: String,
67}
68
69#[derive(Clone, Debug)]
70struct NostrRecordsProjection {
71 rows: Vec<NostrRecordRow>,
72 cursor: Option<String>,
73 next_cursor: Option<String>,
74 gap: GapState,
75 source: String,
76 detail: Option<String>,
77 truncated: bool,
78}
79
80impl NostrRecordsProjection {
81 fn unavailable(detail: impl Into<String>) -> Self {
82 Self {
83 rows: Vec::new(),
84 cursor: None,
85 next_cursor: None,
86 gap: GapState::Unavailable,
87 source: "confirmed_nostr".into(),
88 detail: Some(detail.into()),
89 truncated: false,
90 }
91 }
92}
93
94pub struct SarahWorkroomPanel {
95 _workspace: WeakEntity<Workspace>,
96 focus_handle: FocusHandle,
97 composer: Entity<Editor>,
98 projection: WorkroomProjection,
99 /// SARAH-CW-08: community room projections (isolated from owner-private).
100 community: CommunityRoomProjection,
101 /// SARAH-CW-08: which room this single pane is showing.
102 active_room: RoomKind,
103 /// OMEGA-SW-04 pure interaction projection (pending/send/ladder/terminal).
104 interaction: InteractionState,
105 status: SharedString,
106 supervisor: Option<SharedOmegaEffectdSupervisor>,
107 binding: Option<OpenAgentsBinding>,
108 binding_projection: BindingProjection,
109 binding_busy: bool,
110 refreshing: bool,
111 sending: bool,
112 interrupting: bool,
113 full_auto_runs: Vec<WorkroomFullAutoRun>,
114 full_auto_detail: Option<String>,
115 device_grants: Vec<Issue31GrantProjection>,
116 device_grants_detail: Option<String>,
117 nostr_records: NostrRecordsProjection,
118 grant_busy: Option<String>,
119 _refresh: Option<Task<()>>,
120 public_demo: bool,
121}
122
123pub fn init(cx: &mut App) {
124 cx.observe_new(|workspace: &mut Workspace, _, _| {
125 workspace
126 .register_action(|workspace, _: &OpenPanel, window, cx| {
127 workspace.focus_panel::<SarahWorkroomPanel>(window, cx);
128 if let Some(panel) = workspace.panel::<SarahWorkroomPanel>(cx) {
129 // Local mark-read when the owner opens the room (OMEGA-SW-06).
130 panel.update(cx, |panel, cx| panel.mark_room_read(cx));
131 }
132 })
133 .register_action(|workspace, _: &FocusComposer, window, cx| {
134 if let Some(panel) = workspace.panel::<SarahWorkroomPanel>(cx) {
135 panel.update(cx, |panel, cx| panel.focus_composer(window, cx));
136 }
137 workspace.focus_panel::<SarahWorkroomPanel>(window, cx);
138 })
139 .register_action(|workspace, _: &SendMessage, window, cx| {
140 if let Some(panel) = workspace.panel::<SarahWorkroomPanel>(cx) {
141 panel.update(cx, |panel, cx| panel.send_message(window, cx));
142 }
143 workspace.focus_panel::<SarahWorkroomPanel>(window, cx);
144 })
145 .register_action(|workspace, _: &InterruptTurn, window, cx| {
146 if let Some(panel) = workspace.panel::<SarahWorkroomPanel>(cx) {
147 panel.update(cx, |panel, cx| panel.interrupt_turn(cx));
148 }
149 workspace.focus_panel::<SarahWorkroomPanel>(window, cx);
150 });
151 })
152 .detach();
153}
154
155impl SarahWorkroomPanel {
156 pub fn load(
157 workspace: WeakEntity<Workspace>,
158 cx: AsyncWindowContext,
159 ) -> Task<Result<Entity<Self>>> {
160 cx.spawn(async move |cx| {
161 let workspace_for_panel = workspace.clone();
162 workspace.update_in(cx, |_workspace, window, cx| {
163 Ok(cx.new(|cx| Self::new(workspace_for_panel, window, cx)))
164 })?
165 })
166 }
167
168 fn new(workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
169 let public_demo = crate::public_demo_mode();
170 let composer = cx.new(|cx| {
171 let mut editor = Editor::multi_line(window, cx);
172 editor.set_placeholder_text("Message Sarah (text only).", window, cx);
173 editor
174 });
175 let binding = if public_demo {
176 None
177 } else {
178 try_openagents_binding(cx)
179 };
180 let binding_projection = binding
181 .as_ref()
182 .map(|binding| binding.load_projection())
183 .unwrap_or_else(BindingProjection::unbound);
184 let mut panel = Self {
185 _workspace: workspace,
186 focus_handle: cx.focus_handle(),
187 composer,
188 projection: if public_demo {
189 WorkroomProjection::public_demo()
190 } else {
191 WorkroomProjection::honest_unsubscribed()
192 },
193 community: CommunityRoomProjection::honest_unsubscribed(),
194 active_room: RoomKind::OwnerPrivate,
195 interaction: InteractionState::new(),
196 status: if public_demo {
197 "Public demo · fictional data · isolated profile".into()
198 } else {
199 binding_projection.state.status_line().into()
200 },
201 supervisor: None,
202 binding,
203 binding_projection,
204 binding_busy: false,
205 refreshing: false,
206 sending: false,
207 interrupting: false,
208 full_auto_runs: Vec::new(),
209 full_auto_detail: Some("Full Auto records have not been refreshed yet.".into()),
210 device_grants: Vec::new(),
211 device_grants_detail: Some("Device grants have not been refreshed yet.".into()),
212 nostr_records: NostrRecordsProjection::unavailable(
213 "Confirmed Nostr record references have not been refreshed yet.",
214 ),
215 grant_busy: None,
216 _refresh: None,
217 public_demo,
218 };
219 if !public_demo {
220 panel.ensure_supervisor(cx);
221 panel.refresh_from_effectd(cx);
222 panel.schedule_refresh(cx);
223 }
224 panel
225 }
226
227 fn schedule_refresh(&mut self, cx: &mut Context<Self>) {
228 let executor = cx.background_executor().clone();
229 self._refresh = Some(cx.spawn(async move |this, cx| {
230 loop {
231 executor.timer(Duration::from_secs(3)).await;
232 if this
233 .update(cx, |panel, cx| panel.refresh_from_effectd(cx))
234 .is_err()
235 {
236 break;
237 }
238 }
239 }));
240 }
241
242 fn bind_openagents_account(&mut self, cx: &mut Context<Self>) {
243 if self.binding_busy {
244 return;
245 }
246 let Some(binding) = self.binding.clone() else {
247 self.status = "OpenAgents binding service unavailable.".into();
248 cx.notify();
249 return;
250 };
251 // Relation requires the active Omega Nostr public key from isolated custody.
252 let omega_pubkey = match omega_identity::IdentityService::system(*app_identity::CHANNEL)
253 .inspect()
254 .ok()
255 .and_then(|custody| custody.identity)
256 .map(|identity| identity.public_key_hex().as_str().to_string())
257 {
258 Some(pubkey) if !pubkey.is_empty() => pubkey,
259 _ => {
260 self.status =
261 "Omega identity is not ready. Create or open an identity before binding."
262 .into();
263 cx.notify();
264 return;
265 }
266 };
267 self.binding_busy = true;
268 self.status = "Binding OpenAgents account in your browser…".into();
269 cx.notify();
270 cx.spawn(async move |this, cx| {
271 let projection = binding.bind(&omega_pubkey, cx).await;
272 this.update(cx, |panel, cx| {
273 panel.binding_busy = false;
274 panel.binding_projection = projection.clone();
275 // Visible states only: unbound | bound | refused.
276 // Refused must show the owner-scope message, never a network fault.
277 panel.status = match projection.state {
278 BindingState::Unbound => "OpenAgents account unbound.".into(),
279 BindingState::Bound => format!(
280 "Bound OpenAgents account {} to Omega identity (metering attribution).",
281 projection
282 .openagents_account_id
283 .as_deref()
284 .unwrap_or("unknown")
285 )
286 .into(),
287 BindingState::Refused => projection
288 .gate_message
289 .clone()
290 .unwrap_or_else(|| BindingState::Refused.status_line().to_string())
291 .into(),
292 };
293 cx.notify();
294 })
295 .log_err();
296 })
297 .detach();
298 }
299
300 fn clear_openagents_binding(&mut self, cx: &mut Context<Self>) {
301 if self.binding_busy {
302 return;
303 }
304 let Some(binding) = self.binding.clone() else {
305 return;
306 };
307 self.binding_busy = true;
308 cx.spawn(async move |this, cx| {
309 let projection = binding.clear(cx).await;
310 this.update(cx, |panel, cx| {
311 panel.binding_busy = false;
312 panel.binding_projection = projection;
313 panel.status = "OpenAgents account unbound.".into();
314 cx.notify();
315 })
316 .log_err();
317 })
318 .detach();
319 }
320
321 fn focus_composer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
322 self.composer.focus_handle(cx).focus(window, cx);
323 // Opening / focusing the room is a local mark-read (MVP).
324 self.mark_room_read(cx);
325 cx.notify();
326 }
327
328 /// Local mark-read only (OMEGA-SW-06). Does not publish NIP-RS / kind 30078.
329 fn mark_room_read(&mut self, cx: &mut Context<Self>) {
330 self.projection.mark_room_read();
331 if self.projection.attention.unread_count == 0 {
332 self.status = "Room marked read (local only).".into();
333 }
334 cx.notify();
335 }
336
337 fn ensure_supervisor(&mut self, cx: &mut Context<Self>) {
338 if self.supervisor.is_some() {
339 return;
340 }
341 match shared_supervisor(cx) {
342 Ok(supervisor) => {
343 self.supervisor = Some(supervisor);
344 self.status = "Connected to omega-effectd supervisor.".into();
345 }
346 Err(error) => {
347 let detail = format!("omega-effectd unavailable ({error}).");
348 self.status = detail.clone().into();
349 self.projection.mark_effectd_unavailable(detail);
350 }
351 }
352 cx.notify();
353 }
354
355 fn refresh_from_effectd(&mut self, cx: &mut Context<Self>) {
356 if self.refreshing {
357 return;
358 }
359 self.ensure_supervisor(cx);
360 let Some(supervisor) = self.supervisor.clone() else {
361 return;
362 };
363 self.refreshing = true;
364 cx.notify();
365
366 cx.spawn(async move |this, cx| {
367 let bootstrap = {
368 let mut guard = supervisor.lock().await;
369 match guard.ensure_started().await {
370 Ok(()) => match guard.sarah_bootstrap().await {
371 Ok(value) => Ok(value),
372 Err(error) => Err(error.to_string()),
373 },
374 Err(error) => Err(error.to_string()),
375 }
376 };
377
378 let snapshot = match &bootstrap {
379 Ok(_) => {
380 let mut guard = supervisor.lock().await;
381 match guard
382 .sarah_room_snapshot(Some(json!({
383 "transcriptLimit": 50,
384 "activityLimit": 50,
385 "nostrLimit": 50,
386 })))
387 .await
388 {
389 Ok(value) => Ok(value),
390 Err(error) => Err(error.to_string()),
391 }
392 }
393 Err(error) => Err(error.clone()),
394 };
395
396 let device_grants = match &bootstrap {
397 Ok(_) => {
398 let mut guard = supervisor.lock().await;
399 match guard.sarah_device_grants().await {
400 Ok(value) => value
401 .get("grants")
402 .cloned()
403 .ok_or_else(|| "device grant response omitted grants".to_string())
404 .and_then(|grants| {
405 serde_json::from_value::<Vec<Issue31GrantProjection>>(grants)
406 .map_err(|error| error.to_string())
407 }),
408 Err(error) => Err(error.to_string()),
409 }
410 }
411 Err(error) => Err(error.clone()),
412 };
413
414 let full_auto = {
415 let mut guard = supervisor.lock().await;
416 match guard.list_runs().await {
417 Ok(rows) => {
418 let mut details = Vec::new();
419 let mut detail_error = None;
420 for row in rows {
421 match guard.get_run(&row.run_ref).await {
422 Ok(value) => details.push(value),
423 Err(error) => {
424 detail_error = Some(error.to_string());
425 break;
426 }
427 }
428 }
429 if let Some(error) = detail_error {
430 Err(error)
431 } else {
432 Ok(details)
433 }
434 }
435 Err(error) => Err(error.to_string()),
436 }
437 };
438
439 this.update(cx, |panel, cx| {
440 panel.refreshing = false;
441 match device_grants {
442 Ok(grants) => {
443 panel.device_grants = grants;
444 panel.device_grants_detail = if panel.device_grants.is_empty() {
445 Some("No paired device grants.".into())
446 } else {
447 None
448 };
449 }
450 Err(error) => {
451 panel.device_grants.clear();
452 panel.device_grants_detail =
453 Some(format!("Device grants unavailable: {error}"));
454 }
455 }
456 match full_auto {
457 Ok(values) => {
458 panel.full_auto_runs = values
459 .iter()
460 .filter_map(|value| WorkroomFullAutoRun::from_value(value, chrono::Utc::now()))
461 .collect();
462 panel.full_auto_detail = if panel.full_auto_runs.is_empty() {
463 Some("No Full Auto run records.".into())
464 } else {
465 None
466 };
467 }
468 Err(error) => {
469 panel.full_auto_runs.clear();
470 panel.full_auto_detail = Some(format!("Full Auto records unavailable: {error}"));
471 }
472 }
473 match (bootstrap, snapshot) {
474 (Ok(boot), Ok(snap)) => {
475 panel.apply_bootstrap(&boot);
476 panel.apply_snapshot(&snap);
477 panel.status = "Room projection refreshed from omega-effectd.".into();
478 panel.sync_interaction_status();
479 }
480 (Ok(boot), Err(snap_err)) => {
481 panel.apply_bootstrap(&boot);
482 panel.projection.transcript.meta =
483 ProjectionMeta::unavailable(sources::TRANSCRIPT, &snap_err);
484 panel.projection.activity.meta =
485 ProjectionMeta::unavailable(sources::ACTIVITY, &snap_err);
486 panel.projection.receipts.meta =
487 ProjectionMeta::unavailable(sources::RECEIPTS, &snap_err);
488 panel.projection.run_state.meta =
489 ProjectionMeta::unavailable(sources::RUN_STATE, &snap_err);
490 panel.projection.run_state.reason = Some(snap_err.clone());
491 panel.nostr_records = NostrRecordsProjection::unavailable(format!(
492 "Confirmed Nostr record references unavailable: {snap_err}"
493 ));
494 panel.status = format!("Bootstrap ok; room snapshot unavailable: {snap_err}")
495 .into();
496 }
497 (Err(error), _) => {
498 // Methods may not exist until SARAH-NR-06. Stay honest.
499 panel.projection.mark_effectd_unavailable(error.clone());
500 panel.nostr_records = NostrRecordsProjection::unavailable(format!(
501 "Confirmed Nostr record source unavailable: {error}"
502 ));
503 panel.status = format!(
504 "Sarah record methods unavailable ({error}). Sources stay labeled missing."
505 )
506 .into();
507 }
508 }
509 cx.notify();
510 })
511 .log_err();
512 })
513 .detach();
514 }
515
516 fn apply_bootstrap(&mut self, value: &Value) {
517 // SARAH-NR-06 bootstrap is flat; SW-03 also accepted nested room/principal.
518 let room = value.get("room").or_else(|| value.get("principal"));
519 let root = Some(value);
520 let principal_ref = string_field(room, &["principalRef", "principal_ref", "ref"])
521 .or_else(|| string_field(root, &["principalRef", "principal_ref"]));
522 let display_name = string_field(room, &["displayName", "display_name", "name"])
523 .or_else(|| string_field(root, &["displayName", "display_name"]))
524 .or_else(|| Some("Sarah".into()));
525 let role = string_field(room, &["role"])
526 .or_else(|| string_field(root, &["role"]))
527 .or_else(|| Some("principal.sarah".into()));
528 let thread_ref = string_field(
529 value.get("thread").or(room),
530 &["threadRef", "thread_ref", "ref", "conversation"],
531 )
532 .or_else(|| {
533 string_field(
534 root,
535 &[
536 "conversationRef",
537 "conversation_ref",
538 "legacyThreadRef",
539 "legacy_thread_ref",
540 "threadRef",
541 ],
542 )
543 });
544 let authority_profile = string_field(
545 value.get("authority").or(room),
546 &["profile", "authorityProfile", "authority_profile"],
547 )
548 .or_else(|| {
549 string_field(
550 root,
551 &[
552 "authorityProfileRef",
553 "authority_profile_ref",
554 "authorityProfile",
555 ],
556 )
557 });
558 let authority_revision = string_field(
559 value.get("authority").or(room),
560 &["revision", "authorityRevision", "authority_revision"],
561 )
562 .or_else(|| {
563 value
564 .get("authorityProfileRevision")
565 .or_else(|| value.get("authority_profile_revision"))
566 .map(|v| match v {
567 Value::Number(n) => n.to_string(),
568 Value::String(s) => s.clone(),
569 _ => String::new(),
570 })
571 .filter(|s| !s.is_empty())
572 });
573
574 self.projection.room = RoomProjection {
575 meta: ProjectionMeta::fresh(sources::ROOM),
576 principal_ref,
577 display_name,
578 role,
579 thread_ref,
580 authority_profile,
581 authority_revision,
582 detail: None,
583 };
584 self.projection.recompute_attention();
585 }
586
587 fn apply_snapshot(&mut self, value: &Value) {
588 // Preserve local pending rows across refresh until record confirms them.
589 let local_pending: Vec<TranscriptRow> = self
590 .projection
591 .transcript
592 .rows
593 .iter()
594 .filter(|row| row.ack == MessageAck::Pending)
595 .cloned()
596 .collect();
597
598 // Transcript — ordinary turns only (including proactive tick turns).
599 // omega-effectd uses `entries`; older fixtures may use items/messages.
600 let mut transcript = TranscriptProjection {
601 meta: ProjectionMeta::fresh(sources::TRANSCRIPT),
602 rows: Vec::new(),
603 cursor: string_field(value.get("transcript"), &["cursor"]),
604 truncated: false,
605 };
606 if let Some(items) = value
607 .get("transcript")
608 .and_then(|t| {
609 t.get("entries")
610 .or_else(|| t.get("items"))
611 .or_else(|| t.get("messages"))
612 })
613 .and_then(|v| v.as_array())
614 {
615 for item in items {
616 // Proactive tick turns and Q&A answers share this path.
617 // Only an explicit pending ack/status stays non-confirmed.
618 let ack = match item
619 .get("ack")
620 .or_else(|| item.get("status"))
621 .or_else(|| item.get("state"))
622 .and_then(|v| v.as_str())
623 {
624 Some("pending") => MessageAck::Pending,
625 _ => MessageAck::Confirmed,
626 };
627 transcript.push_bounded(TranscriptRow {
628 message_ref: string_field(
629 Some(item),
630 &["messageRef", "eventId", "event_id", "id", "ref", "cursor"],
631 )
632 .unwrap_or_else(|| "unknown".into()),
633 role: string_field(Some(item), &["role"]).unwrap_or_else(|| "unknown".into()),
634 text: string_field(Some(item), &["text", "content"]).unwrap_or_default(),
635 ack,
636 });
637 }
638 } else if value.get("transcript").is_none() {
639 transcript.meta = ProjectionMeta::missing(sources::TRANSCRIPT);
640 }
641 // Re-attach unconfirmed local sends so refresh never drops optimistic rows.
642 for pending in local_pending {
643 if !transcript
644 .rows
645 .iter()
646 .any(|row| row.message_ref == pending.message_ref)
647 {
648 transcript.push_bounded(pending);
649 }
650 }
651 if let Some(gap) = value
652 .get("transcript")
653 .and_then(|t| t.get("gap").or_else(|| t.get("gapState")))
654 .and_then(|v| v.as_str())
655 {
656 if gap != "none" {
657 transcript.meta.gap = GapState::Gap;
658 transcript.meta.freshness = Freshness::Stale;
659 }
660 }
661 self.projection.transcript = transcript;
662
663 // Activity — NR-06 uses `entries` with `entry` kind field.
664 let mut activity = ActivityProjection {
665 meta: ProjectionMeta::fresh(sources::ACTIVITY),
666 rows: Vec::new(),
667 cursor: string_field(value.get("activity"), &["cursor"]),
668 truncated: false,
669 };
670 if let Some(items) = value
671 .get("activity")
672 .and_then(|a| {
673 a.get("entries")
674 .or_else(|| a.get("items"))
675 .or_else(|| a.get("events"))
676 })
677 .and_then(|v| v.as_array())
678 {
679 for item in items {
680 let kind = string_field(Some(item), &["entry", "kind", "type"])
681 .unwrap_or_else(|| "event".into());
682 let event_ref = string_field(
683 Some(item),
684 &["eventRef", "eventId", "event_id", "id", "ref"],
685 )
686 .unwrap_or_else(|| "unknown".into());
687 let summary =
688 string_field(Some(item), &["summary", "text"]).unwrap_or_else(|| kind.clone());
689 let turn_ref = string_field(Some(item), &["turnRef", "turn_ref", "turn"]);
690 activity.push_bounded(ActivityRow {
691 event_ref: event_ref.clone(),
692 kind: kind.clone(),
693 summary: summary.clone(),
694 turn_ref: turn_ref.clone(),
695 });
696 // Drive interaction ladder from snapshot activity (ordered).
697 if let Some(event) = InteractionEvent::from_runtime_kind(
698 &kind,
699 event_ref,
700 turn_ref,
701 summary,
702 string_field(Some(item), &["toolRef", "tool_ref"]),
703 string_field(Some(item), &["reason"]),
704 ) {
705 let _ = self.interaction.apply_event(event);
706 }
707 }
708 } else if value.get("activity").is_none() {
709 activity.meta = ProjectionMeta::missing(sources::ACTIVITY);
710 }
711 // Prefer interaction ladder order when it has more recent steps.
712 if !self.interaction.tool_ladder.is_empty() {
713 let mut ladder_activity = ActivityProjection {
714 meta: ProjectionMeta::fresh(sources::ACTIVITY),
715 rows: Vec::new(),
716 cursor: activity.cursor.clone(),
717 truncated: false,
718 };
719 for row in self.interaction.activity_rows() {
720 ladder_activity.push_bounded(row);
721 }
722 activity = ladder_activity;
723 }
724 self.projection.activity = activity;
725
726 // Receipts (stub refs only; deep inspector is OMEGA-SW-05).
727 let mut receipts = ReceiptsProjection {
728 meta: ProjectionMeta::fresh(sources::RECEIPTS),
729 rows: Vec::new(),
730 detail: Some("Receipt refs only. Deep inspector is OMEGA-SW-05.".into()),
731 };
732 if let Some(items) = value
733 .get("receipts")
734 .and_then(|r| r.get("items").or_else(|| r.as_array().map(|_| r)))
735 .and_then(|v| {
736 if v.is_array() {
737 v.as_array()
738 } else {
739 v.get("items").and_then(|i| i.as_array())
740 }
741 })
742 {
743 for item in items {
744 receipts.push_bounded(ReceiptRow {
745 receipt_ref: string_field(
746 Some(item),
747 &["receiptRef", "authorityReceiptRef", "ref", "id"],
748 )
749 .unwrap_or_else(|| "unknown".into()),
750 allowed: item.get("allowed").and_then(|v| v.as_bool()),
751 decision_ref: string_field(Some(item), &["decisionRef", "decision_ref"]),
752 tool_ref: string_field(Some(item), &["toolRef", "tool_ref"]),
753 });
754 }
755 } else if value.get("receipts").is_none() {
756 receipts.meta = ProjectionMeta::missing(sources::RECEIPTS);
757 receipts.detail = Some("No receipt page in snapshot. Source labeled missing.".into());
758 }
759 self.projection.receipts = receipts;
760
761 self.nostr_records = parse_nostr_records_projection(value.get("nostrRecords"));
762
763 // Run state — NR-06 uses `state`; legacy used `phase`.
764 let run = value.get("runState").or_else(|| value.get("run_state"));
765 let phase_str = string_field(run, &["phase", "state", "status"]);
766 let phase = phase_str
767 .as_deref()
768 .map(parse_run_phase)
769 .unwrap_or(RunPhase::Unknown);
770 let reason = string_field(run, &["reason", "finishReason", "finish_reason"]);
771 let turn_ref = string_field(run, &["turnRef", "turn_ref"]);
772
773 self.interaction.apply_snapshot_run(phase, turn_ref, reason);
774
775 let mut run_state = self.interaction.run.clone();
776 if run.is_none() {
777 run_state.meta = ProjectionMeta::missing(sources::RUN_STATE);
778 if run_state.reason.is_none() {
779 run_state.reason = Some("Run state missing from snapshot.".into());
780 }
781 }
782 self.projection.run_state = run_state;
783 self.projection.connection_detail = Some("Snapshot applied from omega-effectd.".into());
784 // OMEGA-SW-06: recompute local unread + attention after transcript page.
785 // Never invent proactive rows when the autonomous tick is off.
786 debug_assert!(
787 empty_room_is_honest(&self.projection.transcript, OMEGA_AUTONOMOUS_TICK_ENABLED),
788 "empty room must stay honest when autonomous tick is off"
789 );
790 self.projection.recompute_attention();
791 self.sync_interaction_status();
792 }
793
794 /// Apply one ordered room/runtime event into interaction + projection.
795 fn apply_interaction_event(&mut self, event: InteractionEvent) {
796 let rows = self.interaction.apply_event(event);
797 for row in rows {
798 self.upsert_transcript_row(row);
799 }
800 // Refresh activity from ordered tool ladder.
801 if !self.interaction.tool_ladder.is_empty() {
802 let mut activity = ActivityProjection {
803 meta: ProjectionMeta::fresh(sources::ACTIVITY),
804 rows: Vec::new(),
805 cursor: self.projection.activity.cursor.clone(),
806 truncated: false,
807 };
808 for row in self.interaction.activity_rows() {
809 activity.push_bounded(row);
810 }
811 self.projection.activity = activity;
812 }
813 self.projection.run_state = self.interaction.run.clone();
814 self.projection.recompute_attention();
815 self.sync_interaction_status();
816 }
817
818 fn upsert_transcript_row(&mut self, row: TranscriptRow) {
819 if let Some(existing) = self
820 .projection
821 .transcript
822 .rows
823 .iter_mut()
824 .find(|r| r.message_ref == row.message_ref)
825 {
826 *existing = row;
827 self.projection.recompute_attention();
828 return;
829 }
830 // Confirm may replace a local pending row by text/local ref.
831 if row.ack == MessageAck::Confirmed {
832 if let Some(idx) = self.projection.transcript.rows.iter().position(|r| {
833 r.ack == MessageAck::Pending && r.role == row.role && r.text == row.text
834 }) {
835 self.projection.transcript.rows[idx] = row;
836 self.projection.recompute_attention();
837 return;
838 }
839 }
840 self.projection.transcript.push_bounded(row);
841 if self.projection.transcript.meta.gap == GapState::Unavailable {
842 self.projection.transcript.meta = ProjectionMeta::fresh(sources::TRANSCRIPT);
843 }
844 self.projection.recompute_attention();
845 }
846
847 fn sync_interaction_status(&mut self) {
848 // Prefer interaction status when a turn is active or pending; keep
849 // attention mark-read messages otherwise.
850 if self.interaction.pending_send_count() > 0
851 || self.interaction.run.phase == RunPhase::Running
852 || self.interaction.run.phase == RunPhase::Queued
853 || self.interaction.terminal.is_terminal()
854 || self.interaction.run.interrupt_intent != InterruptIntentState::None
855 || !self.interaction.tool_ladder.is_empty()
856 || self.interaction.answer != AnswerState::None
857 {
858 let mut status = self.interaction.status_line();
859 if self.interaction.uses_honest_liveness() {
860 status.push_str(" · liveness=tool_ladder");
861 }
862 self.status = status.into();
863 }
864 }
865
866 /// OMEGA-SW-04: send composer text. Local pending until record confirms.
867 fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
868 if self.sending {
869 return;
870 }
871 let text = self.composer.read(cx).text(cx).trim().to_string();
872 if text.is_empty() {
873 self.status = "Message text is required.".into();
874 cx.notify();
875 return;
876 }
877
878 let (local_ref, pending_row) = self.interaction.begin_send(text.clone());
879 self.upsert_transcript_row(pending_row);
880 self.projection.transcript.meta = ProjectionMeta::pending(sources::TRANSCRIPT);
881 self.status = format!("Pending local send {local_ref} until record confirms.").into();
882
883 self.composer.update(cx, |editor, cx| {
884 editor.clear(window, cx);
885 });
886
887 self.ensure_supervisor(cx);
888 let Some(supervisor) = self.supervisor.clone() else {
889 self.interaction.fail_send(&local_ref);
890 self.projection
891 .transcript
892 .rows
893 .retain(|row| row.message_ref != local_ref);
894 self.projection.mark_effectd_unavailable("no supervisor");
895 self.status = "omega-effectd unavailable; local pending send dropped.".into();
896 cx.notify();
897 return;
898 };
899
900 self.sending = true;
901 let idempotency_ref = format!("idempotency.workroom.send.{}", Uuid::new_v4());
902 cx.notify();
903
904 cx.spawn(async move |this, cx| {
905 let result = {
906 let mut guard = supervisor.lock().await;
907 match guard.ensure_started().await {
908 Ok(()) => guard.sarah_send_message(&text, &idempotency_ref).await,
909 Err(error) => Err(omega_effectd::SupervisorError::Anyhow(error)),
910 }
911 };
912 this.update(cx, |panel, cx| {
913 panel.sending = false;
914 match result {
915 Ok(value) => {
916 let message_ref = string_field(
917 Some(&value),
918 &["messageRef", "message_ref", "eventId", "event_id"],
919 )
920 .unwrap_or_else(|| local_ref.clone());
921 let turn_ref = string_field(Some(&value), &["turnRef", "turn_ref"]);
922 let status = string_field(Some(&value), &["status"])
923 .unwrap_or_else(|| "accepted".into());
924 if let Some(confirmed) = panel.interaction.confirm_send(
925 &local_ref,
926 message_ref.clone(),
927 turn_ref,
928 ) {
929 panel.upsert_transcript_row(confirmed);
930 } else {
931 panel.upsert_transcript_row(TranscriptRow {
932 message_ref: message_ref.clone(),
933 role: "owner".into(),
934 text: text.clone(),
935 ack: MessageAck::Confirmed,
936 });
937 }
938 // Accepted on record — turn runs only after claim event.
939 panel.projection.run_state = panel.interaction.run.clone();
940 panel.projection.transcript.meta =
941 ProjectionMeta::fresh(sources::TRANSCRIPT);
942 panel.status = format!(
943 "Message confirmed ({status}) ref={message_ref}; turn claim pending."
944 )
945 .into();
946 panel.sync_interaction_status();
947 }
948 Err(error) => {
949 panel.interaction.fail_send(&local_ref);
950 panel
951 .projection
952 .transcript
953 .rows
954 .retain(|row| row.message_ref != local_ref);
955 panel.projection.recompute_attention();
956 panel.status =
957 format!("Send failed ({error}). Pending local row dropped.").into();
958 }
959 }
960 cx.notify();
961 })
962 .log_err();
963 })
964 .detach();
965 }
966
967 fn renew_device_grant(&mut self, grant: Issue31GrantProjection, cx: &mut Context<Self>) {
968 if self.grant_busy.is_some() || grant.status != "active" {
969 return;
970 }
971 self.ensure_supervisor(cx);
972 let Some(supervisor) = self.supervisor.clone() else {
973 self.status = "omega-effectd unavailable; device grant was not renewed.".into();
974 cx.notify();
975 return;
976 };
977 let grant_ref = grant.grant_ref.clone();
978 let scopes = grant.scopes;
979 let expires_at = u64::try_from(chrono::Utc::now().timestamp().max(0))
980 .unwrap_or(0)
981 .saturating_add(24 * 60 * 60);
982 let idempotency_ref = format!("idempotency.workroom.grant_renew.{}", Uuid::new_v4());
983 self.grant_busy = Some(grant_ref.clone());
984 self.status = format!("Renewing device grant {grant_ref}…").into();
985 cx.notify();
986 cx.spawn(async move |this, cx| {
987 let result = {
988 let mut guard = supervisor.lock().await;
989 match guard.ensure_started().await {
990 Ok(()) => {
991 guard
992 .sarah_renew_device_grant(
993 &grant_ref,
994 &scopes,
995 expires_at,
996 &idempotency_ref,
997 )
998 .await
999 }
1000 Err(error) => Err(omega_effectd::SupervisorError::Anyhow(error)),
1001 }
1002 };
1003 this.update(cx, |panel, cx| {
1004 panel.grant_busy = None;
1005 panel.status = match result {
1006 Ok(_) => format!("Device grant {grant_ref} renewed.").into(),
1007 Err(error) => format!("Device grant renewal failed: {error}").into(),
1008 };
1009 panel.refresh_from_effectd(cx);
1010 cx.notify();
1011 })
1012 .log_err();
1013 })
1014 .detach();
1015 }
1016
1017 fn revoke_device_grant(&mut self, grant_ref: String, cx: &mut Context<Self>) {
1018 if self.grant_busy.is_some() {
1019 return;
1020 }
1021 self.ensure_supervisor(cx);
1022 let Some(supervisor) = self.supervisor.clone() else {
1023 self.status = "omega-effectd unavailable; device grant was not revoked.".into();
1024 cx.notify();
1025 return;
1026 };
1027 let idempotency_ref = format!("idempotency.workroom.grant_revoke.{}", Uuid::new_v4());
1028 self.grant_busy = Some(grant_ref.clone());
1029 self.status = format!("Revoking device grant {grant_ref}…").into();
1030 cx.notify();
1031 cx.spawn(async move |this, cx| {
1032 let result = {
1033 let mut guard = supervisor.lock().await;
1034 match guard.ensure_started().await {
1035 Ok(()) => {
1036 guard
1037 .sarah_revoke_device_grant(
1038 &grant_ref,
1039 "reason.omega.owner_revoked",
1040 &idempotency_ref,
1041 )
1042 .await
1043 }
1044 Err(error) => Err(omega_effectd::SupervisorError::Anyhow(error)),
1045 }
1046 };
1047 this.update(cx, |panel, cx| {
1048 panel.grant_busy = None;
1049 panel.status = match result {
1050 Ok(_) => format!("Device grant {grant_ref} revoked.").into(),
1051 Err(error) => format!("Device grant revocation failed: {error}").into(),
1052 };
1053 panel.refresh_from_effectd(cx);
1054 cx.notify();
1055 })
1056 .log_err();
1057 })
1058 .detach();
1059 }
1060
1061 fn readmit_device(&mut self, grant_ref: String, cx: &mut Context<Self>) {
1062 if self.grant_busy.is_some() {
1063 return;
1064 }
1065 self.ensure_supervisor(cx);
1066 let Some(supervisor) = self.supervisor.clone() else {
1067 self.status = "omega-effectd unavailable; device was not re-admitted.".into();
1068 cx.notify();
1069 return;
1070 };
1071 let idempotency_ref = format!("idempotency.workroom.device_readmit.{}", Uuid::new_v4());
1072 self.grant_busy = Some(grant_ref.clone());
1073 self.status = format!("Re-admitting the device behind {grant_ref}…").into();
1074 cx.notify();
1075 cx.spawn(async move |this, cx| {
1076 let result = {
1077 let mut guard = supervisor.lock().await;
1078 match guard.ensure_started().await {
1079 Ok(()) => guard.sarah_readmit_device(&grant_ref, &idempotency_ref).await,
1080 Err(error) => Err(omega_effectd::SupervisorError::Anyhow(error)),
1081 }
1082 };
1083 this.update(cx, |panel, cx| {
1084 panel.grant_busy = None;
1085 panel.status = match result {
1086 // Re-admission restores nothing by itself. The device still
1087 // has to pair again, so the status must not read as access.
1088 Ok(_) => "Device re-admitted. It must pair again before it has any access."
1089 .to_string()
1090 .into(),
1091 Err(error) => format!("Device re-admission failed: {error}").into(),
1092 };
1093 panel.refresh_from_effectd(cx);
1094 cx.notify();
1095 })
1096 .log_err();
1097 })
1098 .detach();
1099 }
1100
1101 fn interrupt_turn(&mut self, cx: &mut Context<Self>) {
1102 if self.interrupting {
1103 return;
1104 }
1105 // Law: pending never renders as applied.
1106 self.interaction.begin_interrupt();
1107 self.projection.run_state = self.interaction.run.clone();
1108 self.status = "Interrupt intent pending until terminal turn event.".into();
1109 self.ensure_supervisor(cx);
1110 let Some(supervisor) = self.supervisor.clone() else {
1111 self.projection.run_state.meta =
1112 ProjectionMeta::unavailable(sources::EFFECTD, "no supervisor");
1113 cx.notify();
1114 return;
1115 };
1116 let turn_ref = self
1117 .interaction
1118 .run
1119 .turn_ref
1120 .clone()
1121 .or_else(|| self.projection.run_state.turn_ref.clone())
1122 .unwrap_or_else(|| "active".into());
1123 self.interrupting = true;
1124 let idempotency_ref = format!("idempotency.workroom.interrupt.{}", Uuid::new_v4());
1125 cx.notify();
1126
1127 cx.spawn(async move |this, cx| {
1128 let result = {
1129 let mut guard = supervisor.lock().await;
1130 match guard.ensure_started().await {
1131 Ok(()) => {
1132 guard
1133 .sarah_interrupt_turn(&turn_ref, &idempotency_ref)
1134 .await
1135 }
1136 Err(error) => Err(omega_effectd::SupervisorError::Anyhow(error)),
1137 }
1138 };
1139 this.update(cx, |panel, cx| {
1140 panel.interrupting = false;
1141 match result {
1142 Ok(value) => {
1143 // Accepted intent only. Applied only after terminal event.
1144 let state = value
1145 .get("state")
1146 .or_else(|| value.get("status"))
1147 .and_then(|v| v.as_str())
1148 .unwrap_or("pending");
1149 let pending = value
1150 .get("pending")
1151 .and_then(|v| v.as_bool())
1152 .unwrap_or(true);
1153 if (state == "applied" || state == "interrupted") && !pending {
1154 if panel.interaction.run.phase == RunPhase::Interrupted {
1155 panel.apply_interaction_event(InteractionEvent::TurnInterrupted {
1156 turn_ref: Some(turn_ref.clone()),
1157 reason: string_field(Some(&value), &["reason"])
1158 .unwrap_or_else(|| "owner_interrupt".into()),
1159 });
1160 } else {
1161 panel.interaction.begin_interrupt();
1162 panel.projection.run_state = panel.interaction.run.clone();
1163 }
1164 } else {
1165 // Stay pending — never upgrade to Applied here.
1166 panel.interaction.begin_interrupt();
1167 panel.projection.run_state = panel.interaction.run.clone();
1168 }
1169 panel.status = format!(
1170 "Interrupt intent: {state} (not applied until terminal event)."
1171 )
1172 .into();
1173 }
1174 Err(error) => {
1175 // Keep intent visible as pending/unavailable, not applied.
1176 panel.interaction.begin_interrupt();
1177 panel.projection.run_state = panel.interaction.run.clone();
1178 panel.projection.run_state.meta =
1179 ProjectionMeta::pending(sources::RUN_STATE);
1180 panel.status = format!(
1181 "Interrupt request failed ({error}). Intent stays pending, not applied."
1182 )
1183 .into();
1184 }
1185 }
1186 cx.notify();
1187 })
1188 .log_err();
1189 })
1190 .detach();
1191 }
1192
1193 /// Test / inspection helper: current in-memory projection (not durable).
1194 pub fn projection(&self) -> &WorkroomProjection {
1195 &self.projection
1196 }
1197
1198 /// Test / inspection helper: community room projection (not durable).
1199 pub fn community(&self) -> &CommunityRoomProjection {
1200 &self.community
1201 }
1202
1203 /// Test / inspection helper: active room kind.
1204 pub fn active_room(&self) -> RoomKind {
1205 self.active_room
1206 }
1207
1208 /// SARAH-CW-08: switch rooms inside the same pane (not a second dock panel).
1209 pub fn select_room(&mut self, kind: RoomKind, cx: &mut Context<Self>) {
1210 self.active_room = kind;
1211 self.status = match kind {
1212 RoomKind::OwnerPrivate => "Showing owner-private Sarah room.".into(),
1213 RoomKind::Community => {
1214 "Showing community room (separate membership and history).".into()
1215 }
1216 };
1217 // Composer stays one instance; community publish is not wired in this skeleton.
1218 if kind.is_community() {
1219 // Placeholder reminds the operator which room is active.
1220 // Full community compose is a later packet; do not invent a second Editor.
1221 }
1222 cx.notify();
1223 }
1224
1225 /// Two-room isolation check for tests and honest UI guards.
1226 pub fn rooms_are_isolated(&self) -> bool {
1227 // Distinct identities when both known.
1228 if let (Some(thread), Some(group)) = (
1229 self.projection.room.thread_ref.as_deref(),
1230 self.community.room.group_ref.as_deref().or(self
1231 .community
1232 .membership
1233 .group_ref
1234 .as_deref()),
1235 ) {
1236 if thread == group {
1237 return false;
1238 }
1239 }
1240 let owner_refs: std::collections::BTreeSet<&str> = self
1241 .projection
1242 .transcript
1243 .rows
1244 .iter()
1245 .map(|r| r.message_ref.as_str())
1246 .collect();
1247 let community_refs: std::collections::BTreeSet<&str> = self
1248 .community
1249 .transcript
1250 .rows
1251 .iter()
1252 .map(|r| r.message_ref.as_str())
1253 .collect();
1254 owner_refs.is_disjoint(&community_refs)
1255 }
1256
1257 /// Test / inspection helper: interaction state (not durable).
1258 pub fn interaction(&self) -> &InteractionState {
1259 &self.interaction
1260 }
1261}
1262
1263fn parse_run_phase(s: &str) -> RunPhase {
1264 if s.starts_with("turn.") {
1265 return RunPhase::from_event_kind(s);
1266 }
1267 match s {
1268 "queued" => RunPhase::Queued,
1269 "running" => RunPhase::Running,
1270 "interrupted" => RunPhase::Interrupted,
1271 "interrupt_pending" => RunPhase::Running,
1272 "finished" | "completed" => RunPhase::Finished,
1273 "idle" => RunPhase::Idle,
1274 _ => RunPhase::Unknown,
1275 }
1276}
1277
1278fn string_field(value: Option<&Value>, keys: &[&str]) -> Option<String> {
1279 let value = value?;
1280 for key in keys {
1281 if let Some(s) = value.get(*key).and_then(|v| v.as_str()) {
1282 if !s.is_empty() {
1283 return Some(s.to_string());
1284 }
1285 }
1286 }
1287 None
1288}
1289
1290impl EventEmitter<PanelEvent> for SarahWorkroomPanel {}
1291
1292impl Focusable for SarahWorkroomPanel {
1293 fn focus_handle(&self, _: &App) -> FocusHandle {
1294 self.focus_handle.clone()
1295 }
1296}
1297
1298impl Panel for SarahWorkroomPanel {
1299 fn persistent_name() -> &'static str {
1300 "SarahWorkroomPanel"
1301 }
1302
1303 fn panel_key() -> &'static str {
1304 PANEL_KEY
1305 }
1306
1307 fn position(&self, _: &Window, _: &App) -> DockPosition {
1308 DockPosition::Right
1309 }
1310
1311 fn position_is_valid(&self, _: DockPosition) -> bool {
1312 true
1313 }
1314
1315 fn set_position(&mut self, _: DockPosition, _: &mut Window, _: &mut Context<Self>) {}
1316
1317 fn default_size(&self, _: &Window, _: &App) -> gpui::Pixels {
1318 px(440.)
1319 }
1320
1321 fn icon(&self, _: &Window, _: &App) -> Option<IconName> {
1322 Some(IconName::OmegaAgent)
1323 }
1324
1325 fn icon_tooltip(&self, _: &Window, _: &App) -> Option<&'static str> {
1326 // Dock tooltip may describe the surface; the in-pane header is only "Sarah".
1327 Some("Sarah")
1328 }
1329
1330 fn icon_label(&self, _: &Window, _: &App) -> Option<String> {
1331 // One unread count for the room (OMEGA-SW-06).
1332 self.projection.attention.icon_label()
1333 }
1334
1335 fn toggle_action(&self) -> Box<dyn gpui::Action> {
1336 Box::new(OpenPanel)
1337 }
1338
1339 fn activation_priority(&self) -> u32 {
1340 10
1341 }
1342}
1343
1344impl Render for SarahWorkroomPanel {
1345 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1346 let p = &self.projection;
1347 if self.public_demo {
1348 return v_flex()
1349 .id("sarah-workroom-panel")
1350 .size_full()
1351 .track_focus(&self.focus_handle)
1352 .gap_3()
1353 .p_3()
1354 .child(
1355 h_flex()
1356 .justify_between()
1357 .child(Label::new("Sarah").size(LabelSize::Large))
1358 .child(
1359 Label::new("PUBLIC DEMO")
1360 .color(Color::Accent)
1361 .size(LabelSize::Small),
1362 ),
1363 )
1364 .child(Label::new(self.status.clone()).color(Color::Muted))
1365 .child(Label::new("Orbit Notes launch room").size(LabelSize::Large))
1366 .child(
1367 Label::new("Product engineering · completed · 12 checks passed")
1368 .color(Color::Success),
1369 )
1370 .child(Label::new("Conversation").color(Color::Muted))
1371 .child(
1372 v_flex()
1373 .id("sarah-workroom-transcript")
1374 .border_1()
1375 .border_color(cx.theme().colors().border)
1376 .rounded_md()
1377 .p_2()
1378 .child(transcript_body(&p.transcript)),
1379 )
1380 .child(Label::new("Recent activity").color(Color::Muted))
1381 .child(
1382 v_flex()
1383 .id("sarah-workroom-activity")
1384 .border_1()
1385 .border_color(cx.theme().colors().border)
1386 .rounded_md()
1387 .p_2()
1388 .child(activity_body(&p.activity)),
1389 )
1390 .child(
1391 Label::new("Offline fixture · no account, secret, or private path")
1392 .color(Color::Muted)
1393 .size(LabelSize::Small),
1394 );
1395 }
1396 let community = &self.community;
1397 let active = self.active_room;
1398 let showing_community = active.is_community();
1399 let can_interrupt = !showing_community
1400 && !self.interrupting
1401 && matches!(
1402 p.run_state.phase,
1403 RunPhase::Running | RunPhase::Queued | RunPhase::Unknown
1404 );
1405 // One composer for the pane; community publish is not wired in CW-08 skeleton.
1406 let can_send = !showing_community && !self.sending;
1407 let answer = self.interaction.answer.clone();
1408 let terminal = self.interaction.terminal.clone();
1409 let honest = self.interaction.uses_honest_liveness();
1410 let header = active.header();
1411 let device_grants = device_grants_body(
1412 &self.device_grants,
1413 self.device_grants_detail.as_deref(),
1414 self.grant_busy.as_deref(),
1415 cx,
1416 );
1417
1418 v_flex()
1419 .id("sarah-workroom-panel")
1420 .size_full()
1421 .track_focus(&self.focus_handle)
1422 .gap_2()
1423 .p_3()
1424 // Active room header must be unmistakable (Sarah vs Community).
1425 .child(Label::new(header).size(LabelSize::Large))
1426 .when(showing_community, |this| {
1427 this.child(
1428 Label::new(COMMUNITY_ROOM_SUBTITLE)
1429 .color(Color::Accent)
1430 .size(LabelSize::Small),
1431 )
1432 })
1433 .child(Label::new(self.status.clone()).color(Color::Muted))
1434 // SARAH-CW-08: room switcher — same pane, two rooms.
1435 .child(
1436 h_flex()
1437 .gap_2()
1438 .child(
1439 Button::new("workroom-room-owner-private", OWNER_PRIVATE_ROOM_HEADER)
1440 .style(if showing_community {
1441 ButtonStyle::Subtle
1442 } else {
1443 ButtonStyle::Filled
1444 })
1445 .disabled(!showing_community)
1446 .on_click(cx.listener(|this, _, _, cx| {
1447 this.select_room(RoomKind::OwnerPrivate, cx);
1448 })),
1449 )
1450 .child(
1451 Button::new("workroom-room-community", COMMUNITY_ROOM_HEADER)
1452 .style(if showing_community {
1453 ButtonStyle::Filled
1454 } else {
1455 ButtonStyle::Subtle
1456 })
1457 .disabled(showing_community)
1458 .on_click(cx.listener(|this, _, _, cx| {
1459 this.select_room(RoomKind::Community, cx);
1460 })),
1461 ),
1462 )
1463 .when(!showing_community, |this| {
1464 this.when_some(p.connection_detail.clone(), |this, detail| {
1465 this.child(Label::new(detail).color(Color::Muted))
1466 })
1467 .when(honest, |this| {
1468 this.child(
1469 Label::new("Liveness: ordered tool ladder (no token stream).")
1470 .color(Color::Muted)
1471 .size(LabelSize::Small),
1472 )
1473 })
1474 })
1475 .when(showing_community, |this| {
1476 this.when_some(community.connection_detail.clone(), |this, detail| {
1477 this.child(Label::new(detail).color(Color::Muted))
1478 })
1479 .child(
1480 Label::new(V1_NO_PAY_ROOM_DESCRIPTION)
1481 .color(Color::Warning)
1482 .size(LabelSize::Small),
1483 )
1484 .child(
1485 Label::new(V1_NO_PAY_FIRST_RUN_COPY)
1486 .color(Color::Muted)
1487 .size(LabelSize::Small),
1488 )
1489 })
1490 // OMEGA-SW-01: visible binding state (unbound | bound | refused).
1491 .child(binding_section(&self.binding_projection))
1492 .child(
1493 h_flex()
1494 .gap_2()
1495 .child(
1496 Button::new(
1497 "sarah-workroom-bind",
1498 if self.binding_busy {
1499 "Binding…"
1500 } else {
1501 "Bind OpenAgents account"
1502 },
1503 )
1504 .style(ButtonStyle::Subtle)
1505 .disabled(
1506 self.binding_busy
1507 || self.binding_projection.state == BindingState::Bound,
1508 )
1509 .on_click(cx.listener(|this, _, _, cx| this.bind_openagents_account(cx))),
1510 )
1511 .child(
1512 Button::new("sarah-workroom-unbind", "Clear binding")
1513 .style(ButtonStyle::Subtle)
1514 .disabled(
1515 self.binding_busy
1516 || self.binding_projection.state == BindingState::Unbound,
1517 )
1518 .on_click(
1519 cx.listener(|this, _, _, cx| this.clear_openagents_binding(cx)),
1520 ),
1521 ),
1522 )
1523 // --- Owner-private Sarah room ---
1524 .when(!showing_community, |this| {
1525 this.child(attention_body(&p.attention))
1526 .child(section_header("Room", &p.room.meta))
1527 .child(room_body(&p.room))
1528 .child(section_header("Transcript", &p.transcript.meta))
1529 .child(
1530 v_flex()
1531 .id("sarah-workroom-transcript")
1532 .border_1()
1533 .border_color(cx.theme().colors().border)
1534 .rounded_md()
1535 .p_2()
1536 .max_h(px(160.))
1537 .overflow_y_scroll()
1538 .child(transcript_body(&p.transcript)),
1539 )
1540 .child(section_header("Activity (tool ladder)", &p.activity.meta))
1541 .child(
1542 v_flex()
1543 .id("sarah-workroom-activity")
1544 .border_1()
1545 .border_color(cx.theme().colors().border)
1546 .rounded_md()
1547 .p_2()
1548 .max_h(px(120.))
1549 .overflow_y_scroll()
1550 .child(activity_body(&p.activity)),
1551 )
1552 .child(Label::new("Answer").color(Color::Muted))
1553 .child(answer_body(&answer))
1554 .child(section_header("Receipts", &p.receipts.meta))
1555 .child(receipts_body(&p.receipts))
1556 .child(section_header("Run state", &p.run_state.meta))
1557 .child(run_state_body(&p.run_state, &terminal))
1558 .child(Label::new("Full Auto work").color(Color::Muted))
1559 .child(full_auto_body(
1560 &self.full_auto_runs,
1561 self.full_auto_detail.as_deref(),
1562 ))
1563 .child(Label::new("Paired devices").color(Color::Muted))
1564 .child(device_grants)
1565 })
1566 // --- Community room (SARAH-CW-08) — same pane, separate history ---
1567 .when(showing_community, |this| {
1568 this.child(section_header("Community group", &community.room.meta))
1569 .child(community_room_body(community))
1570 .child(section_header("Membership", &community.membership.meta))
1571 .child(membership_body(&community.membership))
1572 .child(section_header("Work units", &community.work_units.meta))
1573 .child(work_units_body(&community.work_units))
1574 .child(section_header(
1575 // Never "earnings" — experience only.
1576 "Experience rank",
1577 &community.experience.meta,
1578 ))
1579 .child(experience_body(&community.experience))
1580 .child(section_header(
1581 "Group transcript",
1582 &community.transcript.meta,
1583 ))
1584 .child(
1585 v_flex()
1586 .id("community-workroom-transcript")
1587 .border_1()
1588 .border_color(cx.theme().colors().border)
1589 .rounded_md()
1590 .p_2()
1591 .max_h(px(160.))
1592 .overflow_y_scroll()
1593 .child(transcript_body(&community.transcript)),
1594 )
1595 })
1596 .child(Label::new("Confirmed Nostr records").color(Color::Muted))
1597 .child(
1598 v_flex()
1599 .id("workroom-confirmed-nostr-records")
1600 .border_1()
1601 .border_color(cx.theme().colors().border)
1602 .rounded_md()
1603 .p_2()
1604 .max_h(px(140.))
1605 .overflow_y_scroll()
1606 .child(nostr_records_body(&self.nostr_records)),
1607 )
1608 // One composer for the pane (not a second composer).
1609 .child(
1610 Label::new(if showing_community {
1611 "Composer (community publish not wired — skeleton)"
1612 } else {
1613 "Composer"
1614 })
1615 .color(Color::Muted),
1616 )
1617 .child(
1618 div()
1619 .border_1()
1620 .border_color(cx.theme().colors().border)
1621 .rounded_md()
1622 .h(px(72.))
1623 .child(self.composer.clone()),
1624 )
1625 .child(
1626 h_flex()
1627 .gap_2()
1628 .child(
1629 Button::new(
1630 "sarah-workroom-send",
1631 if self.sending { "Sending…" } else { "Send" },
1632 )
1633 .style(ButtonStyle::Filled)
1634 .disabled(!can_send)
1635 .on_click(cx.listener(|this, _, window, cx| {
1636 this.send_message(window, cx);
1637 })),
1638 )
1639 .child(
1640 Button::new("sarah-workroom-refresh", "Refresh")
1641 .style(ButtonStyle::Subtle)
1642 .disabled(self.refreshing || showing_community)
1643 .on_click(cx.listener(|this, _, _, cx| this.refresh_from_effectd(cx))),
1644 )
1645 .child(
1646 Button::new("sarah-workroom-mark-read", "Mark read")
1647 .style(ButtonStyle::Subtle)
1648 .disabled(showing_community || p.attention.unread_count == 0)
1649 .on_click(cx.listener(|this, _, _, cx| this.mark_room_read(cx))),
1650 )
1651 .child(
1652 Button::new(
1653 "sarah-workroom-interrupt",
1654 if self.interrupting {
1655 "Interrupting…"
1656 } else if p.run_state.interrupt_intent == InterruptIntentState::Pending
1657 {
1658 "Interrupt pending"
1659 } else {
1660 "Interrupt"
1661 },
1662 )
1663 .style(ButtonStyle::Subtle)
1664 .disabled(
1665 !can_interrupt
1666 && p.run_state.interrupt_intent != InterruptIntentState::Pending,
1667 )
1668 .on_click(cx.listener(|this, _, _, cx| this.interrupt_turn(cx))),
1669 ),
1670 )
1671 }
1672}
1673
1674fn device_grants_body(
1675 grants: &[Issue31GrantProjection],
1676 detail: Option<&str>,
1677 busy_grant_ref: Option<&str>,
1678 cx: &mut Context<SarahWorkroomPanel>,
1679) -> impl IntoElement + use<> {
1680 v_flex()
1681 .id("sarah-workroom-device-grants")
1682 .gap_1()
1683 .border_1()
1684 .rounded_md()
1685 .p_2()
1686 .when_some(detail.map(str::to_string), |this, detail| {
1687 this.child(Label::new(detail).color(Color::Muted))
1688 })
1689 .children(grants.iter().cloned().enumerate().map(|(index, grant)| {
1690 let renew_grant = grant.clone();
1691 let revoke_grant_ref = grant.grant_ref.clone();
1692 let readmit_grant_ref = grant.grant_ref.clone();
1693 let busy = busy_grant_ref.is_some();
1694 let active = grant.status == "active";
1695 let revoked = grant.status == "revoked";
1696 v_flex()
1697 .gap_0p5()
1698 .child(Label::new(format!(
1699 "Device {} · {} · generation {}",
1700 grant.device_fingerprint, grant.status, grant.generation
1701 )))
1702 .child(
1703 Label::new(format!(
1704 "grant={} · expires={} · scopes={:?}",
1705 grant.grant_ref,
1706 grant
1707 .expires_at
1708 .map(|expires_at| expires_at.to_string())
1709 .unwrap_or_else(|| "none".into()),
1710 grant.scopes
1711 ))
1712 .color(Color::Muted)
1713 .size(LabelSize::Small),
1714 )
1715 .child(
1716 h_flex()
1717 .gap_1()
1718 .child(
1719 Button::new(("renew-device-grant", index), "Renew 24h")
1720 .style(ButtonStyle::Subtle)
1721 .disabled(busy || !active)
1722 .on_click(cx.listener(move |this, _, _, cx| {
1723 this.renew_device_grant(renew_grant.clone(), cx);
1724 })),
1725 )
1726 .child(
1727 Button::new(("revoke-device-grant", index), "Revoke")
1728 .style(ButtonStyle::Subtle)
1729 .disabled(busy || revoked)
1730 .on_click(cx.listener(move |this, _, _, cx| {
1731 this.revoke_device_grant(revoke_grant_ref.clone(), cx);
1732 })),
1733 )
1734 .child(
1735 Button::new(("readmit-device", index), "Re-admit device")
1736 .style(ButtonStyle::Subtle)
1737 .disabled(busy || !revoked)
1738 .on_click(cx.listener(move |this, _, _, cx| {
1739 this.readmit_device(readmit_grant_ref.clone(), cx);
1740 })),
1741 ),
1742 )
1743 }))
1744}
1745
1746fn full_auto_body(runs: &[WorkroomFullAutoRun], detail: Option<&str>) -> impl IntoElement {
1747 v_flex()
1748 .id("sarah-workroom-full-auto")
1749 .gap_1()
1750 .border_1()
1751 .rounded_md()
1752 .p_2()
1753 .when_some(detail.map(str::to_string), |this, detail| {
1754 this.child(Label::new(detail).color(Color::Muted))
1755 })
1756 .children(runs.iter().map(|run| {
1757 v_flex()
1758 .gap_0p5()
1759 .child(Label::new(format!(
1760 "{} · {} · {}",
1761 run.objective, run.lane, run.state
1762 )))
1763 .child(
1764 Label::new(format!("Unattended: {}", run.exact_unattended_duration))
1765 .color(Color::Muted)
1766 .size(LabelSize::Small),
1767 )
1768 .when_some(run.latest_turn.clone(), |this, turn| {
1769 this.child(Label::new(format!("Live: {turn}")).color(Color::Accent))
1770 })
1771 .when_some(run.terminal_reason.clone(), |this, reason| {
1772 this.child(Label::new(format!("Outcome: {reason}")).color(Color::Warning))
1773 })
1774 }))
1775}
1776
1777fn community_room_body(community: &CommunityRoomProjection) -> impl IntoElement {
1778 let room = &community.room;
1779 v_flex()
1780 .gap_0p5()
1781 .child(Label::new(format!(
1782 "group={}",
1783 room.group_ref.as_deref().unwrap_or("(missing)")
1784 )))
1785 .child(Label::new(format!(
1786 "name={}",
1787 room.display_name.as_deref().unwrap_or("(missing)")
1788 )))
1789 .child(
1790 Label::new(format!("invitation_only={}", room.invitation_only))
1791 .color(Color::Muted)
1792 .size(LabelSize::Small),
1793 )
1794 .child(
1795 Label::new(room.description.clone())
1796 .color(Color::Muted)
1797 .size(LabelSize::Small),
1798 )
1799 .when_some(room.detail.clone(), |this, detail| {
1800 this.child(
1801 Label::new(detail)
1802 .color(Color::Warning)
1803 .size(LabelSize::Small),
1804 )
1805 })
1806}
1807
1808fn membership_body(membership: &crate::community::MembershipProjection) -> impl IntoElement {
1809 if membership.members.is_empty() {
1810 return v_flex().child(
1811 Label::new(
1812 membership
1813 .detail
1814 .clone()
1815 .unwrap_or_else(|| "No members projected.".into()),
1816 )
1817 .color(Color::Muted)
1818 .size(LabelSize::Small),
1819 );
1820 }
1821 let mut col = v_flex().gap_0p5();
1822 for member in &membership.members {
1823 let agents = member
1824 .agents
1825 .iter()
1826 .map(|a| {
1827 format!(
1828 "{ref}{attested}{revoked}",
1829 ref = a.agent_ref,
1830 attested = if a.attested { "·attested" } else { "" },
1831 revoked = if a.revoked { "·revoked" } else { "" },
1832 )
1833 })
1834 .collect::<Vec<_>>()
1835 .join(", ");
1836 col = col.child(Label::new(format!(
1837 "{name} ({mref}) attested={attested} agents=[{agents}]",
1838 name = member.display_name.as_deref().unwrap_or("member"),
1839 mref = member.member_ref,
1840 attested = member.attested,
1841 agents = agents,
1842 )));
1843 }
1844 if membership.truncated {
1845 col = col.child(Label::new("…roster truncated").color(Color::Muted));
1846 }
1847 col
1848}
1849
1850fn work_units_body(units: &crate::community::WorkUnitsProjection) -> impl IntoElement {
1851 if units.units.is_empty() {
1852 return v_flex().child(
1853 Label::new(
1854 units
1855 .detail
1856 .clone()
1857 .unwrap_or_else(|| "No work units projected.".into()),
1858 )
1859 .color(Color::Muted)
1860 .size(LabelSize::Small),
1861 );
1862 }
1863 let mut col = v_flex().gap_0p5();
1864 for unit in &units.units {
1865 col = col.child(Label::new(format!(
1866 "{title} ({uref}) · {acceptance} · quotes={q}{tier}{reward}",
1867 title = unit.title,
1868 uref = unit.unit_ref,
1869 acceptance = unit.acceptance.label(),
1870 q = unit.quotes.len(),
1871 tier = unit
1872 .tier
1873 .map(|t| format!(" · tier={t}"))
1874 .unwrap_or_default(),
1875 reward = unit
1876 .reward_note
1877 .as_ref()
1878 .map(|n| format!(" · {n}"))
1879 .unwrap_or_default(),
1880 )));
1881 }
1882 if units.truncated {
1883 col = col.child(Label::new("…work units truncated").color(Color::Muted));
1884 }
1885 col
1886}
1887
1888fn experience_body(experience: &crate::community::ExperienceRankProjection) -> impl IntoElement {
1889 // Structural: label is experience, never earnings.
1890 let summary = experience.summary_line();
1891 v_flex()
1892 .gap_0p5()
1893 .child(Label::new(summary))
1894 .child(
1895 Label::new(format!(
1896 "reward_label={label} (not {forbidden})",
1897 label = experience.reward_label,
1898 forbidden = crate::community::FORBIDDEN_EARNINGS_LABEL,
1899 ))
1900 .color(Color::Muted)
1901 .size(LabelSize::Small),
1902 )
1903 .when(experience.recent_awards.is_empty(), |this| {
1904 this.child(
1905 Label::new(
1906 experience
1907 .detail
1908 .clone()
1909 .unwrap_or_else(|| format!("No {EXPERIENCE_LABEL} awards projected.")),
1910 )
1911 .color(Color::Muted)
1912 .size(LabelSize::Small),
1913 )
1914 })
1915 .children(experience.recent_awards.iter().map(|award| {
1916 Label::new(format!(
1917 "+{pts} {kind} ({aref})",
1918 pts = award.points,
1919 kind = award.reason_kind,
1920 aref = award.award_ref,
1921 ))
1922 }))
1923}
1924
1925fn binding_section(binding: &BindingProjection) -> impl IntoElement {
1926 // Projection is public-safe: never render tokens or credential material.
1927 let state_line = format!("binding={}", binding.state.label());
1928 let account_line = binding
1929 .openagents_account_id
1930 .as_ref()
1931 .map(|id| format!("account={id}"));
1932 let gate = binding.gate_message.clone();
1933 v_flex()
1934 .gap_0p5()
1935 .child(Label::new("OpenAgents binding").color(Color::Muted))
1936 .child(Label::new(state_line))
1937 .when_some(account_line, |this, line| {
1938 this.child(Label::new(line).color(Color::Muted).size(LabelSize::Small))
1939 })
1940 .when_some(gate, |this, message| {
1941 this.child(
1942 Label::new(message)
1943 .color(Color::Warning)
1944 .size(LabelSize::Small),
1945 )
1946 })
1947}
1948
1949fn attention_body(attention: &crate::attention::RoomAttention) -> impl IntoElement {
1950 let marker_color = if attention.marker == AttentionMarker::NeedsAttention {
1951 Color::Accent
1952 } else {
1953 Color::Muted
1954 };
1955 let tick_note = attention.tick_note.map(|s| s.to_string());
1956 v_flex()
1957 .id("sarah-workroom-attention")
1958 .gap_0p5()
1959 .child(Label::new(attention.summary_line()).color(marker_color))
1960 .when_some(tick_note, |this, note| {
1961 this.child(Label::new(note).color(Color::Muted).size(LabelSize::Small))
1962 })
1963}
1964
1965fn section_header(title: &'static str, meta: &ProjectionMeta) -> impl IntoElement {
1966 v_flex().gap_0p5().child(Label::new(title)).child(
1967 Label::new(meta.summary_line())
1968 .color(Color::Muted)
1969 .size(LabelSize::Small),
1970 )
1971}
1972
1973fn room_body(room: &RoomProjection) -> impl IntoElement {
1974 v_flex()
1975 .gap_0p5()
1976 .when_some(room.detail.clone(), |this, detail| {
1977 this.child(Label::new(detail).color(Color::Warning))
1978 })
1979 .when_some(room.display_name.clone(), |this, name| {
1980 this.child(Label::new(format!("name={name}")))
1981 })
1982 .when_some(room.principal_ref.clone(), |this, r| {
1983 this.child(Label::new(format!("principal={r}")).color(Color::Muted))
1984 })
1985 .when_some(room.role.clone(), |this, role| {
1986 this.child(Label::new(format!("role={role}")).color(Color::Muted))
1987 })
1988 .when_some(room.thread_ref.clone(), |this, t| {
1989 this.child(Label::new(format!("thread={t}")).color(Color::Muted))
1990 })
1991 .when_some(room.authority_profile.clone(), |this, profile| {
1992 this.child(
1993 Label::new(format!(
1994 "authority={} rev={}",
1995 profile,
1996 room.authority_revision.as_deref().unwrap_or("—")
1997 ))
1998 .color(Color::Muted),
1999 )
2000 })
2001 .when(
2002 room.display_name.is_none() && room.principal_ref.is_none() && room.detail.is_none(),
2003 |this| this.child(Label::new("No room fields.").color(Color::Muted)),
2004 )
2005}
2006
2007fn transcript_body(transcript: &TranscriptProjection) -> impl IntoElement {
2008 if transcript.rows.is_empty() {
2009 return v_flex().child(
2010 Label::new(if transcript.meta.gap == GapState::Unavailable {
2011 "Transcript source unavailable (not an empty success)."
2012 } else {
2013 "No messages in page."
2014 })
2015 .color(Color::Muted),
2016 );
2017 }
2018 let mut col = v_flex().gap_1();
2019 for row in &transcript.rows {
2020 let line = format!(
2021 "[{}] {} · {}: {}",
2022 row.ack.label(),
2023 row.message_ref,
2024 row.role,
2025 row.text
2026 );
2027 let color = if row.ack == MessageAck::Pending {
2028 Color::Warning
2029 } else {
2030 Color::Default
2031 };
2032 col = col.child(Label::new(line).color(color));
2033 }
2034 if transcript.truncated {
2035 col = col.child(
2036 Label::new("(earlier rows truncated at capacity bound)")
2037 .color(Color::Muted)
2038 .size(LabelSize::Small),
2039 );
2040 }
2041 col
2042}
2043
2044fn activity_body(activity: &ActivityProjection) -> impl IntoElement {
2045 if activity.rows.is_empty() {
2046 return v_flex().child(
2047 Label::new(if activity.meta.gap == GapState::Unavailable {
2048 "Activity source unavailable (not an empty success)."
2049 } else {
2050 "No activity events in page."
2051 })
2052 .color(Color::Muted),
2053 );
2054 }
2055 let mut col = v_flex().gap_1();
2056 for row in &activity.rows {
2057 col = col.child(Label::new(format!(
2058 "{} · {} · {}",
2059 row.kind, row.event_ref, row.summary
2060 )));
2061 }
2062 if activity.truncated {
2063 col = col.child(
2064 Label::new("(earlier activity truncated at capacity bound)")
2065 .color(Color::Muted)
2066 .size(LabelSize::Small),
2067 );
2068 }
2069 col
2070}
2071
2072fn parse_nostr_records_projection(record_page: Option<&Value>) -> NostrRecordsProjection {
2073 let Some(record_page) = record_page else {
2074 return NostrRecordsProjection::unavailable(
2075 "Snapshot omitted confirmed Nostr record references.",
2076 );
2077 };
2078 let source =
2079 string_field(Some(record_page), &["source"]).unwrap_or_else(|| "confirmed_nostr".into());
2080 let mut rows = Vec::new();
2081 let mut truncated = false;
2082 if let Some(items) = record_page.get("entries").and_then(Value::as_array) {
2083 for item in items {
2084 if rows.len() == MAX_NOSTR_RECORD_ROWS {
2085 truncated = true;
2086 break;
2087 }
2088 let Some(event_id) = string_field(Some(item), &["eventId", "event_id"]) else {
2089 continue;
2090 };
2091 let Some(kind) = item
2092 .get("kind")
2093 .and_then(Value::as_u64)
2094 .and_then(|kind| u16::try_from(kind).ok())
2095 else {
2096 continue;
2097 };
2098 rows.push(NostrRecordRow {
2099 event_id,
2100 kind,
2101 record_kind: string_field(Some(item), &["recordKind", "record_kind"])
2102 .unwrap_or_else(|| "record".into()),
2103 author_fingerprint: string_field(
2104 Some(item),
2105 &["authorFingerprint", "author_fingerprint"],
2106 )
2107 .unwrap_or_else(|| "UNKNOWN".into()),
2108 created_at: string_field(Some(item), &["createdAt", "created_at"])
2109 .unwrap_or_else(|| "unknown".into()),
2110 source: string_field(Some(item), &["source"]).unwrap_or_else(|| source.clone()),
2111 });
2112 }
2113 }
2114 let gap = match string_field(Some(record_page), &["gapState", "gap_state"]).as_deref() {
2115 Some("none") => GapState::None,
2116 Some(_) => GapState::Gap,
2117 None => GapState::Unavailable,
2118 };
2119 NostrRecordsProjection {
2120 rows,
2121 cursor: string_field(Some(record_page), &["cursor"]),
2122 next_cursor: string_field(Some(record_page), &["nextCursor", "next_cursor"]),
2123 gap,
2124 source,
2125 detail: (gap == GapState::Unavailable)
2126 .then(|| "Confirmed Nostr record page omitted gap state.".into()),
2127 truncated,
2128 }
2129}
2130
2131fn nostr_records_body(records: &NostrRecordsProjection) -> impl IntoElement {
2132 let mut column = v_flex().gap_1().child(
2133 Label::new(format!(
2134 "source={} · gap={} · cursor={} · next={}",
2135 records.source,
2136 records.gap.label(),
2137 records.cursor.as_deref().unwrap_or("missing"),
2138 records.next_cursor.as_deref().unwrap_or("end")
2139 ))
2140 .color(if records.gap == GapState::None {
2141 Color::Muted
2142 } else {
2143 Color::Warning
2144 })
2145 .size(LabelSize::Small),
2146 );
2147 if let Some(detail) = &records.detail {
2148 column = column.child(Label::new(detail.clone()).color(Color::Muted));
2149 }
2150 if records.rows.is_empty() {
2151 return column.child(
2152 Label::new(if records.gap == GapState::Unavailable {
2153 "Confirmed Nostr record source unavailable (not an empty success)."
2154 } else {
2155 "No confirmed AE, RS, ER, NIP-29, or LBR references in this page."
2156 })
2157 .color(Color::Muted),
2158 );
2159 }
2160 for row in &records.rows {
2161 column = column.child(Label::new(format!(
2162 "kind={} · {} · {} · author={} · {} · source={}",
2163 row.kind,
2164 row.record_kind,
2165 row.event_id,
2166 row.author_fingerprint,
2167 row.created_at,
2168 row.source
2169 )));
2170 }
2171 if records.truncated {
2172 column = column.child(
2173 Label::new("(confirmed record references truncated at capacity bound)")
2174 .color(Color::Muted)
2175 .size(LabelSize::Small),
2176 );
2177 }
2178 column
2179}
2180
2181fn receipts_body(receipts: &ReceiptsProjection) -> impl IntoElement {
2182 v_flex()
2183 .gap_0p5()
2184 .when_some(receipts.detail.clone(), |this, detail| {
2185 this.child(Label::new(detail).color(Color::Muted))
2186 })
2187 .map(|this| {
2188 if receipts.rows.is_empty() {
2189 this.child(
2190 Label::new(if receipts.meta.gap == GapState::Unavailable {
2191 "No receipt refs (source unavailable)."
2192 } else {
2193 "No receipt refs in page."
2194 })
2195 .color(Color::Muted),
2196 )
2197 } else {
2198 let mut col = this;
2199 for row in &receipts.rows {
2200 col = col.child(Label::new(format!(
2201 "receipt={} allowed={} decision={} tool={}",
2202 row.receipt_ref,
2203 row.allowed
2204 .map(|a| if a { "true" } else { "false" })
2205 .unwrap_or("—"),
2206 row.decision_ref.as_deref().unwrap_or("—"),
2207 row.tool_ref.as_deref().unwrap_or("—"),
2208 )));
2209 }
2210 col
2211 }
2212 })
2213}
2214
2215fn answer_body(answer: &AnswerState) -> impl IntoElement {
2216 match answer {
2217 AnswerState::None => v_flex().child(
2218 Label::new("No answer block yet (stream:false; not a token stream).")
2219 .color(Color::Muted),
2220 ),
2221 AnswerState::Text { text } => v_flex()
2222 .gap_0p5()
2223 .child(
2224 Label::new("state=text (block arrived)")
2225 .color(Color::Muted)
2226 .size(LabelSize::Small),
2227 )
2228 .child(Label::new(text.clone())),
2229 AnswerState::Completed { text } => v_flex()
2230 .gap_0p5()
2231 .child(
2232 Label::new("state=completed")
2233 .color(Color::Muted)
2234 .size(LabelSize::Small),
2235 )
2236 .child(Label::new(text.clone())),
2237 }
2238}
2239
2240fn run_state_body(run: &RunStateProjection, terminal: &TerminalOutcome) -> impl IntoElement {
2241 v_flex()
2242 .gap_0p5()
2243 .child(Label::new(format!(
2244 "phase={} · interrupt={}",
2245 run.phase.label(),
2246 run.interrupt_intent.label()
2247 )))
2248 .when_some(run.turn_ref.clone(), |this, t| {
2249 this.child(Label::new(format!("turn={t}")).color(Color::Muted))
2250 })
2251 .child(Label::new(format!("terminal={}", terminal.label())).color(Color::Muted))
2252 .when_some(
2253 terminal
2254 .reason()
2255 .map(|r| r.to_string())
2256 .or_else(|| run.reason.clone()),
2257 |this, reason| {
2258 this.child(Label::new(format!("reason={reason}")).color(
2259 if terminal.is_terminal() {
2260 Color::Warning
2261 } else {
2262 Color::Muted
2263 },
2264 ))
2265 },
2266 )
2267}
2268
2269#[cfg(test)]
2270mod panel_logic_tests {
2271 use super::*;
2272 use crate::projections::WorkroomProjection;
2273 use serde_json::json;
2274
2275 #[test]
2276 fn apply_bootstrap_maps_room_fields() {
2277 let mut projection = WorkroomProjection::honest_unsubscribed();
2278 let value = json!({
2279 "room": {
2280 "principalRef": "principal.sarah",
2281 "displayName": "Sarah",
2282 "role": "principal.sarah",
2283 "threadRef": "thread.sarah.abc",
2284 },
2285 "authority": {
2286 "profile": "sarah",
2287 "revision": "7"
2288 }
2289 });
2290
2291 let room = value.get("room");
2292 projection.room = RoomProjection {
2293 meta: ProjectionMeta::fresh(sources::ROOM),
2294 principal_ref: string_field(room, &["principalRef"]),
2295 display_name: string_field(room, &["displayName"]),
2296 role: string_field(room, &["role"]),
2297 thread_ref: string_field(room, &["threadRef"]),
2298 authority_profile: string_field(value.get("authority"), &["profile"]),
2299 authority_revision: string_field(value.get("authority"), &["revision"]),
2300 detail: None,
2301 };
2302
2303 assert_eq!(
2304 projection.room.principal_ref.as_deref(),
2305 Some("principal.sarah")
2306 );
2307 assert_eq!(projection.room.display_name.as_deref(), Some("Sarah"));
2308 assert_eq!(
2309 projection.room.thread_ref.as_deref(),
2310 Some("thread.sarah.abc")
2311 );
2312 assert_eq!(projection.room.authority_revision.as_deref(), Some("7"));
2313 assert_eq!(projection.room.meta.freshness, Freshness::Fresh);
2314 }
2315
2316 #[test]
2317 fn open_focus_send_interrupt_actions_are_registered_names() {
2318 let _open = OpenPanel;
2319 let _focus = FocusComposer;
2320 let _send = SendMessage;
2321 let _interrupt = InterruptTurn;
2322 assert_eq!(WorkroomProjection::header(), "Sarah");
2323 assert_eq!(PANEL_KEY, "SarahWorkroomPanel");
2324 }
2325
2326 #[test]
2327 fn community_room_headers_and_copy_are_distinct_and_unpaid() {
2328 assert_eq!(OWNER_PRIVATE_ROOM_HEADER, "Sarah");
2329 assert_eq!(COMMUNITY_ROOM_HEADER, "Community");
2330 assert_ne!(OWNER_PRIVATE_ROOM_HEADER, COMMUNITY_ROOM_HEADER);
2331 assert!(COMMUNITY_ROOM_SUBTITLE.contains("separate"));
2332 assert!(V1_NO_PAY_ROOM_DESCRIPTION.contains("experience"));
2333 assert!(V1_NO_PAY_ROOM_DESCRIPTION.contains("not money"));
2334 assert!(
2335 !V1_NO_PAY_ROOM_DESCRIPTION
2336 .to_ascii_lowercase()
2337 .contains("earnings")
2338 );
2339 assert!(V1_NO_PAY_FIRST_RUN_COPY.contains("does not pay"));
2340 assert_eq!(EXPERIENCE_LABEL, "experience");
2341 let community = CommunityRoomProjection::honest_unsubscribed();
2342 assert!(community.is_v1_compliant());
2343 assert!(community.membership.is_honest_missing());
2344 assert!(community.work_units.is_honest_missing());
2345 assert!(community.experience.is_v1_experience_only());
2346 }
2347
2348 #[test]
2349 fn two_room_isolation_on_panel_fields() {
2350 // Mirrors SarahWorkroomPanel field layout without constructing GPUI.
2351 let mut projection = WorkroomProjection::honest_unsubscribed();
2352 let mut community = CommunityRoomProjection::honest_unsubscribed();
2353 projection.room.thread_ref = Some("thread.sarah.1".into());
2354 projection.transcript.push_bounded(TranscriptRow {
2355 message_ref: "private.1".into(),
2356 role: "owner".into(),
2357 text: "secret".into(),
2358 ack: MessageAck::Confirmed,
2359 });
2360 community.room.group_ref = Some("group.community.1".into());
2361 community.push_untrusted_message("community.1".into(), "member".into(), "hello".into());
2362 let owner_refs: std::collections::BTreeSet<&str> = projection
2363 .transcript
2364 .rows
2365 .iter()
2366 .map(|r| r.message_ref.as_str())
2367 .collect();
2368 let community_refs: std::collections::BTreeSet<&str> = community
2369 .transcript
2370 .rows
2371 .iter()
2372 .map(|r| r.message_ref.as_str())
2373 .collect();
2374 assert!(owner_refs.is_disjoint(&community_refs));
2375 assert_ne!(
2376 projection.room.thread_ref.as_deref(),
2377 community.room.group_ref.as_deref()
2378 );
2379 // Switch kind only changes active room — both stores remain.
2380 let active = RoomKind::Community;
2381 assert_eq!(active.header(), COMMUNITY_ROOM_HEADER);
2382 assert_eq!(RoomKind::OwnerPrivate.header(), OWNER_PRIVATE_ROOM_HEADER);
2383 assert_eq!(projection.transcript.rows.len(), 1);
2384 assert_eq!(community.transcript.rows.len(), 1);
2385 }
2386
2387 #[test]
2388 fn interrupt_pending_law_on_run_state() {
2389 let mut run = RunStateProjection {
2390 meta: ProjectionMeta::fresh(sources::RUN_STATE),
2391 phase: RunPhase::Running,
2392 reason: None,
2393 turn_ref: Some("turn:1".into()),
2394 interrupt_intent: InterruptIntentState::None,
2395 };
2396 run.mark_interrupt_pending();
2397 assert_eq!(run.interrupt_intent, InterruptIntentState::Pending);
2398 assert_eq!(run.phase, RunPhase::Running);
2399 assert_ne!(run.interrupt_intent, InterruptIntentState::Applied);
2400 }
2401
2402 #[test]
2403 fn parse_run_phase_covers_nr06_and_event_kinds() {
2404 assert_eq!(parse_run_phase("running"), RunPhase::Running);
2405 assert_eq!(parse_run_phase("interrupt_pending"), RunPhase::Running);
2406 assert_eq!(parse_run_phase("turn.started"), RunPhase::Running);
2407 assert_eq!(parse_run_phase("turn.finished"), RunPhase::Finished);
2408 assert_eq!(parse_run_phase("interrupted"), RunPhase::Interrupted);
2409 }
2410
2411 #[test]
2412 fn confirmed_nostr_record_projection_keeps_only_bounded_public_refs() {
2413 let value = json!({
2414 "entries": [{
2415 "eventId": "a".repeat(64),
2416 "kind": 30174,
2417 "recordKind": "memory",
2418 "authorFingerprint": "0123456789ABCDEF",
2419 "createdAt": "2026-07-24T00:00:00Z",
2420 "source": "confirmed_nostr"
2421 }],
2422 "cursor": format!("cursor.1.{}", "a".repeat(64)),
2423 "nextCursor": null,
2424 "gapState": "possible",
2425 "source": "confirmed_nostr"
2426 });
2427 let projection = parse_nostr_records_projection(Some(&value));
2428 assert_eq!(projection.rows.len(), 1);
2429 assert_eq!(projection.rows[0].kind, 30_174);
2430 assert_eq!(projection.rows[0].record_kind, "memory");
2431 assert_eq!(projection.rows[0].source, "confirmed_nostr");
2432 assert_eq!(projection.gap, GapState::Gap);
2433 assert_eq!(projection.source, "confirmed_nostr");
2434 assert!(projection.next_cursor.is_none());
2435
2436 let missing = parse_nostr_records_projection(None);
2437 assert_eq!(missing.gap, GapState::Unavailable);
2438 assert!(missing.rows.is_empty());
2439 }
2440
2441 #[test]
2442 fn apply_snapshot_maps_entries_and_proactive_turns_as_ordinary_rows() {
2443 let value = json!({
2444 "transcript": {
2445 "entries": [
2446 {
2447 "eventId": "evt.owner.1",
2448 "role": "owner",
2449 "kind": "text",
2450 "text": "status?",
2451 "status": "accepted"
2452 },
2453 {
2454 "eventId": "message.sarah_auto.tick.1",
2455 "role": "sarah",
2456 "kind": "text",
2457 "text": "Release is green.",
2458 "status": "confirmed"
2459 }
2460 ],
2461 "cursor": "cursor.1",
2462 "gapState": "none"
2463 },
2464 "activity": { "entries": [], "gapState": "none" },
2465 "runState": { "state": "idle", "turnRef": null }
2466 });
2467
2468 let mut projection = WorkroomProjection::honest_unsubscribed();
2469 let mut transcript = TranscriptProjection {
2470 meta: ProjectionMeta::fresh(sources::TRANSCRIPT),
2471 rows: Vec::new(),
2472 cursor: string_field(value.get("transcript"), &["cursor"]),
2473 truncated: false,
2474 };
2475 if let Some(items) = value
2476 .get("transcript")
2477 .and_then(|t| {
2478 t.get("entries")
2479 .or_else(|| t.get("items"))
2480 .or_else(|| t.get("messages"))
2481 })
2482 .and_then(|v| v.as_array())
2483 {
2484 for item in items {
2485 let ack = match item
2486 .get("ack")
2487 .or_else(|| item.get("status"))
2488 .or_else(|| item.get("state"))
2489 .and_then(|v| v.as_str())
2490 {
2491 Some("pending") => MessageAck::Pending,
2492 _ => MessageAck::Confirmed,
2493 };
2494 transcript.push_bounded(TranscriptRow {
2495 message_ref: string_field(
2496 Some(item),
2497 &["messageRef", "eventId", "id", "ref", "cursor"],
2498 )
2499 .unwrap_or_else(|| "unknown".into()),
2500 role: string_field(Some(item), &["role"]).unwrap_or_else(|| "unknown".into()),
2501 text: string_field(Some(item), &["text", "content"]).unwrap_or_default(),
2502 ack,
2503 });
2504 }
2505 }
2506 projection.transcript = transcript;
2507 projection.room.thread_ref = Some("thread.sarah.abc".into());
2508 projection.recompute_attention();
2509
2510 assert_eq!(projection.transcript.rows.len(), 2);
2511 assert_eq!(projection.transcript.rows[1].role, "sarah");
2512 assert_eq!(
2513 projection.transcript.rows[1].message_ref,
2514 "message.sarah_auto.tick.1"
2515 );
2516 assert_eq!(projection.transcript.rows[1].ack, MessageAck::Confirmed);
2517 // Proactive update raises the same attention path as a Q&A answer.
2518 assert_eq!(projection.attention.unread_count, 1);
2519 assert_eq!(projection.attention.marker, AttentionMarker::NeedsAttention);
2520 assert!(empty_room_is_honest(
2521 &projection.transcript,
2522 OMEGA_AUTONOMOUS_TICK_ENABLED
2523 ));
2524
2525 projection.mark_room_read();
2526 assert_eq!(projection.attention.unread_count, 0);
2527 assert_eq!(projection.attention.marker, AttentionMarker::None);
2528 }
2529
2530 #[test]
2531 fn tick_off_empty_snapshot_stays_honest() {
2532 assert!(!OMEGA_AUTONOMOUS_TICK_ENABLED);
2533 let empty = TranscriptProjection {
2534 meta: ProjectionMeta::fresh(sources::TRANSCRIPT),
2535 rows: Vec::new(),
2536 cursor: None,
2537 truncated: false,
2538 };
2539 assert!(empty_room_is_honest(&empty, OMEGA_AUTONOMOUS_TICK_ENABLED));
2540 let mut p = WorkroomProjection::honest_unsubscribed();
2541 p.transcript = empty;
2542 p.recompute_attention();
2543 assert_eq!(p.attention.unread_count, 0);
2544 assert!(!p.attention.marker.is_set());
2545 assert!(p.attention.tick_note.is_some());
2546 }
2547
2548 #[test]
2549 fn apply_snapshot_nr06_shape_maps_entries_and_run_state() {
2550 let value = json!({
2551 "transcript": {
2552 "entries": [{
2553 "eventId": "evt1",
2554 "role": "owner",
2555 "text": "hello",
2556 "status": "confirmed"
2557 }],
2558 "cursor": "cursor.0",
2559 "gapState": "none"
2560 },
2561 "activity": {
2562 "entries": [{
2563 "eventId": "act1",
2564 "entry": "tool.call",
2565 "turnRef": "turn.1",
2566 "summary": "capacity"
2567 }],
2568 "cursor": "cursor.1"
2569 },
2570 "runState": {
2571 "state": "running",
2572 "turnRef": "turn.1",
2573 "reason": null
2574 }
2575 });
2576
2577 let items = value
2578 .get("transcript")
2579 .and_then(|t| t.get("entries"))
2580 .and_then(|v| v.as_array())
2581 .expect("entries");
2582 assert_eq!(items.len(), 1);
2583 assert_eq!(
2584 string_field(Some(&items[0]), &["eventId"]).as_deref(),
2585 Some("evt1")
2586 );
2587 let phase = parse_run_phase(
2588 string_field(value.get("runState"), &["state"])
2589 .as_deref()
2590 .unwrap(),
2591 );
2592 assert_eq!(phase, RunPhase::Running);
2593 let entry_kind = string_field(
2594 value
2595 .get("activity")
2596 .and_then(|a| a.get("entries"))
2597 .and_then(|v| v.as_array())
2598 .and_then(|a| a.first()),
2599 &["entry", "kind"],
2600 );
2601 assert_eq!(entry_kind.as_deref(), Some("tool.call"));
2602 }
2603}
2604