summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 15:27:58 +0900
committerAdam Malczewski <[email protected]>2026-06-07 15:27:58 +0900
commit635cb6de7342ac87b27243652b1ad3b3a133d6a4 (patch)
tree7adb2744dbdb9075620a9a04645bfce9f9d266c4
parent29aef69f00906a7ef973bd68df7a56c7a212206f (diff)
downloaddispatch-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.ts7
-rw-r--r--src/features/chat/ui/ChatView.svelte97
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>