diff --git a/README.md b/README.md index e4758f9..2150ab3 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/apps/client/soma/web/src/App.tsx b/apps/client/soma/web/src/App.tsx index c6827ee..999b974 100644 --- a/apps/client/soma/web/src/App.tsx +++ b/apps/client/soma/web/src/App.tsx @@ -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, @@ -7,7 +7,9 @@ import { useSessionActions, useSessionError, useSessionPending, - useSessionTimeline, + useSessionPreviews, + useSessionRuntime, + useSessionTurnTimeline, useSessions, } from "@mini-stim/hooks"; @@ -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(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); @@ -94,18 +148,23 @@ export function App() { busy={busy} onCreate={createNewSession} onSelect={selectSession} + previews={previews} selectedSessionId={selectedSessionId} sessions={sessions} /> void; + onToggleInspect: () => void; selectedSessionId: string | null; title: string; titleValue: string | null; @@ -84,13 +87,22 @@ export function ChatHeader(props: { )} - + {props.busy ? ( - sending + {props.activity} ) : null} {props.connection === "error" ? ( reconnecting ) : null} + diff --git a/apps/client/soma/web/src/components/ChatShell.tsx b/apps/client/soma/web/src/components/ChatShell.tsx index c2f5d07..61f40fe 100644 --- a/apps/client/soma/web/src/components/ChatShell.tsx +++ b/apps/client/soma/web/src/components/ChatShell.tsx @@ -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; @@ -22,15 +28,24 @@ export function ChatShell(props: { )} - middle={} + middle={ + props.inspecting ? ( + + ) : ( + + ) + } bottom={( + No runtime snapshot loaded for this session yet. + + ); + } + + const memory = runtime.soul_session?.session_memory.trim() ?? ""; + + return ( + + + + + + SESSION MEMORY + {runtime.soul_session ? ( + + seen through seq {runtime.soul_session.last_seen_session_seq} + + ) : null} + + {memory ? ( + {memory} + ) : ( + + The agent has not written any session memory yet. + + )} + + + + COMPACTS + {runtime.compacts.length ? ( + runtime.compacts.map((compact) => ( + + + + + replaces seq {compact.start_session_seq}–{compact.end_session_seq} + + + + {compact.summary} + + + )) + ) : ( + + No context compaction has happened in this session. + + )} + + + + EFFECTS + {runtime.effects.length ? ( + runtime.effects.map((effect) => ( + + {effect.effect_type} + + {effect.status} + + + )) + ) : ( + + No hook effects recorded in this session. + + )} + + + + + ); +} diff --git a/apps/client/soma/web/src/components/SessionRail.tsx b/apps/client/soma/web/src/components/SessionRail.tsx index d2ae79e..a7cd0d6 100644 --- a/apps/client/soma/web/src/components/SessionRail.tsx +++ b/apps/client/soma/web/src/components/SessionRail.tsx @@ -17,6 +17,7 @@ export function SessionRail(props: { busy: boolean; onCreate: () => void; onSelect: (sessionId: string) => void; + previews: Record; selectedSessionId: string | null; sessions: Session[]; }) { @@ -48,7 +49,7 @@ export function SessionRail(props: { {props.sessions.map((session) => { const selected = session.id === props.selectedSessionId; - const label = sessionLabel(session); + const label = sessionLabel(session, props.previews[session.id]); return (