feat: unified tool cards (glyph + arg, call+result in one card, ✓/✗ status), client-side message queue (queue while busy, flush on settle, removable pending bubbles), mobile chat-header wrap
This commit is contained in:
+70
-25
@@ -143,6 +143,16 @@ function oneLine(text: string): string {
|
||||
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat;
|
||||
}
|
||||
|
||||
/** true when the standalone result bubble is skipped because its ToolCard
|
||||
* (which carries the output preview) is already rendered */
|
||||
function resultSkipped(m: ChatMessage, tools: Map<string, ToolState>): boolean {
|
||||
return (
|
||||
m.role === "toolResult" &&
|
||||
m.toolCallId !== null &&
|
||||
tools.has(m.toolCallId)
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }): React.ReactNode {
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
return (
|
||||
@@ -362,25 +372,34 @@ function DiffBlock({ lines }: { lines: DiffLine[] }): React.ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
// Well-known tools get a compact glyph + their primary arg instead of
|
||||
// the raw tool name (bash → "$ ls -la", read → "📄 src/main.ts", …).
|
||||
const TOOL_ICON: Record<string, string> = {
|
||||
bash: "$",
|
||||
read: "📄",
|
||||
edit: "✎",
|
||||
write: "📝",
|
||||
};
|
||||
|
||||
function ToolCard({ tool }: { tool: ToolState }) {
|
||||
const status: string = tool.running
|
||||
? "running…"
|
||||
: tool.isError
|
||||
? "error"
|
||||
: "done";
|
||||
const status: string = tool.running ? "⋯" : tool.isError ? "✗" : "✓";
|
||||
const statusClass: string = tool.isError
|
||||
? "tool-status-err"
|
||||
: "tool-status-ok";
|
||||
: tool.running
|
||||
? "tool-status-run"
|
||||
: "tool-status-ok";
|
||||
const summary: string = oneLine(toolSummaryText(tool.args));
|
||||
const icon: string | undefined = TOOL_ICON[tool.name];
|
||||
const label: string =
|
||||
icon === undefined ? tool.name : `${icon} ${summary}`.trim();
|
||||
const rest: string = icon === undefined ? summary : "";
|
||||
const diffBlocks: DiffLine[][] | null = toolDiffBlocks(tool.name, tool.args);
|
||||
return (
|
||||
<details className="tool-card">
|
||||
<summary>
|
||||
<span className="tool-name">🛠 {tool.name}</span>
|
||||
{summary.length > 0 && <span className="tool-summary">{summary}</span>}
|
||||
<span className={`tool-status ${tool.running ? "" : statusClass}`}>
|
||||
{tool.running ? "working…" : status}
|
||||
</span>
|
||||
<span className="tool-label">{label}</span>
|
||||
{rest.length > 0 && <span className="tool-summary">{rest}</span>}
|
||||
<span className={`tool-status ${statusClass}`}>{status}</span>
|
||||
</summary>
|
||||
<div className="tool-body">
|
||||
{diffBlocks === null ? (
|
||||
@@ -451,6 +470,9 @@ export function Bubble({
|
||||
if (t !== undefined) msgTools.push(t);
|
||||
}
|
||||
}
|
||||
// the ToolCard above already carries this result (output preview): skip
|
||||
// the duplicate standalone "result" bubble when the tool state exists
|
||||
if (resultSkipped(msg, tools)) return null;
|
||||
const copyable: boolean =
|
||||
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
|
||||
const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : "";
|
||||
@@ -506,6 +528,10 @@ interface Props {
|
||||
messages: ChatMessage[];
|
||||
tools: Map<string, ToolState>;
|
||||
busy: boolean;
|
||||
/** messages queued client-side while the agent runs; sent on settle */
|
||||
queued: string[];
|
||||
/** drop a queued message before it is sent (the ✕ on a queued bubble) */
|
||||
unqueue: (index: number) => void;
|
||||
/** an older page exists beyond the loaded window (B1) */
|
||||
hasOlder: boolean;
|
||||
loadingOlder: boolean;
|
||||
@@ -521,6 +547,8 @@ export default function ChatStream({
|
||||
messages,
|
||||
tools,
|
||||
busy,
|
||||
queued,
|
||||
unqueue,
|
||||
hasOlder,
|
||||
loadingOlder,
|
||||
onLoadOlder,
|
||||
@@ -574,9 +602,11 @@ export default function ChatStream({
|
||||
const q: string = query.trim().toLowerCase();
|
||||
if (q.length === 0) return [];
|
||||
return messages
|
||||
.filter((m) => searchHaystack(m).includes(q))
|
||||
.filter(
|
||||
(m) => !resultSkipped(m, tools) && searchHaystack(m).includes(q),
|
||||
)
|
||||
.map((m) => m.key);
|
||||
}, [messages, query]);
|
||||
}, [messages, tools, query]);
|
||||
|
||||
// live events can shrink the match set under the cursor
|
||||
useEffect(() => {
|
||||
@@ -627,19 +657,34 @@ export default function ChatStream({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.key}
|
||||
className={m.key === currentKey ? "msg-current" : undefined}
|
||||
ref={(el: HTMLDivElement | null): void => {
|
||||
if (el === null) rowRefs.current.delete(m.key);
|
||||
else rowRefs.current.set(m.key, el);
|
||||
}}
|
||||
>
|
||||
<Bubble msg={m} tools={tools} query={query} showTs={showTs} />
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.key}
|
||||
className={m.key === currentKey ? "msg-current" : undefined}
|
||||
ref={(el: HTMLDivElement | null): void => {
|
||||
if (el === null) rowRefs.current.delete(m.key);
|
||||
else rowRefs.current.set(m.key, el);
|
||||
}}
|
||||
>
|
||||
<Bubble msg={m} tools={tools} query={query} showTs={showTs} />
|
||||
</div>
|
||||
))}
|
||||
{queued.map((text, i) => (
|
||||
<div className="bubble-row user" key={`q-${i}-${text}`}>
|
||||
<div className="bubble queued-bubble">
|
||||
<div className="queued-line">{text}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn queued-remove"
|
||||
aria-label="Remove queued message"
|
||||
onClick={() => unqueue(i)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{showTyping && <TypingIndicator />}
|
||||
</div>
|
||||
))}
|
||||
{showTyping && <TypingIndicator />}
|
||||
</div>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
|
||||
Reference in New Issue
Block a user