Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:54:26.991Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

panel.rs

2604 lines · 103.3 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.