diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 15:27:58 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 15:27:58 +0900 |
| commit | 635cb6de7342ac87b27243652b1ad3b3a133d6a4 (patch) | |
| tree | 7adb2744dbdb9075620a9a04645bfce9f9d266c4 | |
| parent | 29aef69f00906a7ef973bd68df7a56c7a212206f (diff) | |
| download | dispatch-web-635cb6de7342ac87b27243652b1ad3b3a133d6a4.tar.gz dispatch-web-635cb6de7342ac87b27243652b1ad3b3a133d6a4.zip | |
feat(chat): restyle transcript — left-aligned, bubbleless assistant, tool cards
All messages flow left in one column via the DaisyUI chat-start grid:
- user keeps a primary speech bubble;
- assistant/system/error render in a transparent (invisible) chat-bubble so
they read as plain prose yet inherit identical left spacing, capped to a
readable max-w-5xl column;
- tool call/result render as regular (non-speech) rounded cards, nested in the
same grid so they line up too;
- role header labels dropped; chat-wide left padding added.
Alignment uses specificity-based variants (no !important).
| -rw-r--r-- | src/features/chat/ui.test.ts | 7 | ||||
| -rw-r--r-- | src/features/chat/ui/ChatView.svelte | 97 |
2 files changed, 65 insertions, 39 deletions
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index ac8f640..2099257 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -20,7 +20,6 @@ describe("ChatView", () => { render(ChatView, { props: { chunks } }); expect(screen.getByText("Hello world")).toBeInTheDocument(); - expect(screen.getByText("assistant")).toBeInTheDocument(); }); it("renders multiple chunks", () => { @@ -145,8 +144,10 @@ describe("ChatView", () => { render(ChatView, { props: { chunks } }); - const bubble = screen.getByText("Streaming...").closest(".chat-bubble"); - expect(bubble).toHaveClass("opacity-50"); + // Assistant chunks are no longer in a bubble; the provisional marker now + // lives on the plain wrapper that directly contains the text. + const wrapper = screen.getByText("Streaming...").closest("div"); + expect(wrapper).toHaveClass("opacity-50"); }); it("renders empty transcript", () => { diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index cb6069b..0234852 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -4,44 +4,69 @@ let { chunks }: { chunks: readonly RenderedChunk[] } = $props(); </script> -<div class="flex flex-col gap-2 p-4" role="log" aria-live="polite"> +<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite"> {#each chunks as rendered, i (rendered.seq != null ? `c${rendered.seq}` : `p${i}`)} - <div class="chat {rendered.role === 'user' ? 'chat-start' : 'chat-end'}"> - <div class="chat-header text-xs opacity-70">{rendered.role}</div> - <div - class="chat-bubble" - class:chat-bubble-primary={rendered.role === "user"} - class:chat-bubble-secondary={rendered.role === "assistant"} - class:opacity-50={rendered.provisional} - > - {#if rendered.chunk.type === "text"} - <p>{rendered.chunk.text}</p> - {:else if rendered.chunk.type === "thinking"} - <details> - <summary>Thinking</summary> + {#if rendered.role === "user"} + <!-- User: a speech bubble, left-aligned --> + <div class="chat chat-start"> + <div class="chat-bubble chat-bubble-primary" class:opacity-50={rendered.provisional}> + {#if rendered.chunk.type === "text"} <p>{rendered.chunk.text}</p> - </details> - {:else if rendered.chunk.type === "tool-call"} - <div class="text-sm"> - <strong>{rendered.chunk.toolName}</strong> - <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre> - </div> - {:else if rendered.chunk.type === "tool-result"} - <div class="text-sm" class:text-error={rendered.chunk.isError}> - <strong>{rendered.chunk.toolName}</strong> - <pre class="text-xs mt-1">{rendered.chunk.content}</pre> - </div> - {:else if rendered.chunk.type === "error"} - <div class="text-error" role="alert"> - {rendered.chunk.message} - {#if rendered.chunk.code} - <span class="text-xs opacity-70">[{rendered.chunk.code}]</span> - {/if} - </div> - {:else if rendered.chunk.type === "system"} - <div class="text-sm opacity-70">{rendered.chunk.text}</div> - {/if} + {/if} + </div> </div> - </div> + {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} + <!-- Tool: a regular (non-speech) card. Nested in the chat-start grid via + a transparent, padding-stripped chat-bubble shim so the card inherits + the same left offset as the bubble bodies (no magic margin). --> + <div class="chat chat-start [&>.chat-bubble]:max-w-full [&>.chat-bubble]:p-0"> + <div class="chat-bubble bg-transparent" class:opacity-50={rendered.provisional}> + {#if rendered.chunk.type === "tool-call"} + <div class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm"> + <strong>{rendered.chunk.toolName}</strong> + <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre> + </div> + {:else} + <div + class="w-fit max-w-full rounded-box bg-base-200 p-3 text-sm" + class:text-error={rendered.chunk.isError} + > + <strong>{rendered.chunk.toolName}</strong> + <pre class="text-xs mt-1">{rendered.chunk.content}</pre> + </div> + {/if} + </div> + </div> + {:else} + <!-- Assistant / system / error: an INVISIBLE speech bubble — the same + DaisyUI chat-start grid as the user bubble, so it inherits the + identical left spacing (incl. the small leading gap). Transparent + bg means no visible body and no visible tail; full width capped to + a readable column. --> + <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> + <div + class="chat-bubble w-full bg-transparent" + class:opacity-50={rendered.provisional} + > + {#if rendered.chunk.type === "text"} + <p>{rendered.chunk.text}</p> + {:else if rendered.chunk.type === "thinking"} + <details> + <summary>Thinking</summary> + <p>{rendered.chunk.text}</p> + </details> + {:else if rendered.chunk.type === "error"} + <div class="text-error" role="alert"> + {rendered.chunk.message} + {#if rendered.chunk.code} + <span class="text-xs opacity-70">[{rendered.chunk.code}]</span> + {/if} + </div> + {:else if rendered.chunk.type === "system"} + <div class="text-sm opacity-70">{rendered.chunk.text}</div> + {/if} + </div> + </div> + {/if} {/each} </div> |
