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:
@@ -215,10 +215,40 @@
|
||||
}
|
||||
|
||||
.chat-thread-header-new-chat {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Single thread-wide markdown / plain-text toggle, anchored to the right of
|
||||
* the header next to "New Chat". Replaces the per-message eye toggle that
|
||||
* used to live inside every assistant bubble. */
|
||||
.chat-thread-header-render-toggle {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(var(--space-md) * 3);
|
||||
height: calc(var(--space-md) * 3);
|
||||
min-width: calc(var(--space-md) * 3);
|
||||
min-height: calc(var(--space-md) * 3);
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.chat-thread-header-render-toggle:hover {
|
||||
background: var(--bg-hover, var(--bg-secondary));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-thread-header-render-toggle--plain {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
@@ -262,6 +292,15 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Model-only chats hide the per-message agent identity. The toggle still
|
||||
* needs a place to live, so collapse the avatar row to just the toggle on
|
||||
* the right edge instead of a full identity strip. */
|
||||
.chat-message-avatar.chat-message-avatar--toolbar-only {
|
||||
margin-bottom: 0;
|
||||
min-height: 0;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-message-render-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -541,13 +541,24 @@ function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
|
||||
|
||||
interface ChatMessageItemProps {
|
||||
message: ChatMessageInfo;
|
||||
/**
|
||||
* When true, render assistant message content as plain text instead of
|
||||
* Markdown. The per-message eye toggle has been removed in favor of a
|
||||
* single thread-level toggle in the chat header, so this is a global
|
||||
* mirror of that header state.
|
||||
*/
|
||||
forcePlain: boolean;
|
||||
agentName: string;
|
||||
/**
|
||||
* Hide the per-message agent identity (icon + name + model tag) on
|
||||
* assistant bubbles. In model-only chats the agent identity *is* the
|
||||
* active model and it's already shown in the thread header.
|
||||
*/
|
||||
hideAssistantIdentity: boolean;
|
||||
showAssistantModelTag: boolean;
|
||||
activeModelTag: string | null;
|
||||
activeSessionId: string | null;
|
||||
mentionAgentsByName: Map<string, Agent>;
|
||||
onToggleRender: (id: string) => void;
|
||||
}
|
||||
|
||||
// Renders a single chat message bubble. Memoized so the streaming bubble's
|
||||
@@ -557,11 +568,11 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
message,
|
||||
forcePlain,
|
||||
agentName,
|
||||
hideAssistantIdentity,
|
||||
showAssistantModelTag,
|
||||
activeModelTag,
|
||||
activeSessionId,
|
||||
mentionAgentsByName,
|
||||
onToggleRender,
|
||||
}: ChatMessageItemProps) {
|
||||
const isAssistantMessage = message.role === "assistant";
|
||||
|
||||
@@ -659,20 +670,11 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
className={`chat-message chat-message--${message.role}`}
|
||||
data-testid={`chat-message-${message.id}`}
|
||||
>
|
||||
{isAssistantMessage && (
|
||||
{isAssistantMessage && !hideAssistantIdentity && (
|
||||
<div className="chat-message-avatar">
|
||||
<Bot size={14} />
|
||||
<span>{agentName}</span>
|
||||
{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-message-render-toggle${forcePlain ? " chat-message-render-toggle--plain" : ""}`}
|
||||
data-testid="chat-message-render-toggle"
|
||||
aria-label={forcePlain ? "Show rendered markdown" : "Show plain text"}
|
||||
onClick={() => onToggleRender(message.id)}
|
||||
>
|
||||
{forcePlain ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isAssistantMessage
|
||||
@@ -730,7 +732,11 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const [mentionPopupVisible, setMentionPopupVisible] = useState(false);
|
||||
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
||||
const [mentionStartPos, setMentionStartPos] = useState(-1);
|
||||
const [plainTextMessageIds, setPlainTextMessageIds] = useState<Set<string>>(() => new Set());
|
||||
// Single thread-wide toggle: when true, all assistant content (including the
|
||||
// streaming bubble) renders as plain text instead of Markdown. Replaces the
|
||||
// earlier per-message toggle so the chat header owns this control instead
|
||||
// of every reply having its own button.
|
||||
const [showAllAsPlain, setShowAllAsPlain] = useState(false);
|
||||
// Attachment state mirrors QuickEntryBox: pending files selected before send.
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
@@ -1479,22 +1485,25 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
? (activeModelTag ?? "Fusion")
|
||||
: (activeSession?.agentId?.slice(0, 30) ?? "Fusion"));
|
||||
|
||||
const showAssistantModelTag = Boolean(activeModelTag && activeModelTag !== agentName);
|
||||
// The model tag is already visible in the thread header — repeating it on
|
||||
// every assistant message is noise. Keep it suppressed for regular chat
|
||||
// (real agent name is the identity); QuickChat already collapses the tag
|
||||
// because its `agentName` IS the model tag, so the per-message slot was
|
||||
// always empty there too.
|
||||
const showAssistantModelTag = false;
|
||||
|
||||
// In model-only chats (no real agent picked) the agent identity *is* the
|
||||
// model name, which is already in the thread header. Repeating it on every
|
||||
// assistant bubble is noise. Hide the per-message identity row entirely;
|
||||
// the render-mode toggle still appears in a slim toolbar.
|
||||
const hideAssistantIdentity = activeSession?.agentId === FN_AGENT_ID;
|
||||
|
||||
const pendingPreview = pendingMessage.length > 50
|
||||
? `${pendingMessage.slice(0, 50)}…`
|
||||
: pendingMessage;
|
||||
|
||||
const toggleMessageRenderMode = useCallback((messageId: string) => {
|
||||
setPlainTextMessageIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(messageId)) {
|
||||
next.delete(messageId);
|
||||
} else {
|
||||
next.add(messageId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const toggleAllAsPlain = useCallback(() => {
|
||||
setShowAllAsPlain((value) => !value);
|
||||
}, []);
|
||||
|
||||
const renderAssistantContent = useCallback(
|
||||
@@ -1677,6 +1686,17 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
<Bot size={16} />
|
||||
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
|
||||
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
{hasThreadInView && (
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-thread-header-render-toggle${showAllAsPlain ? " chat-thread-header-render-toggle--plain" : ""}`}
|
||||
data-testid="chat-thread-render-toggle"
|
||||
aria-label={showAllAsPlain ? "Show all messages as rendered Markdown" : "Show all messages as plain text"}
|
||||
onClick={toggleAllAsPlain}
|
||||
>
|
||||
{showAllAsPlain ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<button
|
||||
className="btn btn-sm btn-primary chat-thread-header-new-chat"
|
||||
@@ -1699,32 +1719,25 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={plainTextMessageIds.has(message.id)}
|
||||
forcePlain={showAllAsPlain}
|
||||
agentName={agentName}
|
||||
hideAssistantIdentity={hideAssistantIdentity}
|
||||
showAssistantModelTag={showAssistantModelTag}
|
||||
activeModelTag={activeModelTag}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
/>
|
||||
))}
|
||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||
<div className="chat-message-avatar">
|
||||
<Bot size={14} />
|
||||
<span>{agentName}</span>
|
||||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " chat-message-render-toggle--plain" : ""}`}
|
||||
data-testid="chat-message-render-toggle"
|
||||
aria-label={plainTextMessageIds.has("__streaming__") ? "Show rendered markdown" : "Show plain text"}
|
||||
onClick={() => toggleMessageRenderMode("__streaming__")}
|
||||
>
|
||||
{plainTextMessageIds.has("__streaming__") ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{!hideAssistantIdentity && (
|
||||
<div className="chat-message-avatar">
|
||||
<Bot size={14} />
|
||||
<span>{agentName}</span>
|
||||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
</div>
|
||||
)}
|
||||
{streamingText ? (
|
||||
renderAssistantContent(streamingText, plainTextMessageIds.has("__streaming__"))
|
||||
renderAssistantContent(streamingText, showAllAsPlain)
|
||||
) : (
|
||||
<div className="chat-message-content chat-message-content--waiting">
|
||||
{streamingThinking ? "Thinking…" : "Connecting…"}
|
||||
@@ -1758,13 +1771,13 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={plainTextMessageIds.has(message.id)}
|
||||
forcePlain={showAllAsPlain}
|
||||
agentName={agentName}
|
||||
hideAssistantIdentity={hideAssistantIdentity}
|
||||
showAssistantModelTag={showAssistantModelTag}
|
||||
activeModelTag={activeModelTag}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user