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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,27 @@ Out of scope:
semantics, Tauri, and native projections.
- Legacy completions or chat completions compatibility.

Modeled in core but deliberately not yet surfaced in the web UI. Each entry
names its unlock condition so deferral stays distinguishable from neglect:

- Session forking (`parent_session_id`, `fork_point`): modeled and stored,
not exposed over the API. Unlocks when a turn-level "fork from here"
action is wanted; the turn-grouped transcript is the natural anchor.
- Image message parts (`MessagePart::Image`): contracts and rendering
pipeline accept them; the composer is text-only. Unlocks when a concrete
image-input use case shows up in dogfooding.
- Message editing/deletion (`Message.version`, `deleted_at`,
`MessageEvent` audit trail): event-sourced lifecycle exists; the UI
renders latest state only. Unlocks if correction workflows matter more
than transcript immutability.
- Soul-view timeline (`SoulSessionEntry`, dual seq sequences): the inspect
panel shows memory and compact summaries, not the full "what the agent
actually sees" context reconstruction. Unlocks as the inspect panel
matures past its v1 form.
- Effects detail (`SessionEffect` payload/result refs): the inspect panel
lists type and status only. Unlocks when hooks start producing effects
worth debugging in the UI.

## Setup

Create `.env` from `.env.example` and fill:
Expand Down
67 changes: 63 additions & 4 deletions apps/client/soma/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { AppRoot, Grid, GridItem } from "@mini-stim/components";
import {
useDebouncedValue,
Expand All @@ -7,7 +7,9 @@ import {
useSessionActions,
useSessionError,
useSessionPending,
useSessionTimeline,
useSessionPreviews,
useSessionRuntime,
useSessionTurnTimeline,
useSessions,
} from "@mini-stim/hooks";

Expand All @@ -17,17 +19,69 @@ import { SessionRail } from "./components/SessionRail";
export function App() {
const sessions = useSessions();
const selectedSessionId = useSelectedSessionId();
const timeline = useSessionTimeline();
const timeline = useSessionTurnTimeline();
const pending = useSessionPending();
const sessionError = useSessionError();
const connection = useMessageConnection();
const actions = useSessionActions();
const runtime = useSessionRuntime();
const previews = useSessionPreviews();
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const [inspecting, setInspecting] = useState(false);

// The inspect panel is a view over the selected session; switching
// sessions returns to the transcript. Opening it refreshes the snapshot
// so memory/compacts/effects reflect the turns since selection.
useEffect(() => {
setInspecting(false);
}, [selectedSessionId]);

function toggleInspect() {
if (!inspecting && selectedSessionId) {
actions.refreshRuntime(selectedSessionId);
}
setInspecting((current) => !current);
}

const busy = pending > 0;
const debouncedBusy = useDebouncedValue(busy, { debounceMs: 150 });
const visibleError = error ?? sessionError?.message ?? null;
// While a turn is running, the header chip names the phase the turn is
// actually in instead of a generic "sending".
const activity = useMemo(() => {
const running = timeline.find((group) => group.turn?.status === "running");
if (!running) {
return "sending";
}
if (running.items.some((item) => item.kind === "tool_call" && !item.toolResult)) {
return "running tool";
}
if (
running.items.some(
(item) => item.kind === "message" && item.message.message.state === "pending",
)
) {
return "generating";
}
return "thinking";
}, [timeline]);
// Turn failures render in place inside the transcript; the composer
// notice keeps only errors that no failed turn already carries.
const inPlaceErrors = useMemo(
() =>
new Set(
timeline
.filter((group) => group.turn?.status === "failed")
.map((group) => group.turn?.error_text)
.filter((text): text is string => Boolean(text)),
),
[timeline],
);
const sessionErrorMessage =
sessionError && !inPlaceErrors.has(sessionError.message)
? sessionError.message
: null;
const visibleError = error ?? sessionErrorMessage;
const debouncedConnection = useDebouncedValue(connection, { debounceMs: 150 });
const selectedTitle = useMemo(() => {
const selected = sessions.find((session) => session.id === selectedSessionId);
Expand Down Expand Up @@ -94,18 +148,23 @@ export function App() {
busy={busy}
onCreate={createNewSession}
onSelect={selectSession}
previews={previews}
selectedSessionId={selectedSessionId}
sessions={sessions}
/>
</GridItem>
<GridItem area="main" tag="main">
<ChatShell
activity={activity}
busy={debouncedBusy}
connection={debouncedConnection}
error={visibleError}
inspecting={inspecting}
onDraftChange={setDraft}
onSend={send}
onTitleCommit={updateTitle}
onToggleInspect={toggleInspect}
runtime={runtime}
selectedSessionId={selectedSessionId}
title={selectedTitle}
titleValue={selectedSession?.title ?? null}
Expand Down
16 changes: 14 additions & 2 deletions apps/client/soma/web/src/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import {
} from "@mini-stim/components";

export function ChatHeader(props: {
activity: string;
busy: boolean;
connection: string;
inspecting: boolean;
onTitleCommit: (title: string | null) => void;
onToggleInspect: () => void;
selectedSessionId: string | null;
title: string;
titleValue: string | null;
Expand Down Expand Up @@ -84,13 +87,22 @@ export function ChatHeader(props: {
</Button>
)}
</Stack>
<Inline gap="sm" wrap>
<Inline gap="sm" align="center" wrap>
{props.busy ? (
<Badge size="sm" tone="success">sending</Badge>
<Badge size="sm" tone="success">{props.activity}</Badge>
) : null}
{props.connection === "error" ? (
<Badge size="sm" tone="danger">reconnecting</Badge>
) : null}
<Button
size="sm"
variant={props.inspecting ? "outline" : "ghost"}
disabled={!props.selectedSessionId}
type="button"
onClick={props.onToggleInspect}
>
{props.inspecting ? "Transcript" : "Inspect"}
</Button>
</Inline>
</Inline>
</Pane>
Expand Down
17 changes: 16 additions & 1 deletion apps/client/soma/web/src/components/ChatShell.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { Pane, SectionStackLayout } from "@mini-stim/components";
import type { SessionRuntimeSnapshot } from "@mini-stim/hooks";

import { ChatHeader } from "./ChatHeader";
import { Composer } from "./Composer";
import { InspectPanel } from "./InspectPanel";
import { Transcript } from "./Transcript";

export function ChatShell(props: {
activity: string;
busy: boolean;
connection: string;
error: string | null;
inspecting: boolean;
onDraftChange: (value: string) => void;
onSend: () => void;
onTitleCommit: (title: string | null) => void;
onToggleInspect: () => void;
runtime: SessionRuntimeSnapshot | null;
selectedSessionId: string | null;
title: string;
titleValue: string | null;
Expand All @@ -22,15 +28,24 @@ export function ChatShell(props: {
<SectionStackLayout
top={(
<ChatHeader
activity={props.activity}
busy={props.busy}
connection={props.connection}
inspecting={props.inspecting}
onTitleCommit={props.onTitleCommit}
onToggleInspect={props.onToggleInspect}
selectedSessionId={props.selectedSessionId}
title={props.title}
titleValue={props.titleValue}
/>
)}
middle={<Transcript timeline={props.timeline} />}
middle={
props.inspecting ? (
<InspectPanel runtime={props.runtime} />
) : (
<Transcript timeline={props.timeline} />
)
}
bottom={(
<Composer
value={props.draft}
Expand Down
93 changes: 93 additions & 0 deletions apps/client/soma/web/src/components/InspectPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
Badge,
CodeBlock,
Inline,
Pane,
ScrollArea,
Stack,
Text,
Timestamp,
} from "@mini-stim/components";
import type { SessionRuntimeSnapshot } from "@mini-stim/hooks";

export function InspectPanel(props: {
runtime: SessionRuntimeSnapshot | null;
}) {
const { runtime } = props;
if (!runtime) {
return (
<Pane padding="lg">
<Text tone="muted">No runtime snapshot loaded for this session yet.</Text>
</Pane>
);
}

const memory = runtime.soul_session?.session_memory.trim() ?? "";

return (
<ScrollArea grow>
<Pane padding="lg">
<Stack gap="lg">
<Stack gap="sm">
<Inline justify="between" align="center" gap="sm">
<Text size="xs" tone="subtle">SESSION MEMORY</Text>
{runtime.soul_session ? (
<Text size="xs" tone="subtle">
seen through seq {runtime.soul_session.last_seen_session_seq}
</Text>
) : null}
</Inline>
{memory ? (
<CodeBlock>{memory}</CodeBlock>
) : (
<Text size="sm" tone="muted">
The agent has not written any session memory yet.
</Text>
)}
</Stack>

<Stack gap="sm">
<Text size="xs" tone="subtle">COMPACTS</Text>
{runtime.compacts.length ? (
runtime.compacts.map((compact) => (
<Pane key={compact.id} border="around" padding="md" tone="panel">
<Stack gap="xs">
<Inline justify="between" align="center" wrap gap="sm">
<Text size="xs" tone="subtle">
replaces seq {compact.start_session_seq}–{compact.end_session_seq}
</Text>
<Timestamp value={compact.created_at} size="xs" tone="subtle" />
</Inline>
<Text size="sm">{compact.summary}</Text>
</Stack>
</Pane>
))
) : (
<Text size="sm" tone="muted">
No context compaction has happened in this session.
</Text>
)}
</Stack>

<Stack gap="sm">
<Text size="xs" tone="subtle">EFFECTS</Text>
{runtime.effects.length ? (
runtime.effects.map((effect) => (
<Inline key={effect.id} justify="between" align="center" wrap gap="sm">
<Text size="sm">{effect.effect_type}</Text>
<Badge size="sm" tone={effect.error_text ? "danger" : "neutral"}>
{effect.status}
</Badge>
</Inline>
))
) : (
<Text size="sm" tone="muted">
No hook effects recorded in this session.
</Text>
)}
</Stack>
</Stack>
</Pane>
</ScrollArea>
);
}
10 changes: 7 additions & 3 deletions apps/client/soma/web/src/components/SessionRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function SessionRail(props: {
busy: boolean;
onCreate: () => void;
onSelect: (sessionId: string) => void;
previews: Record<string, string>;
selectedSessionId: string | null;
sessions: Session[];
}) {
Expand Down Expand Up @@ -48,7 +49,7 @@ export function SessionRail(props: {
<Stack gap="xs">
{props.sessions.map((session) => {
const selected = session.id === props.selectedSessionId;
const label = sessionLabel(session);
const label = sessionLabel(session, props.previews[session.id]);
return (
<Button
key={session.id}
Expand Down Expand Up @@ -83,6 +84,9 @@ export function SessionRail(props: {
);
}

function sessionLabel(session: { id: string; title?: string | null }) {
return session.title?.trim() || session.id;
function sessionLabel(
session: { id: string; title?: string | null },
preview?: string,
) {
return session.title?.trim() || preview || session.id;
}
7 changes: 6 additions & 1 deletion apps/client/soma/web/src/components/TimelineItemView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function TimelineItemView(props: {

if (item.kind === "message") {
const role = item.message.message.actor_type;
const pending = item.message.message.state === "pending";
return (
<Surface
align={roleAlign(role)}
Expand All @@ -27,7 +28,11 @@ export function TimelineItemView(props: {
<Text size="xs" tone="subtle">
{roleLabel(role)}
</Text>
<Timestamp value={item.createdAt} size="xs" tone="subtle" />
{pending ? (
<Text size="xs" tone="subtle">generating…</Text>
) : (
<Timestamp value={item.createdAt} size="xs" tone="subtle" />
)}
</Inline>
<Text>{item.message.content_text}</Text>
</Stack>
Expand Down
Loading
Loading