diff options
Diffstat (limited to 'src/features/chat/ui')
| -rw-r--r-- | src/features/chat/ui/ChatView.test.ts | 55 | ||||
| -rw-r--r-- | src/features/chat/ui/ToolCard.svelte | 316 |
2 files changed, 271 insertions, 100 deletions
diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts index d12088b..412bf2d 100644 --- a/src/features/chat/ui/ChatView.test.ts +++ b/src/features/chat/ui/ChatView.test.ts @@ -244,7 +244,8 @@ describe("ChatView — tool cards", () => { expect(box).not.toBeChecked(); expect(screen.getByLabelText("Failed")).toHaveClass("text-error"); expect(screen.getByLabelText("Failed")).toHaveTextContent("✗"); - expect(screen.getByText("→ failed · Permission denied")).toHaveClass("text-error"); + expect(screen.getByText("→ failed").closest(".tool-result")).toHaveClass("text-error"); + expect(container.querySelector(".tool-header")).not.toHaveTextContent("Permission denied"); expect(container.querySelector("pre")).toBeNull(); await userEvent.click(box); expect(screen.getByText("Permission denied").parentElement).toHaveClass("text-error"); @@ -253,10 +254,11 @@ describe("ChatView — tool cards", () => { const chunks = shellChunks("contents", false, true); const { container, rerender } = render(ChatView, { props: { chunks: chunks.slice(1) } }); expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1); - expect(screen.getByText("call unavailable")).toBeInTheDocument(); + expect(screen.getByText("Call in earlier history")).toHaveClass("earlier-history"); + expect(screen.getByLabelText("Succeeded")).toHaveClass("opacity-60"); const box = screen.getByRole("checkbox", { name: "Toggle tool call run_shell" }); await userEvent.click(box); - expect(screen.getByText(/Call unavailable \(earlier history unloaded\)/)).toBeInTheDocument(); + expect(screen.getByText(/The call is in earlier, unloaded history/)).toBeInTheDocument(); expect(screen.getByText("contents")).toBeInTheDocument(); await rerender({ chunks }); expect(screen.getByRole("checkbox", { name: "Toggle tool call run_shell" })).toBe(box); @@ -280,6 +282,12 @@ describe("ChatView — tool cards", () => { expect(response).toHaveTextContent("output-7"); expect(response.querySelectorAll("pre")).toHaveLength(1); expect(response.querySelector("pre")?.textContent).toBe(output); + const fewer = screen.getByRole("button", { name: "Show fewer lines" }); + expect(fewer).toHaveClass("btn-primary", "btn-sm"); + expect(fewer.parentElement).toHaveClass("sticky-controls"); + const fullOutput = response.querySelector("pre"); + if (fullOutput === null) throw new Error("expected full output"); + expect(fewer.compareDocumentPosition(fullOutput)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect(box).toBeChecked(); expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute( "aria-expanded", @@ -333,7 +341,8 @@ describe("ChatView — tool cards", () => { }, ]; const { container } = render(ChatView, { props: { chunks } }); - expect(screen.getByText("+2 −1")).toBeInTheDocument(); + expect(screen.getByText("+2")).toHaveClass("text-success"); + expect(screen.getByText("−1")).toHaveClass("text-error"); expect(screen.getByText("→ 1 replacement · diagnostics")).toBeInTheDocument(); await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call edit_file" })); expect(screen.getByText("before")).toHaveClass("text-error"); @@ -342,3 +351,41 @@ describe("ChatView — tool cards", () => { expect(container).toHaveTextContent("<script>alert(1)</script>"); }); }); + +it("renders compact, aligned generic fields with legible headings and inline scalar arrays", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "issue", + toolName: "mcp__github__create_issue", + input: { + owner: "realtradam", + repo: "frontend", + title: "Tool cards", + labels: ["ui", "chat"], + options: { nested: true }, + }, + }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + await userEvent.click( + screen.getByRole("checkbox", { name: "Toggle tool call mcp__github__create_issue" }), + ); + for (const name of ["Call", "Response"]) { + expect(screen.getByRole("heading", { name })).toHaveClass("font-semibold", "text-base-content"); + } + const call = screen.getByRole("region", { name: "Call" }); + expect(call.querySelector("dl")).toHaveClass("call-fields"); + expect(screen.getByText("owner")).toHaveClass("font-semibold", "text-base-content/85"); + const labels = screen.getByText('["ui", "chat"]'); + expect(labels.tagName).toBe("PRE"); + expect(labels.textContent).not.toContain("\n"); + expect(container.querySelectorAll("dt")).toHaveLength(5); + expect(container.querySelectorAll("dd")).toHaveLength(5); + expect(screen.getByText("options").nextElementSibling).toHaveTextContent('"nested": true'); +}); diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte index 8c6e325..f1a56cb 100644 --- a/src/features/chat/ui/ToolCard.svelte +++ b/src/features/chat/ui/ToolCard.svelte @@ -8,104 +8,228 @@ let showAll = $state(false); </script> -<LazyCollapse label={`Toggle tool call ${card.name}`}> - {#snippet title()} - {#if card.status === "pending"} - <span class="loading loading-spinner loading-xs shrink-0" aria-label="Running"></span> - {:else if card.status === "error"} - <span class="shrink-0 text-error" aria-label="Failed">✗</span> - {:else} - <span class="shrink-0 text-success" aria-label="Succeeded">✓</span> - {/if} - <svg - class="h-4 w-4 shrink-0 opacity-60" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - aria-hidden="true" - > - <path - d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" - /> - </svg> - <span class="max-w-[35%] shrink-0 truncate" title={card.name}>{card.name}</span> - <span class="min-w-0 flex-1 truncate opacity-70" title={card.callSummary} - >{card.callSummary}</span - > - {#each card.callBadges as badge} - <span class="shrink-0 text-xs opacity-70">{badge}</span> - {/each} - {#if card.resultSummary !== null} - <span - class="max-w-[40%] shrink-0 truncate text-xs" - class:text-error={card.status === "error"} - title={card.resultSummary} - > - → {card.resultSummary} - </span> - {/if} - {/snippet} - <div class="min-w-0 space-y-3 text-xs"> - <section aria-label="Call"> - <div class="mb-1 font-medium opacity-60">Call</div> - {#if card.callDetail.kind === "command"} - <pre class="overflow-x-auto">$ {card.callDetail.text}</pre> - {:else if card.callDetail.kind === "edit"} - <div class="mb-1 break-all font-mono">{card.callDetail.path}</div> - <div class="space-y-1"> - <div class="opacity-60">− old</div> - <pre class="overflow-x-auto text-error">{card.callDetail.oldText}</pre> - <div class="opacity-60">+ new</div> - <pre class="overflow-x-auto text-success">{card.callDetail.newText}</pre> - </div> - {:else} - <dl class="space-y-1"> - {#each card.callDetail.fields as field} - <div> - <dt class="opacity-60">{field.key}</dt> - <dd> +<div class="tool-card"> + <LazyCollapse label={`Toggle tool call ${card.name}`} titleClass="pe-4"> + {#snippet title()} + <div class="tool-header" class:has-earlier-call={card.callDetail.kind === "unavailable"}> + {#if card.status === "pending"} + <span class="loading loading-spinner loading-xs shrink-0" aria-label="Running"></span> + {:else if card.status === "error"} + <span class="shrink-0 text-error" aria-label="Failed">✗</span> + {:else} + <span + class="shrink-0 text-success" + class:opacity-60={card.callDetail.kind === "unavailable"} + aria-label="Succeeded">✓</span + > + {/if} + <svg + class="h-4 w-4 shrink-0 opacity-60" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + > + <path + d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" + /> + </svg> + <span class="tool-name truncate" title={card.name}>{card.name}</span> + <span + class="tool-summary truncate text-base-content/90" + class:earlier-history={card.callDetail.kind === "unavailable"} + title={card.callDetail.kind === "unavailable" ? card.callDetail.text : card.callSummary} + > + {card.callSummary} + </span> + <span class="tool-badges gap-1 text-xs"> + {#each card.callBadges as badge} + <span + class:text-success={badge.tone === "success"} + class:text-error={badge.tone === "error"}>{badge.label}</span + > + {/each} + </span> + {#if card.resultSummary !== null} + <span + class="tool-result text-xs" + class:text-error={card.status === "error"} + title={card.resultSummary} + > + {#if card.resultSummary === card.compactResultSummary} + <span>→ {card.resultSummary}</span> + {:else} + <span class="result-full">→ {card.resultSummary}</span> + <span class="result-compact">→ {card.compactResultSummary}</span> + {/if} + </span> + {/if} + </div> + {/snippet} + <div class="min-w-0 space-y-4 text-xs"> + <section aria-label="Call"> + <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content"> + Call + </h3> + {#if card.callDetail.kind === "unavailable"} + <p class="text-base-content/75">{card.callDetail.text}</p> + {:else if card.callDetail.kind === "command"} + <pre class="overflow-x-auto">$ {card.callDetail.text}</pre> + {:else if card.callDetail.kind === "edit"} + <div class="mb-2 break-all font-mono">{card.callDetail.path}</div> + <div class="space-y-1"> + <div class="font-medium text-error">− old</div> + <pre class="overflow-x-auto text-error">{card.callDetail.oldText}</pre> + <div class="font-medium text-success">+ new</div> + <pre class="overflow-x-auto text-success">{card.callDetail.newText}</pre> + </div> + {:else} + <dl class="call-fields"> + {#each card.callDetail.fields as field} + <dt class="font-semibold text-base-content/85">{field.key}</dt> + <dd class="min-w-0"> <pre class="overflow-x-auto" - class:whitespace-pre-wrap={!field.multiline}>{field.value}</pre> + class:whitespace-pre-wrap={!field.multiline} + class:break-words={!field.multiline}>{field.value}</pre> </dd> - </div> - {/each} - </dl> - {/if} - </section> - <section aria-label="Response"> - <div class="mb-1 font-medium opacity-60">Response</div> - {#if card.output === null} - <span class="opacity-60">running…</span> - {:else} - <div class:text-error={card.output.isError}> - {#if card.output.kind === "full"} - <pre class="overflow-x-auto">{card.output.lines.join("\n") || "(no output)"}</pre> - {:else} - {#if showAll} - <pre class="overflow-x-auto">{card.output.all.join("\n")}</pre> + {/each} + </dl> + {/if} + </section> + <section aria-label="Response"> + <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content"> + Response + </h3> + {#if card.output === null} + <span class="text-base-content/75">running…</span> + {:else} + <div class:text-error={card.output.isError}> + {#if card.output.kind === "full"} + <pre class="overflow-x-auto">{card.output.lines.join("\n") || "(no output)"}</pre> {:else} - <pre class="overflow-x-auto">{card.output.head.join("\n")}</pre> - {/if} - <button - class="btn btn-ghost btn-xs my-1" - aria-expanded={showAll} - onclick={() => { - showAll = !showAll; - }} - > - {showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`} - </button> - {#if !showAll} - <pre class="overflow-x-auto">{card.output.tail.join("\n")}</pre> + {#if !showAll} + <pre class="overflow-x-auto">{card.output.head.join("\n")}</pre> + {/if} + <div class="output-controls" class:sticky-controls={showAll}> + <button + class="btn btn-primary btn-sm focus-visible:outline-primary" + aria-expanded={showAll} + onclick={() => { + showAll = !showAll; + }} + > + <span aria-hidden="true">{showAll ? "↑" : "↕"}</span> + {showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`} + </button> + </div> + {#if showAll} + <pre class="overflow-x-auto">{card.output.all.join("\n")}</pre> + {:else} + <pre class="overflow-x-auto">{card.output.tail.join("\n")}</pre> + {/if} {/if} - {/if} - </div> - {/if} - </section> - </div> -</LazyCollapse> + </div> + {/if} + </section> + </div> + </LazyCollapse> +</div> + +<style> + .tool-card { + container-type: inline-size; + } + .tool-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-width: 0; + } + .tool-name { + flex: 0 1 auto; + max-width: 30%; + } + .tool-summary { + flex: 1; + min-width: 0; + } + .earlier-history { + font-style: italic; + color: var(--color-base-content); + opacity: 0.7; + } + .tool-badges { + display: flex; + flex-shrink: 0; + } + .tool-badges:empty { + display: none; + } + .tool-result { + flex-shrink: 0; + white-space: nowrap; + } + .result-compact { + display: none; + } + .call-fields { + display: grid; + grid-template-columns: minmax(4rem, max-content) minmax(0, 1fr); + gap: 0.5rem 1rem; + align-items: baseline; + } + .call-fields dt { + max-width: 10rem; + overflow-wrap: anywhere; + } + .output-controls { + padding-block: 0.5rem; + color: var(--color-base-content); + background: var(--color-base-200); + } + .sticky-controls { + position: sticky; + top: 0; + z-index: 2; + border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent); + margin-bottom: 0.5rem; + } + @container (max-width: 48rem) { + .result-full { + display: none; + } + .result-compact { + display: inline; + } + } + @container (max-width: 40rem) { + .tool-badges { + display: none; + } + .tool-name { + max-width: 32%; + } + .has-earlier-call .tool-result { + display: none; + } + } + @container (max-width: 24rem) { + .tool-header { + gap: 0.375rem; + } + .call-fields { + column-gap: 0.75rem; + } + } + @container (max-width: 18rem) { + .tool-result { + display: none; + } + } +</style> |
