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 { 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':
|
||||||
|
|||||||
Reference in New Issue
Block a user