A cohesive batch of pane/tab UX + the persisted workspace-state model (grouped
because the changes interleave across useWorkspacePanes, ChatTabBar, Workspace,
sessionEvents and the api types/client):
- Open a whole chat in a fresh pane via a new open_chat_in_new_pane event:
ChatTabBar tab context menu "Open in new pane", and MessageBubble.fork() now
lands the fork beside the original instead of replacing the active pane.
openChatInNewPane detaches the chat from any pane already holding it
(one-chat-per-pane).
- The tab-bar "+" becomes a New BooChat/BooTerm/BooCode menu (chat as a tab,
term/coder as split panes); the split button is unchanged.
- Drop the per-message "Open in pane" button (it opened a single message's
artifact) and its dead code; the artifact-pane machinery is left orphaned for
a later teardown.
- Session history: the empty/landing pane lists the session's open chats plus
archived chats (fetched separately), click to open / restore-and-open.
- Relocate-on-close: closing a chat pane moves its tabs (in order) into the
oldest chat/empty pane instead of discarding them; terminal/coder panes close
as before. Reopen strips the restored chatIds from all live panes first, so a
relocated-then-reopened pane never duplicates a tab — no stack-shape change.
- Stable global tab numbering: tabNumbers/nextTabNumber assigned on chat-pane
open, retired on close (never reused), rendered map-keyed (not positional).
- workspace_panes is now a WorkspaceState envelope { panes, tabNumbers,
nextTabNumber, closedPaneStack }; the reopen stack moved from a module-level
array into the persisted envelope so it survives reload. Hydrate/persist
normalize the legacy bare-array shape. appendClosed dedupes a value-identical
top entry to neutralize the StrictMode double-invoke of the setPanes updater.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
217 lines
6.3 KiB
TypeScript
217 lines
6.3 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
|
import { MessageBubble, type MessageActions } from '@/components/MessageBubble';
|
|
import { ToolCallGroup } from '@/components/ToolCallGroup';
|
|
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
|
|
import { AskUserInputCard } from '@/components/AskUserInputCard';
|
|
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
|
|
import type { Message } from '@/api/types';
|
|
|
|
export interface CoderMessageWire {
|
|
id: string;
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
status?: 'streaming' | 'complete' | 'failed';
|
|
reasoning_text?: string;
|
|
tool_calls?: CoderToolCallWire[];
|
|
}
|
|
|
|
export interface CoderToolMessageWire {
|
|
id: string;
|
|
role: 'tool';
|
|
tool_results: {
|
|
tool_call_id: string;
|
|
output: unknown;
|
|
truncated?: boolean;
|
|
error?: string;
|
|
};
|
|
}
|
|
|
|
export type CoderTimelineWire = CoderMessageWire | CoderToolMessageWire;
|
|
|
|
function isToolMessage(m: CoderTimelineWire): m is CoderToolMessageWire {
|
|
return m.role === 'tool';
|
|
}
|
|
|
|
type RenderItem =
|
|
| { kind: 'message'; message: CoderMessageWire }
|
|
| { kind: 'tool_run'; run: ToolRun; key: string }
|
|
| { kind: 'tool_group'; runs: ToolRun[]; key: string };
|
|
|
|
const GROUP_THRESHOLD = 3;
|
|
const SCROLL_THRESHOLD_PX = 150;
|
|
|
|
function flattenCoderMessages(messages: CoderTimelineWire[]): RenderItem[] {
|
|
const items: RenderItem[] = [];
|
|
const runsByCallId = new Map<string, ToolRun>();
|
|
|
|
for (const m of messages) {
|
|
if (isToolMessage(m)) {
|
|
const run = runsByCallId.get(m.tool_results.tool_call_id);
|
|
if (run) {
|
|
run.result = {
|
|
tool_call_id: m.tool_results.tool_call_id,
|
|
output: m.tool_results.output,
|
|
truncated: m.tool_results.truncated ?? false,
|
|
...(m.tool_results.error ? { error: m.tool_results.error } : {}),
|
|
};
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (m.role === 'user' || m.role === 'system') {
|
|
items.push({ kind: 'message', message: m });
|
|
continue;
|
|
}
|
|
|
|
const hasToolCalls = (m.tool_calls?.length ?? 0) > 0;
|
|
const hasText = m.content.trim().length > 0;
|
|
const hasReasoning = (m.reasoning_text?.trim().length ?? 0) > 0;
|
|
// External agents persist tool calls + final answer on one row. Render tools
|
|
// before the answer text so the timeline matches BooChat (tools, then reply).
|
|
const externalCombined = hasToolCalls && (hasText || hasReasoning);
|
|
|
|
if (externalCombined) {
|
|
if (hasReasoning) {
|
|
items.push({
|
|
kind: 'message',
|
|
message: { ...m, content: '', reasoning_text: m.reasoning_text },
|
|
});
|
|
}
|
|
for (const tc of m.tool_calls!) {
|
|
const run = wireToolCallToRun(tc);
|
|
runsByCallId.set(tc.id, run);
|
|
items.push({ kind: 'tool_run', run, key: tc.id });
|
|
}
|
|
if (hasText || m.status === 'streaming') {
|
|
items.push({
|
|
kind: 'message',
|
|
message: { ...m, reasoning_text: undefined },
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Native inference: separate assistant rows per step — mirror MessageList.
|
|
if (hasText || hasReasoning || m.status === 'streaming') {
|
|
items.push({ kind: 'message', message: m });
|
|
}
|
|
if (hasToolCalls) {
|
|
for (const tc of m.tool_calls!) {
|
|
const run = wireToolCallToRun(tc);
|
|
runsByCallId.set(tc.id, run);
|
|
items.push({ kind: 'tool_run', run, key: tc.id });
|
|
}
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
function groupToolRuns(items: RenderItem[]): RenderItem[] {
|
|
const out: RenderItem[] = [];
|
|
let i = 0;
|
|
while (i < items.length) {
|
|
const item = items[i]!;
|
|
if (item.kind !== 'tool_run') {
|
|
out.push(item);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
const name = item.run.call.name;
|
|
if (name === 'ask_user_input') {
|
|
out.push(item);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
let j = i + 1;
|
|
while (
|
|
j < items.length &&
|
|
items[j]!.kind === 'tool_run' &&
|
|
(items[j] as { kind: 'tool_run'; run: ToolRun }).run.call.name === name
|
|
) {
|
|
j += 1;
|
|
}
|
|
const run = items.slice(i, j) as Array<{ kind: 'tool_run'; run: ToolRun; key: string }>;
|
|
if (run.length >= GROUP_THRESHOLD) {
|
|
out.push({ kind: 'tool_group', runs: run.map((r) => r.run), key: `group-${run[0]!.key}` });
|
|
} else {
|
|
for (const r of run) out.push(r);
|
|
}
|
|
i = j;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface Props {
|
|
messages: CoderTimelineWire[];
|
|
chatId?: string;
|
|
footer?: ReactNode;
|
|
actions?: MessageActions;
|
|
}
|
|
|
|
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete')[] = ['fork'];
|
|
|
|
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
|
const endRef = useRef<HTMLDivElement>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const isNearBottomRef = useRef(true);
|
|
|
|
const renderItems = useMemo(
|
|
() => groupToolRuns(flattenCoderMessages(messages)),
|
|
[messages],
|
|
);
|
|
|
|
const handleScroll = useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
isNearBottomRef.current =
|
|
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isNearBottomRef.current) {
|
|
endRef.current?.scrollIntoView({ block: 'end' });
|
|
}
|
|
}, [messages]);
|
|
|
|
if (messages.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto" ref={scrollRef} onScroll={handleScroll}>
|
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
|
|
{renderItems.map((item) => {
|
|
if (item.kind === 'message') {
|
|
return (
|
|
<MessageBubble
|
|
key={item.message.id}
|
|
message={item.message as unknown as Message}
|
|
actions={actions}
|
|
hideActions={CODER_HIDDEN_ACTIONS}
|
|
/>
|
|
);
|
|
}
|
|
if (item.kind === 'tool_run') {
|
|
if (item.run.call.name === 'ask_user_input' && chatId) {
|
|
return (
|
|
<AskUserInputCard
|
|
key={item.key}
|
|
toolCall={item.run.call}
|
|
toolResult={item.run.result}
|
|
chatId={chatId}
|
|
apiPrefix="/api/coder"
|
|
/>
|
|
);
|
|
}
|
|
return <ToolCallLine key={item.key} run={item.run} />;
|
|
}
|
|
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
|
})}
|
|
{footer}
|
|
<div ref={endRef} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|