feat(chat): unify QuickChat backend path and consolidate render toggle

Backend
- chat.ts now routes both regular chat and QuickChat through
  createResolvedAgentSession instead of branching to createFnAgent for
  the no-runtime-hint case. This removes the divergent path where
  pi-ai's cleanupSessionResources(sessionId) could tear down resources
  the next generation depends on.
- sendMessage's finally only disposes the agent if it still owns the
  activeGenerations slot. A newer generation that has pre-empted us
  cleans up its own agent in its own finally — disposing here would
  yank the underlying CLI process out from under it.
- __setCreateFnAgent test helper now mirrors its mock into the
  createResolvedAgentSession slot so existing test setups still work
  after the unification.

Frontend
- Extract createChatStreamHandlers (RAF coalescing, accumulators,
  tool-call dedup, fallback handling) — useChat and useQuickChat were
  duplicating ~85 LOC each. Both now compose the shared factory.
- Move shared chat types into chatTypes.ts. The hooks re-export them
  for backward compatibility with existing consumers.
- Removed per-message Markdown/plain-text eye toggles. A single
  thread-level toggle in the chat header now flips every assistant
  bubble (including the streaming one) between rendered Markdown and
  plain text.
- Model-only chats hide the per-message agent identity row entirely;
  the model name is already in the thread header.

Tests
- Updated ChatView tests to reflect the new render-toggle contract
  (single header toggle drives all bubbles) and the model-only avatar
  suppression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-05 11:04:48 -07:00
parent 02eb1c5b30
commit 32c19af7d3
9 changed files with 491 additions and 373 deletions

View File

@@ -445,7 +445,7 @@ describe("ChatView", () => {
expect(screen.queryByTestId("chat-render-mode-plain")).not.toBeInTheDocument();
});
it("renders per-message eye toggles for assistant bubbles on desktop and isolates toggles by message", async () => {
it("thread-header toggle flips every assistant bubble between rendered Markdown and plain text", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
@@ -458,24 +458,27 @@ describe("ChatView", () => {
const firstBubble = screen.getByTestId("chat-message-msg-001");
const secondBubble = screen.getByTestId("chat-message-msg-002");
const [firstToggle, secondToggle] = screen.getAllByTestId("chat-message-render-toggle");
const headerToggle = screen.getByTestId("chat-thread-render-toggle");
expect(firstToggle).toBeInTheDocument();
expect(secondToggle).toBeInTheDocument();
// Per-message toggles were intentionally removed; only the single
// thread-level toggle should exist.
expect(screen.queryAllByTestId("chat-message-render-toggle")).toHaveLength(0);
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(firstToggle);
await userEvent.click(headerToggle);
expect(within(firstBubble).getByText(/\*\*First\*\* item/)).toBeInTheDocument();
expect(within(firstBubble).queryByText("First", { selector: "strong" })).toBeNull();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
expect(within(secondBubble).getByText(/\*\*Second\*\* item/)).toBeInTheDocument();
expect(within(secondBubble).queryByText("Second", { selector: "strong" })).toBeNull();
await userEvent.click(firstToggle);
await userEvent.click(headerToggle);
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
});
it("uses a dedicated streaming toggle sentinel without affecting persisted assistant messages", async () => {
it("thread-header toggle also drives the streaming bubble", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**Persisted**", createdAt: "2026-04-08T00:00:00.000Z" }],
@@ -487,19 +490,19 @@ describe("ChatView", () => {
const persistedBubble = screen.getByTestId("chat-message-msg-001");
const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement;
const [persistedToggle, streamingToggle] = screen.getAllByTestId("chat-message-render-toggle");
const headerToggle = screen.getByTestId("chat-thread-render-toggle");
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(streamingToggle);
await userEvent.click(headerToggle);
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(persistedToggle);
expect(within(persistedBubble).getByText(/\*\*Persisted\*\*/)).toBeInTheDocument();
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
await userEvent.click(headerToggle);
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
});
it("renders tool calls from persisted messages", () => {
@@ -869,7 +872,10 @@ describe("ChatView", () => {
expect(within(avatar!).queryByText("Fusion")).not.toBeInTheDocument();
});
it("shows Fusion in assistant message avatar for fn agent sessions", () => {
it("hides per-message assistant identity for fn agent (model-only) sessions", () => {
// Model-only chats use the active model as their identity, which is
// already shown in the thread header. We deliberately suppress the
// per-message avatar to avoid repeating it on every reply.
setupMockChat({
activeSession: { id: "session-001", agentId: "__fn_agent__", status: "active", title: "Fusion Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
@@ -879,12 +885,11 @@ describe("ChatView", () => {
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
expect(avatar).toBeInTheDocument();
expect(within(avatar!).getByText("Fusion")).toBeInTheDocument();
const messageBubble = screen.getByTestId("chat-message-msg-001");
expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
});
it("shows formatted model name in assistant message avatar for fn agent sessions", async () => {
it("hides per-message assistant identity for fn agent (model-only) sessions even when a model is configured", async () => {
setupMockChat({
activeSession: {
id: "session-001",
@@ -902,14 +907,12 @@ describe("ChatView", () => {
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
expect(avatar).toBeInTheDocument();
const messageBubble = screen.getByTestId("chat-message-msg-001");
expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
// The model name still appears once in the thread header.
await waitFor(() => {
expect(within(avatar!).getByText("Claude Sonnet 4.5")).toBeInTheDocument();
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
});
expect(within(avatar!).queryByText("Fusion")).not.toBeInTheDocument();
expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
});
it("shows resolved agent name in streaming assistant avatar", async () => {
@@ -1858,7 +1861,11 @@ describe("ChatView", () => {
expect(modelTag).not.toBeInTheDocument();
});
it("shows model tag in message avatar when non-fn session has model", () => {
it("does not repeat the model tag in per-message avatars for non-fn sessions", () => {
// Per-message model tags were intentionally removed — the model is shown
// once in the thread header. The avatar should still render with the
// agent name (no agent identity collapse for real agents) but no model
// tag inside it.
setupMockChat({
activeSession: {
id: "session-001",
@@ -1876,12 +1883,13 @@ describe("ChatView", () => {
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
const messageBubble = screen.getByTestId("chat-message-msg-001");
const avatar = messageBubble.querySelector(".chat-message-avatar") as HTMLElement | null;
expect(avatar).toBeInTheDocument();
expect(avatar?.querySelector(".chat-model-tag")?.textContent).toContain("GPT");
expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
});
it("does not show duplicate model tag in message avatar for fn agent sessions", () => {
it("hides per-message identity entirely for fn agent (model-only) sessions even when model is set", () => {
setupMockChat({
activeSession: {
id: "session-001",
@@ -1899,10 +1907,8 @@ describe("ChatView", () => {
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
expect(avatar).toBeInTheDocument();
expect(within(avatar!).getByText("GPT-4o")).toBeInTheDocument();
expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
const messageBubble = screen.getByTestId("chat-message-msg-001");
expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
});
});