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
20 changes: 16 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,15 +201,27 @@ Instructions loaded on demand, in the standard `SKILL.md` format — full detail
under `agent_model_settings`). The active thread is NOT in a context — it is derived from the URL
by `useActiveThreadId()`, so there is one source of truth.
- **Custom Hooks**: `useChatThread`, `useMCPTools`, `useThreads` for data domains
- **Message Components**: Separate components for AI/Human/Tool/Error message types
- **Message Components**: `AIMessage` (prose only — tool calls riding on it are rendered by
`ToolActivityGroup`, not here), `HumanMessage`, `ErrorMessage`
- **Tool rendering** (`src/components/toolRenderers/`): `config.ts` maps each **built-in** tool to a
view for its **arguments** and its **result** (`sql`, `table`, `receipt`, field grids); anything
not listed — every MCP tool, anything added later — falls back to `json`. A closed catalog with an
open fallback: the payload SELECTS a client-owned renderer, it never describes one. Keyed on tool
name rather than sniffed from the payload, because sniffing predicates are a second contract that
drifts. `config.test.ts` pins every key to a really-registered tool, so a rename can't silently
drop a tool back to JSON. A tool call's args render in `ToolCallDisplay`, its result in
`ToolMessage` — two different renders of one operation.
drop a tool back to JSON.
- **A call and its result render as ONE card** (`ToolActivityGroup.tsx`). The wire delivers them as
two messages — the call on an AI message, the result as its own `tool` message — and the template
drew two cards for them. `buildThreadItems()` (`src/services/toolActivity.ts`) pairs them by
`tool_call_id` (never by adjacency: results arrive when they resolve, and a paused call never gets
one) and groups CONSECUTIVE calls into one card. Collapsed by default.
- **Header label**: a single call gets its own gist (`run_sql · 37 rows`); several get a generic
`N tools called`, because no summary spans differently-shaped payloads. `summarizeResult` takes
the tool name so `ok: true` reads as "written" only for a **mutating** tool.
- **Two things are never collapsed**: a gated call's ARGUMENTS (you cannot approve what you cannot
read) and a CHART (it is the answer, not a detail — and it renders with no JSON receipt under
it). A drawn chart leaves the collapsed list entirely.
- There is no global "hide tools" toggle any more; collapsing per card replaced it.
- **Design tokens** (`src/app/globals.css`): paper ground / ink text / one amber `--brand`, mapped
ONTO shadcn's semantic names so `ui/*` inherits them. Amber marks the approval boundary and
nothing else. `--muted-foreground` must stay ≥4.5:1 on paper (`--faint` is the decorative-only
Expand Down Expand Up @@ -297,7 +309,7 @@ Instructions loaded on demand, in the standard `SKILL.md` format — full detail
the SSE contract is otherwise unchanged. Position in the message list is NOT a pending signal:
tool results and later AI text both arrive after the call, so "is it last?" is wrong in both
directions — it hid the gate on a real pause and offered APPROVE on read-only tools.
- `ToolCallDisplay` additionally requires `isMutatingTool(name)` before rendering the amber panel.
- `ToolActivityGroup` additionally requires `isMutatingTool(name)` before rendering the amber panel.
Two independent conditions, because that panel claims "this writes to your data".
- `src/services/agentService.ts` translates the wire signal into a HITL resume: it reads the pending
request via `agent.graph.getState()` and resumes with `Command({ resume: { decisions } })` — one
Expand Down
72 changes: 23 additions & 49 deletions src/components/AIMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,22 @@
import type { MessageResponse, ToolApprovalCallbacks } from "@/types/message";
import type { MessageResponse } from "@/types/message";
import { Bot } from "lucide-react";
import rehypeKatex from "rehype-katex";
import { cn } from "@/lib/utils";
import {
getMessageContent,
hasToolCalls,
getToolCalls,
getPendingToolCallIds,
} from "@/services/messageUtils";
import { ToolCallDisplay } from "./ToolCallDisplay";
import { useUISettings } from "@/contexts/UISettingsContext";
import { getMessageContent } from "@/services/messageUtils";
import MDEditor from "@uiw/react-md-editor";

interface AIMessageProps {
message: MessageResponse;
approvalCallbacks?: ToolApprovalCallbacks;
}

export const AIMessage = ({ message, approvalCallbacks }: AIMessageProps) => {
/**
* The agent's prose. Tool calls ride along on these messages but are NOT rendered here — they are
* paired with their results and drawn by ToolActivityGroup, so one operation is one card.
*/
export const AIMessage = ({ message }: AIMessageProps) => {
const messageContent = getMessageContent(message);
const hasTools = hasToolCalls(message);
const toolCalls = getToolCalls(message);
const pendingToolCallIds = getPendingToolCallIds(message);
const { hideToolMessages } = useUISettings();

// If tool messages are hidden and there's no text content, don't render anything
const shouldShowTools = hasTools && !hideToolMessages;
const hasVisibleContent = messageContent || shouldShowTools;

if (!hasVisibleContent) {
if (!messageContent) {
return null;
}

Expand All @@ -37,38 +25,24 @@ export const AIMessage = ({ message, approvalCallbacks }: AIMessageProps) => {
<div className="bg-brand/15 flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
<Bot className="text-brand-dim h-5 w-5" />
</div>
{/* Prose stays in a readable column; tool cards run full width so a call lines up with
the result card that follows it as a sibling message. */}
<div className="min-w-0 flex-1 space-y-3">
{messageContent && (
<div className={cn("text-foreground max-w-[80%] py-1")}>
<div
data-color-mode="light"
className="cameron-md [&_li]:my-1 [&_ol]:ml-6 [&_ol]:list-decimal [&_ul]:ml-6 [&_ul]:list-disc"
>
<MDEditor.Markdown
source={messageContent}
style={{
backgroundColor: "transparent",
color: "inherit",
padding: 0,
fontSize: "1rem",
}}
rehypePlugins={[rehypeKatex]}
/>
</div>
</div>
)}

{shouldShowTools && (
<div className="space-y-2">
<ToolCallDisplay
toolCalls={toolCalls}
approvalCallbacks={approvalCallbacks}
pendingToolCallIds={pendingToolCallIds}
<div className={cn("text-foreground max-w-[80%] py-1")}>
<div
data-color-mode="light"
className="cameron-md [&_li]:my-1 [&_ol]:ml-6 [&_ol]:list-decimal [&_ul]:ml-6 [&_ul]:list-disc"
>
<MDEditor.Markdown
source={messageContent}
style={{
backgroundColor: "transparent",
color: "inherit",
padding: 0,
fontSize: "1rem",
}}
rehypePlugins={[rehypeKatex]}
/>
</div>
)}
</div>
</div>
</div>
);
Expand Down
30 changes: 3 additions & 27 deletions src/components/MessageInput.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { Button } from "./ui/button";
import { ArrowUp, Loader2, Eye, EyeOff, Paperclip, X, ChevronDown } from "lucide-react";
import { ArrowUp, Loader2, Paperclip, X, ChevronDown } from "lucide-react";
import { MessageOptions, FileAttachment } from "@/types/message";
import { ModelConfiguration } from "./ModelConfiguration";
import { useUISettings } from "@/contexts/UISettingsContext";
Expand All @@ -23,16 +23,8 @@ export const MessageInput = ({
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const [isUploading, setIsUploading] = useState(false);

const {
hideToolMessages,
toggleToolMessages,
provider,
setProvider,
model,
setModel,
approveAllTools,
setApproveAllTools,
} = useUISettings();
const { provider, setProvider, model, setModel, approveAllTools, setApproveAllTools } =
useUISettings();

const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -252,22 +244,6 @@ export const MessageInput = ({
)}
attach
</Button>

<Button
type="button"
size="sm"
variant="ghost"
onClick={toggleToolMessages}
className="text-muted-foreground h-7 gap-1.5 px-2.5 font-mono text-[11px]"
aria-label={hideToolMessages ? "Show tool messages" : "Hide tool messages"}
>
{hideToolMessages ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
{hideToolMessages ? "show tools" : "hide tools"}
</Button>
</div>

<div className="flex items-center gap-3">
Expand Down
65 changes: 32 additions & 33 deletions src/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@ import type { MessageResponse, ToolApprovalCallbacks } from "@/types/message";
import { HumanMessage } from "./HumanMessage";
import { AIMessage } from "./AIMessage";
import { ErrorMessage } from "./ErrorMessage";
import { useEffect, useRef } from "react";
import { getMessageId } from "@/services/messageUtils";
import { useEffect, useMemo, useRef } from "react";
import { buildThreadItems } from "@/services/toolActivity";
import dynamic from "next/dynamic";
import { useUISettings } from "@/contexts/UISettingsContext";

const ToolMessage = dynamic(() => import("./ToolMessage").then((m) => m.ToolMessage), {
ssr: false,
loading: () => (
<div className="bg-muted/40 text-muted-foreground rounded p-4 text-sm">
Loading tool output…
</div>
),
});
const ToolActivityGroupCard = dynamic(
() => import("./ToolActivityGroup").then((m) => m.ToolActivityGroupCard),
{
ssr: false,
loading: () => (
<div className="border-border bg-muted/30 text-muted-foreground rounded-lg border px-4 py-2.5 font-mono text-xs">
loading tools…
</div>
),
},
);

interface MessageListProps {
messages: MessageResponse[];
Expand All @@ -23,7 +25,6 @@ interface MessageListProps {

const MessageList = ({ messages, approveToolExecution }: MessageListProps) => {
const bottomRef = useRef<HTMLDivElement | null>(null);
const { hideToolMessages } = useUISettings();

useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
Expand All @@ -36,32 +37,30 @@ const MessageList = ({ messages, approveToolExecution }: MessageListProps) => {
onDeny: (toolCallId: string) => approveToolExecution(toolCallId, "deny"),
}
: undefined;
// Deduplicate by type+id: ids are only unique WITHIN a type (an AI message's synthetic id and a
// tool message's call id come from different namespaces), so keying on id alone can drop a
// distinct message. Messages without an id are always kept — they cannot be compared.
const seen = new Set<string>();
const uniqueMessages = messages.filter((message) => {
const id = message.data?.id;
if (!id) return true;
const key = `${message.type}:${id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});

// Dedup, plus pairing of each call with its result; see services/toolActivity.ts.
const items = useMemo(() => buildThreadItems(messages), [messages]);

return (
<div className="mx-auto w-full max-w-3xl space-y-6">
{uniqueMessages.map((message, index) => {
// Key by type+id for the same reason the dedup does; index backs up an id-less message.
const key = `${message.type}:${getMessageId(message) || index}`;
<div className="mx-auto w-full max-w-3xl min-w-0 space-y-6">
{items.map((item) => {
if (item.kind === "tools") {
return (
<ToolActivityGroupCard
key={item.id}
activities={item.activities}
approvalCallbacks={approvalCallbacks}
/>
);
}

const { message } = item;
if (message.type === "human") {
return <HumanMessage key={key} message={message} />;
return <HumanMessage key={item.id} message={message} />;
} else if (message.type === "ai") {
return <AIMessage key={key} message={message} approvalCallbacks={approvalCallbacks} />;
} else if (message.type === "tool" && !hideToolMessages) {
return <ToolMessage key={key} message={message} />;
return <AIMessage key={item.id} message={message} />;
} else if (message.type === "error") {
return <ErrorMessage key={key} message={message} />;
return <ErrorMessage key={item.id} message={message} />;
}
return null;
})}
Expand Down
8 changes: 6 additions & 2 deletions src/components/Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,12 @@ export const Thread = ({ threadId }: ThreadProps) => {
{messages.length > 0 ? (
<>
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
<div className="space-y-4 px-4 py-4">
{/* Radix renders its viewport as `display: table; min-width: 100%`, and a table sizes
to its widest content — so a wide tool payload stretches the whole thread column
instead of scrolling inside its own box. Forcing the inner table to `block` keeps
the column at the viewport width. */}
<ScrollArea className="h-full [&>[data-slot=scroll-area-viewport]>div]:block!">
<div className="min-w-0 space-y-4 px-4 py-4">
<MessageList messages={messages} approveToolExecution={approveToolExecution} />
</div>
</ScrollArea>
Expand Down
Loading