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:
+77
-13
@@ -38,6 +38,8 @@ const tool = (p: Partial<ToolState>): ToolState => ({
|
|||||||
function stream(p: {
|
function stream(p: {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
queued?: string[];
|
||||||
|
unqueue?: (index: number) => void;
|
||||||
hasOlder?: boolean;
|
hasOlder?: boolean;
|
||||||
loadingOlder?: boolean;
|
loadingOlder?: boolean;
|
||||||
onOlder?: () => void;
|
onOlder?: () => void;
|
||||||
@@ -50,6 +52,8 @@ function stream(p: {
|
|||||||
messages={p.messages}
|
messages={p.messages}
|
||||||
tools={new Map()}
|
tools={new Map()}
|
||||||
busy={p.busy}
|
busy={p.busy}
|
||||||
|
queued={p.queued ?? []}
|
||||||
|
unqueue={p.unqueue ?? (() => undefined)}
|
||||||
hasOlder={p.hasOlder ?? false}
|
hasOlder={p.hasOlder ?? false}
|
||||||
loadingOlder={p.loadingOlder ?? false}
|
loadingOlder={p.loadingOlder ?? false}
|
||||||
onLoadOlder={p.onOlder ?? (() => undefined)}
|
onLoadOlder={p.onOlder ?? (() => undefined)}
|
||||||
@@ -151,10 +155,10 @@ describe("Bubble", () => {
|
|||||||
tools={tools}
|
tools={tools}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
expect(screen.getByText("$ ls -la")).toBeInTheDocument();
|
||||||
expect(screen.getByText("finished")).toBeInTheDocument();
|
expect(screen.getByText("finished")).toBeInTheDocument();
|
||||||
|
|
||||||
const summary = screen.getByText("🛠 bash").closest("summary") as HTMLElement;
|
const summary = screen.getByText("$ ls -la").closest("summary") as HTMLElement;
|
||||||
const card = summary.closest("details") as HTMLDetailsElement;
|
const card = summary.closest("details") as HTMLDetailsElement;
|
||||||
expect(card.open).toBe(false);
|
expect(card.open).toBe(false);
|
||||||
await userEvent.click(summary);
|
await userEvent.click(summary);
|
||||||
@@ -182,9 +186,9 @@ describe("Bubble", () => {
|
|||||||
tools={tools}
|
tools={tools}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("working…")).toBeInTheDocument();
|
expect(screen.getByText("⋯")).toBeInTheDocument();
|
||||||
expect(screen.getByText("error")).toBeInTheDocument();
|
expect(screen.getByText("✗")).toBeInTheDocument();
|
||||||
expect(screen.getByText("done")).toBeInTheDocument();
|
expect(screen.getByText("✓")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tool call with no matching state renders no card", () => {
|
it("tool call with no matching state renders no card", () => {
|
||||||
@@ -781,7 +785,7 @@ describe("pretty tool args", () => {
|
|||||||
tools={new Map([["t1", tool('{"command":"ls -la","foo":1}')]])}
|
tools={new Map([["t1", tool('{"command":"ls -la","foo":1}')]])}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la");
|
expect(document.querySelector(".tool-label")?.textContent).toBe("$ ls -la");
|
||||||
expect(document.querySelector(".tool-args")?.textContent).not.toContain(
|
expect(document.querySelector(".tool-args")?.textContent).not.toContain(
|
||||||
'"command"',
|
'"command"',
|
||||||
);
|
);
|
||||||
@@ -809,8 +813,8 @@ describe("pretty tool args", () => {
|
|||||||
tools={new Map([["t1", tool('{"path":"src/main.ts"}')]])}
|
tools={new Map([["t1", tool('{"path":"src/main.ts"}')]])}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(document.querySelector(".tool-summary")?.textContent).toBe(
|
expect(document.querySelector(".tool-label")?.textContent).toBe(
|
||||||
"src/main.ts",
|
"$ src/main.ts",
|
||||||
);
|
);
|
||||||
expect(document.querySelector(".tool-args .arg-k")).toBeNull();
|
expect(document.querySelector(".tool-args .arg-k")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -849,8 +853,7 @@ describe("tool summary + diff", () => {
|
|||||||
tools={new Map([["t1", tool('{"command":"ls -la","timeout":30}')]])}
|
tools={new Map([["t1", tool('{"command":"ls -la","timeout":30}')]])}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
expect(screen.getByText("$ ls -la")).toBeInTheDocument();
|
||||||
expect(document.querySelector(".tool-summary")?.textContent).toBe("ls -la");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("read summary shows the path", () => {
|
it("read summary shows the path", () => {
|
||||||
@@ -862,8 +865,8 @@ describe("tool summary + diff", () => {
|
|||||||
tools={new Map([["t1", tool('{"path":"src/main.ts"}', "read")]])}
|
tools={new Map([["t1", tool('{"path":"src/main.ts"}', "read")]])}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(document.querySelector(".tool-summary")?.textContent).toBe(
|
expect(document.querySelector(".tool-label")?.textContent).toBe(
|
||||||
"src/main.ts",
|
"📄 src/main.ts",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -878,7 +881,7 @@ describe("tool summary + diff", () => {
|
|||||||
tools={new Map([["t1", tool(`{"command":"${long}"}`)]])}
|
tools={new Map([["t1", tool(`{"command":"${long}"}`)]])}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const summary = document.querySelector(".tool-summary");
|
const summary = document.querySelector(".tool-label");
|
||||||
expect(summary?.textContent?.endsWith("…")).toBe(true);
|
expect(summary?.textContent?.endsWith("…")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -970,3 +973,64 @@ describe("lineDiff", () => {
|
|||||||
expect(d.filter((l) => l.kind === "add")).toHaveLength(1);
|
expect(d.filter((l) => l.kind === "add")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("unified tool card", () => {
|
||||||
|
const tool = (args: string): ToolState => ({
|
||||||
|
id: "t1",
|
||||||
|
name: "bash",
|
||||||
|
args,
|
||||||
|
running: false,
|
||||||
|
isError: false,
|
||||||
|
preview: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toolResult bubble is skipped when its tool card exists", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<>
|
||||||
|
<Bubble
|
||||||
|
msg={msg({
|
||||||
|
role: "assistant",
|
||||||
|
toolCalls: [{ id: "t1", name: "bash", argsJson: "{}" }],
|
||||||
|
})}
|
||||||
|
tools={new Map([["t1", tool('{"command":"ls"}')]])}
|
||||||
|
/>
|
||||||
|
<Bubble
|
||||||
|
msg={msg({ role: "toolResult", text: "file1\nfile2", toolCallId: "t1" })}
|
||||||
|
tools={new Map([["t1", tool('{"command":"ls"}')]])}
|
||||||
|
/>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
expect(container.querySelectorAll(".tool-card")).toHaveLength(1);
|
||||||
|
expect(container.textContent).not.toContain("result");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toolResult without tool state still renders a result card", () => {
|
||||||
|
render(
|
||||||
|
<Bubble
|
||||||
|
msg={msg({ role: "toolResult", text: "orphan output", toolCallId: "gone" })}
|
||||||
|
tools={new Map()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("orphan output").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queued messages render as removable pending bubbles", async () => {
|
||||||
|
const unqueue = vi.fn();
|
||||||
|
const { rerender } = render(
|
||||||
|
stream({
|
||||||
|
messages: [msg({ role: "user", text: "hi" })],
|
||||||
|
busy: true,
|
||||||
|
queued: ["next msg"],
|
||||||
|
unqueue,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("next msg")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByLabelText("Remove queued message"));
|
||||||
|
expect(unqueue).toHaveBeenCalledWith(0);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
stream({ messages: [msg({ role: "user", text: "hi" })], busy: true, queued: [], unqueue }),
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("next msg")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+70
-25
@@ -143,6 +143,16 @@ function oneLine(text: string): string {
|
|||||||
return flat.length > PREVIEW_LEN ? `${flat.slice(0, PREVIEW_LEN)}…` : flat;
|
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 {
|
function CopyButton({ text }: { text: string }): React.ReactNode {
|
||||||
const [copied, setCopied] = useState<boolean>(false);
|
const [copied, setCopied] = useState<boolean>(false);
|
||||||
return (
|
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 }) {
|
function ToolCard({ tool }: { tool: ToolState }) {
|
||||||
const status: string = tool.running
|
const status: string = tool.running ? "⋯" : tool.isError ? "✗" : "✓";
|
||||||
? "running…"
|
|
||||||
: tool.isError
|
|
||||||
? "error"
|
|
||||||
: "done";
|
|
||||||
const statusClass: string = tool.isError
|
const statusClass: string = tool.isError
|
||||||
? "tool-status-err"
|
? "tool-status-err"
|
||||||
: "tool-status-ok";
|
: tool.running
|
||||||
|
? "tool-status-run"
|
||||||
|
: "tool-status-ok";
|
||||||
const summary: string = oneLine(toolSummaryText(tool.args));
|
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);
|
const diffBlocks: DiffLine[][] | null = toolDiffBlocks(tool.name, tool.args);
|
||||||
return (
|
return (
|
||||||
<details className="tool-card">
|
<details className="tool-card">
|
||||||
<summary>
|
<summary>
|
||||||
<span className="tool-name">🛠 {tool.name}</span>
|
<span className="tool-label">{label}</span>
|
||||||
{summary.length > 0 && <span className="tool-summary">{summary}</span>}
|
{rest.length > 0 && <span className="tool-summary">{rest}</span>}
|
||||||
<span className={`tool-status ${tool.running ? "" : statusClass}`}>
|
<span className={`tool-status ${statusClass}`}>{status}</span>
|
||||||
{tool.running ? "working…" : status}
|
|
||||||
</span>
|
|
||||||
</summary>
|
</summary>
|
||||||
<div className="tool-body">
|
<div className="tool-body">
|
||||||
{diffBlocks === null ? (
|
{diffBlocks === null ? (
|
||||||
@@ -451,6 +470,9 @@ export function Bubble({
|
|||||||
if (t !== undefined) msgTools.push(t);
|
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 =
|
const copyable: boolean =
|
||||||
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
|
msg.role === "assistant" || (msg.role === "user" && msg.text.length > 0);
|
||||||
const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : "";
|
const tsTitle: string = msg.ts > 0 ? new Date(msg.ts).toISOString() : "";
|
||||||
@@ -506,6 +528,10 @@ interface Props {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
tools: Map<string, ToolState>;
|
tools: Map<string, ToolState>;
|
||||||
busy: boolean;
|
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) */
|
/** an older page exists beyond the loaded window (B1) */
|
||||||
hasOlder: boolean;
|
hasOlder: boolean;
|
||||||
loadingOlder: boolean;
|
loadingOlder: boolean;
|
||||||
@@ -521,6 +547,8 @@ export default function ChatStream({
|
|||||||
messages,
|
messages,
|
||||||
tools,
|
tools,
|
||||||
busy,
|
busy,
|
||||||
|
queued,
|
||||||
|
unqueue,
|
||||||
hasOlder,
|
hasOlder,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
onLoadOlder,
|
onLoadOlder,
|
||||||
@@ -574,9 +602,11 @@ export default function ChatStream({
|
|||||||
const q: string = query.trim().toLowerCase();
|
const q: string = query.trim().toLowerCase();
|
||||||
if (q.length === 0) return [];
|
if (q.length === 0) return [];
|
||||||
return messages
|
return messages
|
||||||
.filter((m) => searchHaystack(m).includes(q))
|
.filter(
|
||||||
|
(m) => !resultSkipped(m, tools) && searchHaystack(m).includes(q),
|
||||||
|
)
|
||||||
.map((m) => m.key);
|
.map((m) => m.key);
|
||||||
}, [messages, query]);
|
}, [messages, tools, query]);
|
||||||
|
|
||||||
// live events can shrink the match set under the cursor
|
// live events can shrink the match set under the cursor
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -627,19 +657,34 @@ export default function ChatStream({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((m) => (
|
{messages.map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.key}
|
key={m.key}
|
||||||
className={m.key === currentKey ? "msg-current" : undefined}
|
className={m.key === currentKey ? "msg-current" : undefined}
|
||||||
ref={(el: HTMLDivElement | null): void => {
|
ref={(el: HTMLDivElement | null): void => {
|
||||||
if (el === null) rowRefs.current.delete(m.key);
|
if (el === null) rowRefs.current.delete(m.key);
|
||||||
else rowRefs.current.set(m.key, el);
|
else rowRefs.current.set(m.key, el);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Bubble msg={m} tools={tools} query={query} showTs={showTs} />
|
<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>
|
</div>
|
||||||
))}
|
</div>
|
||||||
{showTyping && <TypingIndicator />}
|
))}
|
||||||
|
{showTyping && <TypingIndicator />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{searchOpen && (
|
{searchOpen && (
|
||||||
|
|||||||
@@ -1718,3 +1718,54 @@ describe("document title", () => {
|
|||||||
await waitFor(() => expect(document.title).toContain("bash"));
|
await waitFor(() => expect(document.title).toContain("bash"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ChatView message queue", () => {
|
||||||
|
it("Enter while busy queues instead of sending; flushed on settle", async () => {
|
||||||
|
const fetchMock = mockFetchJson((_url, init) =>
|
||||||
|
init?.method === "POST" ? { ok: true } : [],
|
||||||
|
);
|
||||||
|
renderChat(makeStore());
|
||||||
|
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
|
||||||
|
const promptCalls = (): number =>
|
||||||
|
fetchMock.mock.calls.filter(([u]) =>
|
||||||
|
String(u).endsWith("/api/sessions/s1/prompt"),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
push([ev("agent_start")]); // busy
|
||||||
|
await userEvent.type(ta, "queued hello");
|
||||||
|
fireEvent.keyDown(ta, { key: "Enter" });
|
||||||
|
|
||||||
|
// not sent yet; visible as a pending bubble
|
||||||
|
expect(promptCalls()).toBe(0);
|
||||||
|
expect(await screen.findByText("queued hello")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// settled → flush
|
||||||
|
push([ev("agent_settled")]);
|
||||||
|
await vi.waitFor(() => expect(promptCalls()).toBe(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queued bubble can be removed before it is sent", async () => {
|
||||||
|
const fetchMock = mockFetchJson((_url, init) =>
|
||||||
|
init?.method === "POST" ? { ok: true } : [],
|
||||||
|
);
|
||||||
|
renderChat(makeStore());
|
||||||
|
const ta = (await screen.findByLabelText("Message")) as HTMLTextAreaElement;
|
||||||
|
const promptCalls = (): number =>
|
||||||
|
fetchMock.mock.calls.filter(([u]) =>
|
||||||
|
String(u).endsWith("/api/sessions/s1/prompt"),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
push([ev("agent_start")]);
|
||||||
|
await userEvent.type(ta, "do not send");
|
||||||
|
fireEvent.keyDown(ta, { key: "Enter" });
|
||||||
|
expect(await screen.findByText("do not send")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByLabelText("Remove queued message"));
|
||||||
|
|
||||||
|
push([ev("agent_settled")]);
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(screen.queryByText("do not send")).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(promptCalls()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+75
-27
@@ -86,6 +86,8 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
const [loadError, setLoadError] = useState<string>("");
|
const [loadError, setLoadError] = useState<string>("");
|
||||||
const [draft, setDraft] = useState<string>("");
|
const [draft, setDraft] = useState<string>("");
|
||||||
const [sending, setSending] = useState<boolean>(false);
|
const [sending, setSending] = useState<boolean>(false);
|
||||||
|
// messages waiting for the current run to settle (sent one per idle window)
|
||||||
|
const [queued, setQueued] = useState<string[]>([]);
|
||||||
const [tasksOpen, setTasksOpen] = useState<boolean>(false);
|
const [tasksOpen, setTasksOpen] = useState<boolean>(false);
|
||||||
const [hasOlder, setHasOlder] = useState<boolean>(false);
|
const [hasOlder, setHasOlder] = useState<boolean>(false);
|
||||||
const [loadingOlder, setLoadingOlder] = useState<boolean>(false);
|
const [loadingOlder, setLoadingOlder] = useState<boolean>(false);
|
||||||
@@ -166,6 +168,7 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
// leak into the new one
|
// leak into the new one
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setSending(false);
|
setSending(false);
|
||||||
|
setQueued([]);
|
||||||
setSearchOpen(false);
|
setSearchOpen(false);
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
sentRef.current = [];
|
sentRef.current = [];
|
||||||
@@ -227,13 +230,13 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
.filter((t) => t.running)
|
.filter((t) => t.running)
|
||||||
.map((t) => t.name)[0];
|
.map((t) => t.name)[0];
|
||||||
const titleClaim: string | null =
|
const titleClaim: string | null =
|
||||||
!busy
|
busy
|
||||||
? label
|
? streaming
|
||||||
: streaming
|
|
||||||
? `${label} · writing…`
|
? `${label} · writing…`
|
||||||
: toolNow !== undefined
|
: toolNow === undefined
|
||||||
? `${label} · ${toolNow}`
|
? `${label} · working…`
|
||||||
: `${label} · working…`;
|
: `${label} · ${toolNow}`
|
||||||
|
: label;
|
||||||
useTitle(titleClaim);
|
useTitle(titleClaim);
|
||||||
|
|
||||||
const minSeq: number = useMemo(
|
const minSeq: number = useMemo(
|
||||||
@@ -312,30 +315,74 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
};
|
};
|
||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
const send = async (): Promise<void> => {
|
const postMessage = async (text: string): Promise<boolean> => {
|
||||||
const text = draft.trim();
|
|
||||||
if (text.length === 0 || sending) return;
|
|
||||||
setDraft("");
|
|
||||||
setSending(true);
|
|
||||||
const hist = sentRef.current;
|
|
||||||
hist.push(text);
|
|
||||||
if (hist.length > SENT_HISTORY_MAX) hist.shift();
|
|
||||||
try {
|
try {
|
||||||
await fetchJson(Route.SessionPrompt(sessionId), {
|
await fetchJson(Route.SessionPrompt(sessionId), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ message: text }),
|
body: JSON.stringify({ message: text }),
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// the message never reached the session: put it back (S6)
|
|
||||||
setDraft(text);
|
|
||||||
if (err instanceof ApiError && err.status === 409)
|
if (err instanceof ApiError && err.status === 409)
|
||||||
pushToast("session offline");
|
pushToast("session offline");
|
||||||
else pushToast(errMessage(err));
|
else pushToast(errMessage(err));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rememberSent = (text: string): void => {
|
||||||
|
const hist = sentRef.current;
|
||||||
|
hist.push(text);
|
||||||
|
if (hist.length > SENT_HISTORY_MAX) hist.shift();
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = async (): Promise<void> => {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (text.length === 0 || sending) return;
|
||||||
|
setDraft("");
|
||||||
|
// agent busy → queue; flushed when the run settles
|
||||||
|
if (busy) {
|
||||||
|
setQueued((q) => [...q, text]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSending(true);
|
||||||
|
rememberSent(text);
|
||||||
|
try {
|
||||||
|
// the message never reached the session: put it back (S6)
|
||||||
|
if (!(await postMessage(text))) setDraft(text);
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unqueue = (index: number): void => {
|
||||||
|
setQueued((q) => q.filter((_, n) => n !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// drain the queue: one message per idle window — the gate only reopens
|
||||||
|
// when the busy window actually opens (the POST resolving before the
|
||||||
|
// agent_start event would otherwise fire the next message back-to-back)
|
||||||
|
const flushingRef = useRef<boolean>(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (busy) {
|
||||||
|
flushingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sending || queued.length === 0 || flushingRef.current) return;
|
||||||
|
const text: string | undefined = queued[0];
|
||||||
|
if (text === undefined) return;
|
||||||
|
flushingRef.current = true;
|
||||||
|
setQueued((q) => q.slice(1));
|
||||||
|
rememberSent(text);
|
||||||
|
void (async () => {
|
||||||
|
// the message never reached the session: put it back (S6)
|
||||||
|
if (!(await postMessage(text))) {
|
||||||
|
flushingRef.current = false;
|
||||||
|
setDraft(text);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [busy, sending, queued, postMessage]);
|
||||||
|
|
||||||
const abort = async (): Promise<void> => {
|
const abort = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await fetchJson(Route.SessionAbort(sessionId), { method: "POST" });
|
await fetchJson(Route.SessionAbort(sessionId), { method: "POST" });
|
||||||
@@ -663,6 +710,8 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
messages={chat.messages}
|
messages={chat.messages}
|
||||||
tools={chat.tools}
|
tools={chat.tools}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
queued={queued}
|
||||||
|
unqueue={unqueue}
|
||||||
hasOlder={hasOlder}
|
hasOlder={hasOlder}
|
||||||
loadingOlder={loadingOlder}
|
loadingOlder={loadingOlder}
|
||||||
onLoadOlder={() => void loadOlder()}
|
onLoadOlder={() => void loadOlder()}
|
||||||
@@ -682,7 +731,7 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
{busy ? (
|
{busy && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="abort-btn"
|
className="abort-btn"
|
||||||
@@ -691,17 +740,16 @@ export default function ChatView({ store, pushToast }: Props) {
|
|||||||
>
|
>
|
||||||
■ stop
|
■ stop
|
||||||
</button>
|
</button>
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="send-btn"
|
|
||||||
aria-label="Send message"
|
|
||||||
disabled={draft.trim().length === 0 || sending}
|
|
||||||
onClick={() => void send()}
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="send-btn"
|
||||||
|
aria-label={busy ? "Queue message" : "Send message"}
|
||||||
|
disabled={draft.trim().length === 0 || sending}
|
||||||
|
onClick={() => void send()}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -688,6 +688,15 @@ a:hover {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.bubble .tool-card summary .tool-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
.bubble .tool-card summary .tool-summary {
|
.bubble .tool-card summary .tool-summary {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 40px;
|
min-width: 40px;
|
||||||
@@ -719,6 +728,9 @@ a:hover {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.tool-status-run {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
details.thinking {
|
details.thinking {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -1378,6 +1390,26 @@ mark.hit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
|
.chat-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.chat-header .title {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.chat-header .model-chip span:first-child {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 110px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
.chat-header .usage-chip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
@@ -1525,3 +1557,23 @@ mark.hit {
|
|||||||
.diff-ctx {
|
.diff-ctx {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- queued (pending) messages ---------- */
|
||||||
|
|
||||||
|
.queued-bubble {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
opacity: 0.7;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.queued-line {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.queued-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user