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
30 changes: 29 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ mini-stim/
│ └── web/ # Vite/React web client
├── packages/
│ ├── contracts/ # generated OpenAPI schema/types
│ └── components/ # reusable UI primitives used by client
│ ├── mqueue/ # browser transport/event projection over contracts + SSE
│ ├── hooks/ # React provider + atomic hooks over mqueue
│ └── components/ # reusable UI primitives/compositions used by client
├── docs/
└── .task/ # local task memory, ignored by git
```
Expand Down Expand Up @@ -99,6 +101,8 @@ plane and must stay limited to cell lifecycle facts.
- Prefer one working web chat loop over architecture scaffolding.
- Server product truth lives in `santi-core`; web UI consumes API contracts and
must not define durable product semantics.
- `mini-stim` is self-contained, so frontend package boundaries exist for code
ownership and clarity, not for independent external release choreography.
- Provider integration truth lives behind `santi-provider::ProviderClient`;
`santi-core` must stay provider-agnostic.
- The server soma owns HTTP routing, SSE framing, and OpenAPI export through the
Expand All @@ -112,6 +116,30 @@ plane and must stay limited to cell lifecycle facts.
fallbacks or exploring unrelated environment workarounds unless the user asks.
- Keep hard cuts acceptable. Add compatibility only for a real external surface.

## Frontend Package Boundary

- Keep the frontend split strict even though everything ships from one repo.
- `packages/contracts` owns generated OpenAPI clients and DTOs only.
- `packages/mqueue` owns browser-facing HTTP calls, SSE wiring, stream merge,
event projection, and any direct use of `@mini-stim/contracts`.
- `packages/hooks` owns React context/providers and atomic hooks over
`mqueue`. It is the only stateful integration layer the web app should
consume.
- `packages/components` owns reusable presentational UI primitives and small
compositions. It should stay transport-agnostic and product-light.
- `apps/client/soma/web` owns route/page assembly, product-specific layout,
and composer/transcript/session UX built from hooks and components.
- Web app code must not call raw `fetch`, construct `EventSource`, import
`@mini-stim/contracts`, or reach into sidecar/browser globals directly.
- If a UI pattern is reusable across multiple client surfaces or would
otherwise cause page-level CSS/control duplication, move it into
`packages/components` instead of re-implementing it in `web`.
- If logic is about transport, replay, stream state, or event normalization, it
belongs below `web`, usually in `mqueue` or `hooks`, not inside React pages.
- Do not create a fake package/release process inside the repo. Keep the
boundary architectural and local-first: workspace packages, direct
consumption, and simple builds are enough.

## Data Modeling Rules

Keep the simplified model normalized:
Expand Down
3 changes: 2 additions & 1 deletion apps/client/soma/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"devDependencies": {
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3"
"@types/react-dom": "^19.2.3",
"sass": "^1.94.0"
}
}
119 changes: 28 additions & 91 deletions apps/client/soma/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Composer } from "@mini-stim/components";
import { AppRoot } from "@mini-stim/components";
import {
useMessageConnection,
useSelectedSessionId,
Expand All @@ -8,9 +8,11 @@ import {
useSessionPending,
useSessionTimeline,
useSessions,
type TimelineItem,
} from "@mini-stim/hooks";

import { ChatShell } from "./components/ChatShell";
import { SessionRail } from "./components/SessionRail";

export function App() {
const sessions = useSessions();
const selectedSessionId = useSelectedSessionId();
Expand Down Expand Up @@ -66,98 +68,33 @@ export function App() {
}

return (
<main className="shell">
<aside className="rail">
<div className="railHeader">
<h1>mini-stim</h1>
<button type="button" onClick={createNewSession} disabled={busy}>
New
</button>
</div>
<nav className="conversationList">
{sessions.map((session) => (
<button
type="button"
key={session.id}
className={session.id === selectedSessionId ? "selected" : ""}
onClick={() => selectSession(session.id)}
>
<span>{sessionLabel(session)}</span>
<small>{session.updated_at}</small>
</button>
))}
</nav>
</aside>
<section className="chat">
<header className="chatHeader">
<h2>{selectedTitle}</h2>
{busy ? <span>Sending</span> : null}
{selectedSessionId ? <span>{connection}</span> : null}
</header>
<div className="transcript">
{timeline.map((item) => renderTimelineItem(item))}
{!timeline.length ? <div className="empty">Start a session</div> : null}
</div>
{visibleError ? <div className="error">{visibleError}</div> : null}
<Composer value={draft} disabled={busy} onChange={setDraft} onSubmit={send} />
</section>
</main>
<AppRoot
sidebar={
<SessionRail
busy={busy}
onCreate={createNewSession}
onSelect={selectSession}
selectedSessionId={selectedSessionId}
sessions={sessions}
/>
}
main={
<ChatShell
busy={busy}
connection={connection}
error={visibleError}
onDraftChange={setDraft}
onSend={send}
selectedSessionId={selectedSessionId}
title={selectedTitle}
timeline={timeline}
draft={draft}
/>
}
/>
);
}

function sessionLabel(session: { id: string; title?: string | null }) {
return session.title?.trim() || session.id;
}

function renderTimelineItem(item: TimelineItem) {
if (item.kind === "message") {
const role = item.message.message.actor_type;
return (
<article key={item.id} className={`message role-${role}`}>
<div>{item.message.content_text}</div>
</article>
);
}

if (item.kind === "tool_call") {
const result = item.toolResult;
const failed = Boolean(result?.error_text);
return (
<article key={item.id} className={`toolBlock ${failed ? "failed" : ""}`}>
<header>
<span>{item.toolCall.tool_name}</span>
<small>{result ? (failed ? "failed" : "completed") : "running"}</small>
</header>
<pre>{formatJson(item.toolCall.arguments)}</pre>
{result ? (
<pre className="toolOutput">
{result.error_text ?? formatJson(result.output)}
</pre>
) : null}
</article>
);
}

const failed = Boolean(item.toolResult.error_text);
return (
<article key={item.id} className={`toolBlock ${failed ? "failed" : ""}`}>
<header>
<span>tool result</span>
<small>{failed ? "failed" : "completed"}</small>
</header>
<pre className="toolOutput">
{item.toolResult.error_text ?? formatJson(item.toolResult.output)}
</pre>
</article>
);
}

function formatJson(value: unknown) {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value;
}
return JSON.stringify(value, null, 2);
}
27 changes: 27 additions & 0 deletions apps/client/soma/web/src/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
Badge,
Heading,
Inline,
Pane,
} from "@mini-stim/components";

export function ChatHeader(props: {
busy: boolean;
connection: string;
selectedSessionId: string | null;
title: string;
}) {
return (
<Pane border="bottom" padding="md" tone="panel">
<Inline justify="between" wrap gap="sm">
<Heading tag="h2" size="md" truncate>
{props.title}
</Heading>
<Inline gap="sm" wrap>
{props.busy ? <Badge tone="success">Sending</Badge> : null}
{props.selectedSessionId ? <Badge>{props.connection}</Badge> : null}
</Inline>
</Inline>
</Pane>
);
}
46 changes: 46 additions & 0 deletions apps/client/soma/web/src/components/ChatShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Notice, Pane, Stack, Text } from "@mini-stim/components";

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

export function ChatShell(props: {
busy: boolean;
connection: string;
error: string | null;
onDraftChange: (value: string) => void;
onSend: () => void;
selectedSessionId: string | null;
title: string;
timeline: Parameters<typeof Transcript>[0]["timeline"];
draft: string;
}) {
return (
<Pane tone="subtle" grow>
<Stack grow>
<ChatHeader
busy={props.busy}
connection={props.connection}
selectedSessionId={props.selectedSessionId}
title={props.title}
/>
<Transcript timeline={props.timeline} />
{props.error ? (
<Pane padding="md">
<Notice tone="danger">
<Text>{props.error}</Text>
</Notice>
</Pane>
) : null}
<Pane border="top" padding="md" tone="panel">
<Composer
value={props.draft}
disabled={props.busy}
onChange={props.onDraftChange}
onSubmit={props.onSend}
/>
</Pane>
</Stack>
</Pane>
);
}
33 changes: 33 additions & 0 deletions apps/client/soma/web/src/components/Composer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { IconButton, Inline, TextArea } from "@mini-stim/components";

export function Composer(props: {
disabled?: boolean;
onChange: (value: string) => void;
onSubmit: () => void;
value: string;
}) {
return (
<form
onSubmit={(event) => {
event.preventDefault();
props.onSubmit();
}}
>
<Inline align="end" gap="sm">
<TextArea
value={props.value}
disabled={props.disabled}
placeholder="Message"
onChange={(event) => props.onChange(event.currentTarget.value)}
/>
<IconButton
type="submit"
label="Send"
disabled={props.disabled || !props.value.trim()}
>
<span aria-hidden="true">↑</span>
</IconButton>
</Inline>
</form>
);
}
67 changes: 67 additions & 0 deletions apps/client/soma/web/src/components/SessionRail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
Badge,
Button,
Heading,
Inline,
Pane,
ScrollArea,
Stack,
Text,
} from "@mini-stim/components";
import type { Session } from "@mini-stim/hooks";

export function SessionRail(props: {
busy: boolean;
onCreate: () => void;
onSelect: (sessionId: string) => void;
selectedSessionId: string | null;
sessions: Session[];
}) {
return (
<Pane border="right" padding="md" tone="panel" grow>
<Stack gap="md" grow>
<Inline justify="between">
<Heading tag="h1" size="md">mini-stim</Heading>
<Button
size="sm"
variant="outline"
disabled={props.busy}
onClick={props.onCreate}
>
New
</Button>
</Inline>
<ScrollArea grow>
<Stack gap="sm">
{props.sessions.map((session) => {
const selected = session.id === props.selectedSessionId;
return (
<Button
key={session.id}
block
justify="start"
variant={selected ? "selected" : "ghost"}
onClick={() => props.onSelect(session.id)}
>
<Stack tag="span" gap="xs" grow align="start">
<Text truncate>{sessionLabel(session)}</Text>
<Inline tag="span" justify="between" grow wrap gap="sm">
<Text size="sm" tone="muted" truncate>
{session.updated_at}
</Text>
{selected ? <Badge>current</Badge> : null}
</Inline>
</Stack>
</Button>
);
})}
</Stack>
</ScrollArea>
</Stack>
</Pane>
);
}

function sessionLabel(session: { id: string; title?: string | null }) {
return session.title?.trim() || session.id;
}
Loading
Loading