summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui/ToolCard.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-15 08:44:04 +0900
committerAdam Malczewski <[email protected]>2026-10-15 08:44:04 +0900
commitc38aba84b29f13c4d6ac940b47ce231e55b6558a (patch)
tree5470958aab18ba956305f12d2fdfd5dcf25c2686 /src/features/chat/ui/ToolCard.svelte
parent8676bd29452f7539622901cb5ae703a3fdaebd07 (diff)
downloaddispatch-web-c38aba84b29f13c4d6ac940b47ce231e55b6558a.tar.gz
dispatch-web-c38aba84b29f13c4d6ac940b47ce231e55b6558a.zip
feat(tool-call-cards): polish responsive headers and expanded details
Diffstat (limited to 'src/features/chat/ui/ToolCard.svelte')
-rw-r--r--src/features/chat/ui/ToolCard.svelte316
1 files changed, 220 insertions, 96 deletions
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>