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:
@@ -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.
|
||||
@@ -2,8 +2,10 @@ import { type CSSProperties, useEffect, useRef, useState } from 'react';
|
||||
import { RoomEvent, Track } from 'livekit-client';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BrainIcon,
|
||||
CheckIcon,
|
||||
CircleDotIcon,
|
||||
EyeIcon,
|
||||
GitBranchIcon,
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
@@ -12,13 +14,20 @@ import {
|
||||
PanelLeftIcon,
|
||||
PanelRightIcon,
|
||||
RadioTowerIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
TriangleAlertIcon,
|
||||
Volume2Icon,
|
||||
WorkflowIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
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 { useInterventions, primeSpeech } from '../livekit/useInterventions.js';
|
||||
import { usePodActivity } from '../hooks/use-pod-activity.js';
|
||||
@@ -698,11 +707,39 @@ function ActivitySidebar({
|
||||
<SidebarContent className="px-3 py-3">
|
||||
<div className="group-data-[collapsible=icon]:hidden">
|
||||
{events.length ? (
|
||||
<div className="flex max-h-[calc(100svh-9rem)] flex-col gap-3 overflow-y-auto pr-1">
|
||||
{events.map((event) => (
|
||||
<div className="flex max-h-[calc(100svh-9rem)] flex-col gap-4 overflow-y-auto pr-1">
|
||||
{ACTIVITY_CATEGORIES.map((category) => {
|
||||
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>
|
||||
) : (
|
||||
<Empty className="min-h-72 border-0 p-0">
|
||||
<EmptyHeader>
|
||||
@@ -791,6 +828,13 @@ function ActivityItem({ event }: { event: PodActivityEvent }) {
|
||||
</p>
|
||||
)}
|
||||
<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) => (
|
||||
<ActivityBadge
|
||||
key={`${item.variant}-${item.label}-${index}`}
|
||||
@@ -829,7 +873,6 @@ function activityMetadata(event: PodActivityEvent): {
|
||||
]
|
||||
: []),
|
||||
...(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) {
|
||||
switch (kind) {
|
||||
case 'git':
|
||||
|
||||
Reference in New Issue
Block a user