Skip to content
Open
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
43 changes: 43 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"class-variance-authority": "^0.7.1",
"d3-force": "^3.0.0",
"electron": "^39.8.5",
"framer-motion": "^12.42.2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
Expand Down
48 changes: 32 additions & 16 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,17 @@ export default function App({ host }: ShadowAgentAppProps) {
const GraphCanvas = rendererSurfaceAdapter.GraphCanvas;
const TimelineSurface = rendererSurfaceAdapter.Timeline;
const ShadowPanelSurface = rendererSurfaceAdapter.ShadowPanel;
// ExhibitStage is the primary main-area surface. Guarded so adapter mocks in
// existing tests (which omit it) keep rendering the legacy panels unchanged.
const ExhibitStageSurface = rendererSurfaceAdapter.ExhibitStage;

const liveGraphNode = (
<GraphCanvas
agentNodes={snapshot?.state.agentNodes ?? []}
latestInsight={snapshot ? pickPrimaryModelInsight(snapshot.state.shadowInsights) : undefined}
riskLevel={snapshot ? deriveRiskLevel(snapshot.state.riskSignals) : undefined}
/>
);

return (
<div className="app-shell">
Expand Down Expand Up @@ -490,18 +501,22 @@ export default function App({ host }: ShadowAgentAppProps) {
<h2>{snapshot?.state.currentObjective ?? 'Waiting for snapshot data'}</h2>
</section>

<div className="panels panels--3col">
<Panel title="Graph" eyebrow="Agent topology" className="panel--wide panel--graph">
<div className="graph-shell">
<GraphCanvas
agentNodes={snapshot?.state.agentNodes ?? []}
latestInsight={snapshot ? pickPrimaryModelInsight(snapshot.state.shadowInsights) : undefined}
riskLevel={snapshot ? deriveRiskLevel(snapshot.state.riskSignals) : undefined}
/>
</div>
</Panel>

<div className="panels__left-bottom">
{ExhibitStageSurface ? (
<section className="exhibit-surface">
<ExhibitStageSurface liveGraph={liveGraphNode} />
</section>
) : null}

<details className="secondary-surfaces" open={!ExhibitStageSurface}>
<summary className="secondary-surfaces__summary">Session panels</summary>
<div className="panels panels--3col">
{ExhibitStageSurface ? null : (
<Panel title="Graph" eyebrow="Agent topology" className="panel--wide panel--graph">
<div className="graph-shell">{liveGraphNode}</div>
</Panel>
)}

<div className="panels__left-bottom">
<TimelineSurface timeline={snapshot?.state.timeline ?? []} />
<Panel title="Privacy" eyebrow="Consent gates">
<PrivacyPanel
Expand All @@ -527,10 +542,11 @@ export default function App({ host }: ShadowAgentAppProps) {
insights={snapshot?.state.shadowInsights ?? []}
/>

<Panel title="File Attention" eyebrow="Hot spots" className="panel--wide">
<FileAttentionView files={snapshot?.state.fileAttention ?? []} />
</Panel>
</div>
<Panel title="File Attention" eyebrow="Hot spots" className="panel--wide">
<FileAttentionView files={snapshot?.state.fileAttention ?? []} />
</Panel>
</div>
</details>
</main>
</div>
);
Expand Down
115 changes: 115 additions & 0 deletions src/renderer/exhibits/ActivityNarrative.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Exhibit 02 — Activity Narrative (the Folding Timeline). Ported from prototype
* view 02: interesting moments ONLY, folding out of a central spine, alternating
* top/bottom. Connectors between consecutive beats are colored by whether the
* storyline thread continues; the trace advances beat by beat while idle.
*/
import { Fragment, useEffect, useMemo, useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { Chip, Frame, cx } from './primitives';
import type { ActivityNarrativePayload, ExhibitArtifactOf } from './types';

export interface ActivityNarrativeProps {
payload: ActivityNarrativePayload;
artifact: ExhibitArtifactOf<'activity_narrative'>;
}

export default function ActivityNarrative({ payload }: ActivityNarrativeProps) {
const reduce = useReducedMotion();
const { beats, threads } = payload;
const [active, setActive] = useState(beats.length - 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Initializing the slider index with beats.length - 1 produces -1 when the payload has zero beats, which makes the range input value invalid and leaves the component in an out-of-range state. Initialize to 0 and clamp to valid bounds. [logic error]

Severity Level: Major ⚠️
- ⚠️ Activity Narrative breaks when beats array is empty.
- ⚠️ Scrub label disappears; slider index becomes inconsistent.
Steps of Reproduction ✅
1. A host embeds the renderer via `App` (src/renderer/App.tsx), which uses the default
surface adapter from `getRendererSurfaceAdapter()`
(src/renderer/renderer-surface-adapter.tsx:15-24) and mounts `ExhibitStage`
(src/renderer/exhibits/ExhibitStage.tsx:101) as the main exhibit floor.

2. In a live or customized setup, the host passes an `artifacts` prop to `ExhibitStage`
(ExhibitStage.tsx:95-101) that includes an `activity_narrative` `ExhibitArtifact` whose
payload has `beats: []` but a valid `threads` array, matching the
`ActivityNarrativePayload` shape.

3. When that artifact is selected as `current` inside `ExhibitStage`
(ExhibitStage.tsx:115-117), `ActiveExhibit` (ExhibitStage.tsx:72-75) renders
`ActivityNarrative` with `payload={artifact.payload}`, and `ActivityNarrative` initializes
`active` with `useState(beats.length - 1)` at
src/renderer/exhibits/ActivityNarrative.tsx:20; with zero beats this evaluates to `-1`.

4. The scrub control renders with `min={0}`, `max={Math.max(0, beats.length - 1)}` and
`value={active}` at ActivityNarrative.tsx:104-108, producing `min=0`, `max=0`, `value=-1`,
while the label uses `beats[active]?.at` at line 102, which is `undefined`. The browser
clamps the thumb to the 0 position, but the component’s internal index is out of range and
the current beat label is blank, confirming the negative-index bug when `beats` is empty.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/renderer/exhibits/ActivityNarrative.tsx
**Line:** 20:20
**Comment:**
	*Logic Error: Initializing the slider index with `beats.length - 1` produces `-1` when the payload has zero beats, which makes the range input value invalid and leaves the component in an out-of-range state. Initialize to `0` and clamp to valid bounds.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

const threadColor = useMemo(
() => Object.fromEntries(threads.map((t) => [t.id, t.color ?? 'rgba(56,189,248,0.65)'])),
[threads]
);

const step = beats.length > 1 ? Math.min(14.5, 80 / (beats.length - 1)) : 14.5;

useEffect(() => {
if (reduce || beats.length <= 1) return;
const id = window.setInterval(() => {
setActive((prev) => (prev + 1) % beats.length);
}, 2600);
return () => window.clearInterval(id);
}, [beats.length, reduce]);

return (
<Frame>
<div className="exhibit-frame__chips">
<Chip>interesting moments only</Chip>
<Chip>alternating tile foldout</Chip>
</div>

<div className="activity-spine" />

<svg viewBox="0 0 100 100" className="exhibit-svg" preserveAspectRatio="none">
{beats.map((beat, idx) => {
if (idx === 0) return null;
const ax = 10 + (idx - 1) * step;
const bx = 10 + idx * step;
const sameThread = beats[idx - 1].thread === beat.thread;
const y = sameThread ? 47 : 53;
const shown = idx <= active;
return (
<motion.path
key={`${beats[idx - 1].at}-${beat.at}-${idx}`}
d={`M ${ax} 50 Q ${(ax + bx) / 2} ${y} ${bx} 50`}
fill="none"
stroke={sameThread ? threadColor[beat.thread] ?? 'rgba(56,189,248,0.65)' : 'rgba(255,255,255,0.18)'}
strokeWidth="0.5"
initial={reduce ? false : { pathLength: 0, opacity: 0.2 }}
animate={{ pathLength: shown ? 1 : 0, opacity: shown ? 1 : 0.15 }}
transition={{ duration: reduce ? 0 : 0.6 }}
/>
);
})}
</svg>

{beats.map((beat, idx) => {
const x = 10 + idx * step;
const activeNow = idx <= active;
const selected = idx === active;
return (
<Fragment key={`${beat.at}-${idx}`}>
<motion.div
className={cx(
'activity-dot',
selected ? 'activity-dot--selected' : activeNow ? 'activity-dot--active' : 'activity-dot--idle'
)}
style={{ left: `${x}%`, top: '50%' }}
animate={selected && !reduce ? { scale: [1, 1.25, 1] } : { scale: 1 }}
transition={selected && !reduce ? { repeat: Infinity, duration: 1.8 } : { duration: 0.2 }}
/>
<motion.div
initial={reduce ? false : { opacity: 0, y: beat.side === 'top' ? 12 : -12 }}
animate={{ opacity: activeNow ? 1 : 0.18, y: 0, scale: selected ? 1.02 : 1 }}
transition={{ duration: reduce ? 0 : 0.45 }}
className={cx('activity-card', selected && 'activity-card--selected')}
style={{ left: `${x}%`, top: beat.side === 'top' ? '15%' : '58%' }}
>
<div className="activity-card__day">{beat.at}</div>
<div className="activity-card__title">{beat.title}</div>
<p className="activity-card__body">{beat.body}</p>
<div className="activity-card__thread">thread · {beat.thread}</div>
</motion.div>
</Fragment>
);
})}

<div className="activity-scrub">
<div className="activity-scrub__labels">
<span>scrub interesting beats</span>
<span>{beats[active]?.at}</span>
</div>
<input
type="range"
min={0}
max={Math.max(0, beats.length - 1)}
value={active}
aria-label="scrub interesting beats"
onChange={(e) => setActive(Number(e.target.value))}
/>
</div>
</Frame>
);
}
94 changes: 94 additions & 0 deletions src/renderer/exhibits/ConcernSnapshot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Exhibit 04 — Concern Snapshot. Ported from prototype view 04 (Architecture
* Snapshot), re-aimed at agent-session observation: the work abstracted into
* 5-8 *concerns* (not directories) with human-language roles, heat-classed
* boxes, and flows between them. Hovering a concern selects it.
*/
import { useMemo, useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { Chip, Frame, cx, curvedPath } from './primitives';
import type { ConcernSnapshotPayload, ExhibitArtifactOf, HeatLevel } from './types';

export interface ConcernSnapshotProps {
payload: ConcernSnapshotPayload;
artifact: ExhibitArtifactOf<'concern_snapshot'>;
}

const HEAT_WIDTH: Record<HeatLevel, string> = {
low: '24%',
medium: '48%',
high: '74%',
'very-high': '92%',
};

export default function ConcernSnapshot({ payload }: ConcernSnapshotProps) {
const reduce = useReducedMotion();
const { concerns, flows } = payload;
const byId = useMemo(() => Object.fromEntries(concerns.map((c) => [c.id, c])), [concerns]);
const [selected, setSelected] = useState(concerns[0]?.id ?? '');
const current = byId[selected] ?? concerns[0];
Comment on lines +28 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The selected concern id is initialized from the first payload item only once and never realigned when concerns changes, so after live artifact refreshes it can point to a removed id and desynchronize highlight vs focus-card content. Reset or clamp selection when the concern list updates. [stale reference]

Severity Level: Major ⚠️
- ⚠️ Concern Snapshot focus card can desync from visual highlight.
- ⚠️ Live refreshes may leave selection pointing to removed concern.
Steps of Reproduction ✅
1. The `concern_snapshot` artifact from the fixture gallery
(src/renderer/exhibits/fixture-gallery.ts:171-201) is rendered through `ExhibitStage` as
`current`, and `ActiveExhibit` (src/renderer/exhibits/ExhibitStage.tsx:76) mounts
`ConcernSnapshot` with `payload={artifact.payload}`.

2. On initial render `ConcernSnapshot` builds `byId` from `concerns` at
src/renderer/exhibits/ConcernSnapshot.tsx:27 and initializes `selected` to
`concerns[0]?.id ?? ''` at line 28, while `current` is computed as `byId[selected] ??
concerns[0]` at line 29 so that the focus card and selected box match.

3. A user hovers or focuses a different concern box in the map (the `motion.button` loop
at ConcernSnapshot.tsx:60-78), which calls `setSelected(concern.id)` at lines 66-67. The
selected box is highlighted via `selected === concern.id && 'concern-box--selected'` at
lines 68-72, and the focus card mirrors `current` for that id.

4. When a future host refreshes the artifact by changing `payload.concerns` in place (same
artifact id, new concerns array that removes or renames the previously selected id),
`byId` recomputes from the new concerns, but `selected` still holds the stale id.
`current` falls back to `concerns[0]` because `byId[selected]` is undefined, while the
highlight condition `selected === concern.id` no longer matches any entry. The result is a
focus card showing the first concern with no box visually marked as selected, confirming
the desynchronized selection state after live payload updates.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/renderer/exhibits/ConcernSnapshot.tsx
**Line:** 28:29
**Comment:**
	*Stale Reference: The selected concern id is initialized from the first payload item only once and never realigned when `concerns` changes, so after live artifact refreshes it can point to a removed id and desynchronize highlight vs focus-card content. Reset or clamp selection when the concern list updates.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎


return (
<Frame>
<div className="exhibit-frame__chips">
<Chip>5-8 concern boxes</Chip>
<Chip>labels say what it does</Chip>
</div>

<svg viewBox="0 0 100 100" className="exhibit-svg" preserveAspectRatio="none">
{flows.map(([aId, bId], idx) => {
const a = byId[aId];
const b = byId[bId];
if (!a || !b) return null;
const from = { x: a.x + a.w / 2, y: a.y + a.h / 2 };
const to = { x: b.x + b.w / 2, y: b.y + b.h / 2 };
return (
<motion.path
key={`${aId}-${bId}`}
d={curvedPath(from, to, idx % 2 === 0 ? 6 : 9)}
fill="none"
stroke="rgba(103,232,249,0.35)"
strokeWidth="0.45"
initial={reduce ? false : { pathLength: 0, opacity: 0.25 }}
animate={{ pathLength: 1, opacity: 0.9 }}
transition={{ duration: reduce ? 0 : 1, delay: reduce ? 0 : idx * 0.1 }}
/>
);
})}
</svg>

{concerns.map((concern) => (
<motion.button
type="button"
key={concern.id}
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
onMouseEnter={() => setSelected(concern.id)}
onFocus={() => setSelected(concern.id)}
className={cx(
'concern-box',
`concern-box--${concern.heat}`,
selected === concern.id && 'concern-box--selected'
)}
style={{ left: `${concern.x}%`, top: `${concern.y}%`, width: `${concern.w}%`, height: `${concern.h}%` }}
>
<div className="concern-box__eyebrow">concern</div>
<div className="concern-box__title">{concern.title}</div>
<div className="concern-box__role">{concern.role}</div>
</motion.button>
))}

{current ? (
<div className="exhibit-focus-card">
<div className="exhibit-focus-card__eyebrow">selected concern</div>
<div className="exhibit-focus-card__title">{current.title}</div>
<p className="exhibit-focus-card__note">{current.role}</p>
<div className="concern-heat-meter">
<span className="concern-heat-meter__fill" style={{ width: HEAT_WIDTH[current.heat] }} />
</div>
<div className="concern-heat-meter__label">recent activity heat · {current.heat}</div>
</div>
) : null}
</Frame>
);
}
Loading
Loading