feat: split pod streams into Signals vs Reasoning sections

My/Team streams were one flat lane; source provenance was buried as a
gray badge by the filename. Group each lane into Signals (vision/git
inputs) and Reasoning & decisions (collision/intervention/outcome), and
promote source to a color-coded provenance chip with a readable kind
label. Presentation-only; no shared-type or backend changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpySDjxpM1usXMEngJRWqG
This commit is contained in:
Ramis
2026-06-27 22:08:53 -07:00
parent 4427e18bb9
commit aeb75c310e
2 changed files with 244 additions and 6 deletions
+108
View File
@@ -0,0 +1,108 @@
# Stream Categorization — My Stream / Team Stream
## Why
Judges must read the pod stream in 10 seconds. Right now both lanes (`My stream`,
`Team stream`) dump every event into one flat chronological list. The two things
that prove "self-improving agent" are mushed together:
- **Sources of decisions** — raw signals the agent observed (screen vision, git).
- **Reasoning decisions** — what Hermes concluded and did (conflict detected,
intervention spoken, outcome/verifier result).
`source` (vision/git/memory/hermes/policy) is currently buried as a plain outline
badge next to the filename. `kind` is only an icon. No sectioning. Result: bloated,
undifferentiated, doesn't tell the loop story (observe → reason → act → learn).
## Goal
Split each stream lane into clear sections and promote provenance, so a judge sees:
"agent ingests **signals**, then makes **reasoning decisions** from them."
No backend / shared-type changes. The data already carries `kind` + `source`.
Pure presentation change in `frontend/src/components/PodView.tsx`
`ActivitySidebar` + `ActivityItem` + new helpers only. Additive, localized.
## Categorization (the contract)
Two sections, derived from existing `PodActivityKind`:
| Section | Heading | kinds | Meaning |
|---|---|---|---|
| `signal` | **Signals** | `observation`, `git` | Raw inputs the agent saw. The *sources*. |
| `decision` | **Reasoning & decisions** | `collision`, `intervention`, `outcome` | What Hermes reasoned and did. |
```ts
const CATEGORY_OF: Record<PodActivityKind, 'signal' | 'decision'> = {
observation: 'signal',
git: 'signal',
collision: 'decision',
intervention: 'decision',
outcome: 'decision',
};
```
Section order: **Signals** first, **Reasoning & decisions** second (top-to-bottom =
the loop direction). A section with zero events renders nothing.
## Provenance chip (sources)
Promote `source` to a leading color-coded chip with an icon. This is the "source of
decision" tag judges look for.
| source | label | icon | tint |
|---|---|---|---|
| `vision` | Vision | `EyeIcon` | chart-1 |
| `git` | Git | `GitBranchIcon` | chart-2 |
| `memory` | Memory | `BrainIcon` | chart-4 |
| `hermes` | Hermes | `SparklesIcon` | primary |
| `policy` | Policy | `ShieldIcon` | chart-3 |
Chip class pattern (use `variant="outline"` so the default primary fill is overridden):
`border-{tint}/40 bg-{tint}/10 text-{tint}`.
## Kind label
Render `kind` as readable text next to the provenance chip, not just an icon:
| kind | label |
|---|---|
| observation | Observed |
| git | Git |
| collision | Conflict |
| intervention | Intervention |
| outcome | Outcome |
Tag row order in each card: **source chip → kind label → actors → file**.
## Implementation steps (after teammate lands frontend work)
1. **Rebase / pull teammate's PodView.tsx first.** Do not start before it lands —
this file is being actively rewritten right now.
2. Add icon imports: `BrainIcon`, `EyeIcon`, `ShieldIcon`, `WorkflowIcon` (and
reuse `GitBranchIcon`, `SparklesIcon`, `RadioTowerIcon`). Import
`PodActivitySource` type from `@podman/shared`.
3. Add module-level consts: `CATEGORY_OF`, `CATEGORIES` (id/label/hint/icon),
`SOURCE_META` (label/icon/className), `KIND_LABEL`.
4. In `ActivitySidebar`'s expanded `SidebarContent`, replace the flat
`events.map(...)` with a `CATEGORIES.map(...)` that filters events per category,
skips empty sections, and renders a section header (icon + label + count + hint)
above each group.
5. In `ActivityItem`, replace the buried source `<Badge variant="outline">{source}</Badge>`
with the colored provenance chip + a kind-label badge; keep actors and file badges.
6. Collapsed icon-rail (the `group-data-[collapsible=icon]` mini list) stays flat —
no sectioning needed there.
7. Verify: `pnpm --filter @podman/frontend build` typechecks; empty-state and a
live pod with mixed events both render correctly.
## Out of scope (do not do)
- No changes to `shared/src/activity.ts`, the SSE hook, or backend event emission.
- No new event kinds/sources.
- No third section / per-kind lanes — two buckets is the whole point (signals vs
reasoning). Keeps a sparse demo stream from fragmenting.
## Merge-safety note
Single file, two functions. Hold until the teammate improving the frontend pushes,
then pull and apply on top to avoid clobbering their design pass.
+136 -6
View File
@@ -2,8 +2,10 @@ import { type CSSProperties, useEffect, useRef, useState } from 'react';
import { RoomEvent, Track } from 'livekit-client'; import { RoomEvent, Track } from 'livekit-client';
import { import {
ArrowLeftIcon, ArrowLeftIcon,
BrainIcon,
CheckIcon, CheckIcon,
CircleDotIcon, CircleDotIcon,
EyeIcon,
GitBranchIcon, GitBranchIcon,
ExternalLinkIcon, ExternalLinkIcon,
FileTextIcon, FileTextIcon,
@@ -12,13 +14,20 @@ import {
PanelLeftIcon, PanelLeftIcon,
PanelRightIcon, PanelRightIcon,
RadioTowerIcon, RadioTowerIcon,
ShieldIcon,
SparklesIcon, SparklesIcon,
TriangleAlertIcon, TriangleAlertIcon,
Volume2Icon, Volume2Icon,
WorkflowIcon,
XIcon, XIcon,
} from 'lucide-react'; } from 'lucide-react';
import type { Room, RemoteTrack, RemoteTrackPublication, RemoteParticipant } from 'livekit-client'; import type { Room, RemoteTrack, RemoteTrackPublication, RemoteParticipant } from 'livekit-client';
import type { Pod, PodActivityEvent, PodActivityKind } from '@podman/shared'; import type {
Pod,
PodActivityEvent,
PodActivityKind,
PodActivitySource,
} from '@podman/shared';
import { startBeat, type BeatHandle } from '../lib/beat.js'; import { startBeat, type BeatHandle } from '../lib/beat.js';
import { useInterventions, primeSpeech } from '../livekit/useInterventions.js'; import { useInterventions, primeSpeech } from '../livekit/useInterventions.js';
import { usePodActivity } from '../hooks/use-pod-activity.js'; import { usePodActivity } from '../hooks/use-pod-activity.js';
@@ -698,10 +707,38 @@ function ActivitySidebar({
<SidebarContent className="px-3 py-3"> <SidebarContent className="px-3 py-3">
<div className="group-data-[collapsible=icon]:hidden"> <div className="group-data-[collapsible=icon]:hidden">
{events.length ? ( {events.length ? (
<div className="flex max-h-[calc(100svh-9rem)] flex-col gap-3 overflow-y-auto pr-1"> <div className="flex max-h-[calc(100svh-9rem)] flex-col gap-4 overflow-y-auto pr-1">
{events.map((event) => ( {ACTIVITY_CATEGORIES.map((category) => {
<ActivityItem key={event.id} event={event} /> const items = events.filter(
))} (event) => CATEGORY_OF[event.kind] === category.id,
);
if (!items.length) return null;
const CategoryIcon = category.icon;
return (
<section key={category.id} className="flex flex-col gap-2">
<div className="flex items-center gap-2 px-0.5">
<CategoryIcon className="size-3.5 text-muted-foreground" />
<h3 className="text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">
{category.label}
</h3>
<Badge
variant="secondary"
className="rounded-md px-1.5 py-0 text-[0.65rem]"
>
{items.length}
</Badge>
</div>
<p className="-mt-1 px-0.5 text-[0.65rem] leading-4 text-muted-foreground/70">
{category.hint}
</p>
<div className="flex flex-col gap-3">
{items.map((event) => (
<ActivityItem key={event.id} event={event} />
))}
</div>
</section>
);
})}
</div> </div>
) : ( ) : (
<Empty className="min-h-72 border-0 p-0"> <Empty className="min-h-72 border-0 p-0">
@@ -791,6 +828,13 @@ function ActivityItem({ event }: { event: PodActivityEvent }) {
</p> </p>
)} )}
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5"> <div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
<SourceChip source={event.source} />
<Badge
variant="outline"
className="rounded-md px-1.5 py-0 text-[0.68rem] leading-4"
>
{KIND_LABEL[event.kind]}
</Badge>
{metadata.map((item, index) => ( {metadata.map((item, index) => (
<ActivityBadge <ActivityBadge
key={`${item.variant}-${item.label}-${index}`} key={`${item.variant}-${item.label}-${index}`}
@@ -829,7 +873,6 @@ function activityMetadata(event: PodActivityEvent): {
] ]
: []), : []),
...(event.file ? [{ label: event.file, variant: 'outline' as const }] : []), ...(event.file ? [{ label: event.file, variant: 'outline' as const }] : []),
{ label: event.source, variant: 'outline' as const },
]; ];
} }
@@ -853,6 +896,93 @@ function ActivityBadge({
); );
} }
type ActivityCategoryId = 'signal' | 'decision';
const CATEGORY_OF: Record<PodActivityKind, ActivityCategoryId> = {
observation: 'signal',
git: 'signal',
collision: 'decision',
intervention: 'decision',
outcome: 'decision',
};
const ACTIVITY_CATEGORIES: {
id: ActivityCategoryId;
label: string;
hint: string;
icon: typeof RadioTowerIcon;
}[] = [
{
id: 'signal',
label: 'Signals',
hint: 'Raw inputs the agent observed — screen vision and git activity.',
icon: RadioTowerIcon,
},
{
id: 'decision',
label: 'Reasoning & decisions',
hint: 'What Hermes concluded and acted on — conflicts, interventions, outcomes.',
icon: WorkflowIcon,
},
];
const KIND_LABEL: Record<PodActivityKind, string> = {
observation: 'Observed',
git: 'Git',
collision: 'Conflict',
intervention: 'Intervention',
outcome: 'Outcome',
};
const SOURCE_META: Record<
PodActivitySource,
{ label: string; icon: typeof RadioTowerIcon; className: string }
> = {
vision: {
label: 'Vision',
icon: EyeIcon,
className: 'border-chart-1/40 bg-chart-1/10 text-chart-1',
},
git: {
label: 'Git',
icon: GitBranchIcon,
className: 'border-chart-2/40 bg-chart-2/10 text-chart-2',
},
memory: {
label: 'Memory',
icon: BrainIcon,
className: 'border-chart-4/40 bg-chart-4/10 text-chart-4',
},
hermes: {
label: 'Hermes',
icon: SparklesIcon,
className: 'border-primary/40 bg-primary/10 text-primary',
},
policy: {
label: 'Policy',
icon: ShieldIcon,
className: 'border-chart-3/40 bg-chart-3/10 text-chart-3',
},
};
function SourceChip({ source }: { source: PodActivitySource }) {
const meta = SOURCE_META[source];
const Icon = meta.icon;
return (
<Badge
variant="outline"
title={`Source: ${meta.label}`}
className={cn(
'gap-1 rounded-md border px-1.5 py-0 text-[0.68rem] font-medium leading-4',
meta.className,
)}
>
<Icon className="size-3" />
{meta.label}
</Badge>
);
}
function activityIcon(kind: PodActivityKind) { function activityIcon(kind: PodActivityKind) {
switch (kind) { switch (kind) {
case 'git': case 'git':