Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions docs/eng/conversation-surface-inventory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Conversation Surface Inventory

## Context

The web UI has several pages that are visually and behaviorally close to one
another because they all orbit the same broker conversation/session graph. The
direct conversation route is currently the most polished composition, but it is
also the easiest place to notice live-panel repaint churn because it combines
the full conversation surface with the conversation inspector, terminal peek,
tail preview, and global broker updates.

This inventory names the overlap so future cleanup can preserve the aesthetic
direction while making refresh boundaries calmer.

## Close Surfaces

| Route | Primary content | Right context | Notes |
| --- | --- | --- | --- |
| `/c/:conversationId` | `ConversationScreen` in full-page mode | `ConversationInspector` | Best visual composition today: conversation owns the room, header metadata is compact, and live activity is beside the thread. |
| `/agents/:agentId/c/:conversationId` | `AgentsScreen` message tab with embedded `ConversationScreen` | `AgentsInspector` | Same message body in a profile shell. Useful for agent context, but visually heavier and less conversation-native. |
| `/agents/:agentId?tab=message` | `AgentsScreen` message tab with implied DM conversation | `AgentsInspector` | Compatibility/convenience form of the agent message route. |
| `/messages/:conversationId` | `MessagesScreen` message index/detail shell | Slot inspector selected by route | Adjacent to conversations, but starts from inbox/filter workflow rather than the thread as the main object. |
| `/conversations` | `ConversationsScreen` list | General inspector | Discovery/list route for broker conversations. |
| `/channels/:channelId` | `ChannelsScreen` channel feed | `ChannelInspectorPanel` | Parallel thread implementation for channel-shaped conversations. |
| `/agent/:conversationId` | `AgentInfoScreen` legacy profile surface | `AgentsInspector` | Legacy agent-info route that still points back to conversation/profile affordances. |
| `/terminal/:agentId` | `TerminalScreen` observe/takeover surface | `TerminalInspector` | Runtime/session surface adjacent to the thread when terminal context is the main object. |

## Inspector Overlap

- `ConversationInspector` combines conversation metadata, latest message,
active flight state, live terminal actions, `TmuxPeekPanel`, and matching Tail
preview events.
- `AgentsInspector` shows profile/context details and also includes live
terminal/observe affordances for the selected agent.
- `TerminalInspector` treats the terminal as the primary object.
- `ChannelInspectorPanel` is the channel-specific sibling for group/channel
conversations.

## Current Refresh Risk

The direct conversation page listens to several live sources at once:

- broker control events for messages, conversations, invocations, and flights
- the global agent refresh loop in `ScoutProvider`
- terminal peek polling when the agent transport is `tmux`
- Tail preview history and live Tail events

The first cleanup should keep `/c/:conversationId` as the canonical aesthetic
target, but scope each live source to the current conversation/agent and avoid
resetting state when fetched payloads are unchanged.

## Flicker Audit Findings

On the Openscout Card conversation route, the selected tmux payload was stable
across repeated samples: the terminal body hash stayed unchanged while
`capturedAt` changed. That means sample freshness was not terminal activity.

Likely flicker multipliers:

- `ConversationInspector` previously reloaded on every `flight.updated` and
`invocation.requested` event, even when unrelated to the current
conversation. This has been scoped to the current conversation/known flight
and coalesced.
- `ScoutProvider` polls `/api/agents` and also reloads agents for broad broker
events. The API can return byte-identical data, so replacing the `agents`
array still caused shell-wide React churn. Agent state now preserves the
previous array when the fetched payload is unchanged.
- `TmuxPeekPanel` polls while visible, but polling is not activity. The preview
now preserves the previous frame when pane content is unchanged and only marks
a frame as changed when the terminal content changes while the panel is
observing. A repeated identical sample settles the panel back to an `At rest`
badge.
- `ConversationScreen` still has intentional low-frequency re-renders for
relative timestamps (`15s`) and outstanding-turn polling (`5s` only while an
outstanding turn is active), but its reload path now preserves existing
message, flight, metadata, and attention-set references when fetched payloads
are unchanged.
- `ConversationScreen.load()` also posted the read cursor on every reload. The
broker emits `conversation.read_cursor.updated` with a fresh `updatedAt`, so
the client now avoids reposting the same last-read message id from repeated
identical reloads.
- Several adjacent panels still have broad `useBrokerEvents(() => load())`
subscriptions. They are route-dependent, but should be narrowed before those
surfaces are treated as canonical.

## Ambient Burst Contract

Conversation context should present activity as an ambient cue, not as the raw
stream. Tail, Terminal, Observe, and Trace remain the high-granularity surfaces.

For conversation inspectors:

- accumulate matching broker invalidations before refetching conversation
metadata, with a bounded max wait so the panel cannot go stale indefinitely
- accumulate Tail preview events into short bursts, then release them in small
batches with CSS-owned easing
- keep terminal previews visible when useful, but treat tmux sampling as
background observation; visible motion/freshness should be based on terminal
content changes, not poll timestamps
- preserve existing row and panel state while fetching or releasing bursts
- keep animation timing and easing as module/CSS constants, not per-frame React
calculations
- link to Tail for the detailed stream instead of turning the inspector into a
second raw event feed

## Cleanup Direction

- Treat `/c/:conversationId` as the canonical thread composition.
- Keep agent-scoped message routes as compatibility/profile entry points unless
product navigation intentionally chooses the profile shell.
- Extract the message feed so direct, agent, and channel routes share message
loading, optimistic reconciliation, and broker event scoping.
- Share live activity components across conversation, agent, and terminal
inspectors with explicit refresh contracts.
- Reserve terminal-focused routes for observe/takeover work, and keep compact
terminal peeks visually stable inside inspectors.
37 changes: 37 additions & 0 deletions packages/runtime/src/local-agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
clearEndpointFailureMetadata,
endpointStateAfterSuccessfulSessionWarmup,
areHarnessBinariesAvailable,
brokerSnapshotMessages,
normalizeClaudeRuntimeLaunchArgs,
normalizeLocalAgentSystemPrompt,
renderLocalAgentSystemPromptTemplate,
Expand Down Expand Up @@ -494,3 +495,39 @@ describe("local agent reply cleanup", () => {
expect(cleaned).toBe("SHAPER_BROKER_OK");
});
});

describe("local agent broker snapshots", () => {
test("treats missing or malformed broker snapshot messages as empty", () => {
expect(brokerSnapshotMessages(undefined)).toEqual([]);
expect(brokerSnapshotMessages({})).toEqual([]);
expect(brokerSnapshotMessages({ messages: null })).toEqual([]);
expect(brokerSnapshotMessages({ messages: [] })).toEqual([]);
});

test("filters malformed broker snapshot messages", () => {
expect(brokerSnapshotMessages({
messages: {
valid: {
actorId: "agent-1",
body: "ready",
createdAt: 123,
},
missingBody: {
actorId: "agent-2",
createdAt: 124,
},
badTimestamp: {
actorId: "agent-3",
body: "bad",
createdAt: "124",
},
},
})).toEqual([
{
actorId: "agent-1",
body: "ready",
createdAt: 123,
},
]);
});
});
17 changes: 16 additions & 1 deletion packages/runtime/src/local-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,21 @@ interface BrokerSnapshot {
messages: Record<string, BrokerSnapshotMessage>;
}

function isBrokerSnapshotMessage(value: unknown): value is BrokerSnapshotMessage {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<BrokerSnapshotMessage>;
return typeof candidate.actorId === "string"
&& typeof candidate.body === "string"
&& typeof candidate.createdAt === "number";
}

export function brokerSnapshotMessages(value: unknown): BrokerSnapshotMessage[] {
if (!value || typeof value !== "object") return [];
const messages = (value as { messages?: unknown }).messages;
if (!messages || typeof messages !== "object" || Array.isArray(messages)) return [];
return Object.values(messages).filter(isBrokerSnapshotMessage);
}

const DEFAULT_LOCAL_AGENT_CAPABILITIES: AgentCapability[] = ["chat", "invoke", "deliver"];
const DEFAULT_LOCAL_AGENT_HARNESS: AgentHarness = "claude";
const DEFAULT_ONE_TIME_LOCAL_AGENT_CARD_TTL_MS = 24 * 60 * 60 * 1000;
Expand Down Expand Up @@ -2420,7 +2435,7 @@ async function readBrokerMessagesSince(sinceSeconds: number): Promise<BrokerSnap
const snapshot = await requestScoutBrokerJson<BrokerSnapshot>(baseUrl, "/v1/snapshot", {
socketPath: resolveBrokerSocketPathForBaseUrl(baseUrl),
});
return Object.values(snapshot.messages)
return brokerSnapshotMessages(snapshot)
.filter((message) => normalizeBrokerTimestamp(message.createdAt) >= sinceSeconds)
.sort((lhs, rhs) => normalizeBrokerTimestamp(lhs.createdAt) - normalizeBrokerTimestamp(rhs.createdAt));
}
Expand Down
9 changes: 9 additions & 0 deletions packages/web/client/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@
}

* { box-sizing: border-box; margin: 0; }

html,
body,
#root {
min-height: 100%;
background: var(--bg, #050605);
color: var(--ink, #f3f4ee);
}

body { margin: 0; }

/* ── Content shell ── */
Expand Down
22 changes: 17 additions & 5 deletions packages/web/client/components/AgentLiveActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,18 @@ export function AgentLiveActions({
const state = normalizeAgentState(agent.state);
const canObserveTerminal = agent.transport === "tmux" && Boolean(activeSessionId);
const canTakeover = canObserveTerminal || Boolean(resolvedCatalog?.resumeCommand);
const hasLiveTurn = Boolean(activeSessionId) || state === "working";
const hasLiveTurn = state === "working";
const isCompact = variant === "compact";
const status = hasLiveTurn
? activeSessionId
? `Live ${shortSession(activeSessionId)}`
? isCompact
? canObserveTerminal ? "Live terminal" : "Live trace"
: `Live ${shortSession(activeSessionId)}`
: "Working"
: activeSessionId
? isCompact
? canObserveTerminal ? "Terminal" : "Trace"
: `${canObserveTerminal ? "Terminal" : "Trace"} ${shortSession(activeSessionId)}`
: state === "ready"
? "Ready"
: "No live turn";
Expand All @@ -87,7 +94,12 @@ export function AgentLiveActions({
: agent.updatedAt
? timeAgo(agent.updatedAt)
: null;
const observeLabel = canObserveTerminal ? "Observe terminal" : "Observe trace";
const observeLabel = canObserveTerminal
? isCompact ? "Observe" : "Observe terminal"
: isCompact ? "Trace" : "Observe trace";
const observeTitle = canObserveTerminal
? "Observe the tmux terminal"
: "Open the web observe trace";

const openTerminal = (mode: "observe" | "takeover") => {
onNavigate?.();
Expand Down Expand Up @@ -143,8 +155,8 @@ export function AgentLiveActions({
type="button"
className="agent-live-actions-button agent-live-actions-button--primary"
onClick={() => canObserveTerminal ? openTerminal("observe") : openTrace()}
title={canObserveTerminal ? "Observe the live tmux terminal" : "Open the web observe trace"}
aria-label={observeLabel}
title={observeTitle}
aria-label={observeTitle}
>
<Eye size={13} strokeWidth={1.9} aria-hidden="true" />
<span>{observeLabel}</span>
Expand Down
35 changes: 25 additions & 10 deletions packages/web/client/components/agent-live-actions.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,18 @@
}

.agent-live-actions--compact {
flex-direction: column;
align-items: stretch;
gap: var(--space-sm);
padding: var(--space-sm) 0 0;
gap: var(--space-xs);
padding: 0;
border: 0;
border-top: 1px solid color-mix(in srgb, var(--ink) 5%, transparent);
border-radius: 0;
background: transparent;
}

.agent-live-actions--compact .agent-live-actions-status {
flex: 1 1 auto;
min-width: 0;
}

.agent-live-actions-status {
display: flex;
align-items: center;
Expand Down Expand Up @@ -126,18 +128,22 @@
}

.agent-live-actions--compact .agent-live-actions-buttons {
justify-content: flex-start;
flex: 0 0 auto;
flex-wrap: nowrap;
justify-content: flex-end;
gap: var(--space-2xs);
}

.agent-live-actions--compact .agent-live-actions-button {
min-height: 26px;
padding: 0 var(--space-sm);
min-height: 24px;
padding: 0 var(--space-xs);
border-radius: var(--radius-sm);
font-size: var(--text-2xs);
}

.agent-live-actions--compact .agent-live-actions-button--primary {
flex: 1 1 100%;
min-height: 30px;
flex: 0 0 auto;
min-height: 24px;
}

@media (max-width: 720px) {
Expand All @@ -149,4 +155,13 @@
.agent-live-actions-buttons {
justify-content: flex-start;
}

.agent-live-actions--compact {
flex-direction: row;
align-items: center;
}

.agent-live-actions--compact .agent-live-actions-buttons {
justify-content: flex-end;
}
}
13 changes: 13 additions & 0 deletions packages/web/client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scout</title>
<style>
html,
body,
#root {
min-height: 100%;
background: #050605;
color: #f3f4ee;
}

body {
margin: 0;
}
</style>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter+Tight:wght@400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&family=Play:wght@400;700&family=Spectral:wght@500;600&display=swap" rel="stylesheet" />
Expand Down
12 changes: 12 additions & 0 deletions packages/web/client/lib/message-visibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Message } from "./types.ts";

export function isNoisyConversationStatusMessage(
message: Pick<Message, "actorId" | "body" | "class">,
): boolean {
if (message.class !== "status") return false;
if (message.actorId !== "system") return false;
return (
message.body.includes("failed to respond") &&
message.body.includes("snapshot.messages")
);
}
12 changes: 12 additions & 0 deletions packages/web/client/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,18 @@ export type AgentObservePayload = {
data: ObserveData;
};

export type TmuxPeekPayload = {
available: boolean;
agentId: string;
sessionId: string | null;
capturedAt: number;
body: string;
lineCount: number;
columnCount: number;
truncated: boolean;
reason: string | null;
};

export type SessionCatalogEntry = {
id: string;
startedAt: number;
Expand Down
10 changes: 9 additions & 1 deletion packages/web/client/scout/Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ const AGENT_REFRESH_POLL_MS = 15_000;

type ThemeVars = CSSProperties & Record<`--${string}`, string>;

function keepPreviousIfJsonEqual<T>(previous: T, next: T): T {
try {
return JSON.stringify(previous) === JSON.stringify(next) ? previous : next;
} catch {
return next;
}
}

const DARK_THEME_VARS: ThemeVars = {
"--hud-bg": "oklch(0.14 0.008 80)",
"--hud-surface": "oklch(0.18 0.009 80)",
Expand Down Expand Up @@ -240,7 +248,7 @@ export function ScoutProvider({
const request = (async () => {
const agentsResult = await api<Agent[]>("/api/agents").catch(() => null);
if (agentsResult) {
setAgents(agentsResult);
setAgents((previous) => keepPreviousIfJsonEqual(previous, agentsResult));
}
})();

Expand Down
Loading