diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 16:41:57 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 16:41:57 +0900 |
| commit | eb1fbf136dfce5099afbf1a2f1411a672656a59a (patch) | |
| tree | 3942fd4a9df1263b607487ba4acea830c6d1d64f | |
| parent | 814544e1924e736c924de6d4d5b3c404ca036999 (diff) | |
| download | dispatch-web-eb1fbf136dfce5099afbf1a2f1411a672656a59a.tar.gz dispatch-web-eb1fbf136dfce5099afbf1a2f1411a672656a59a.zip | |
feat(sidebar-tabs): click tab ID badge to copy conversation id
| -rw-r--r-- | src/features/tabs/ui.test.ts | 32 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabList.svelte | 62 |
2 files changed, 88 insertions, 6 deletions
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index ff342d0..57f9bc1 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/svelte"; +import { fireEvent, render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { Tab } from "./tabs"; @@ -229,4 +229,34 @@ describe("TabList", () => { expect(onRename).toHaveBeenCalledTimes(1); expect(onRename).toHaveBeenCalledWith("c1", "Renamed"); }); + + it("copies the full conversation id to the clipboard and shows 'Copied!' when the ID badge is clicked", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + const onSelect = vi.fn(); + + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect, + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const idBadge = screen.getByRole("button", { name: "Copy conversation id c1" }); + await fireEvent.click(idBadge); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith("c1"); + // The badge briefly shows a "Copied!" confirmation. + expect(idBadge).toHaveTextContent("Copied!"); + // Clicking the ID must NOT switch tabs (the badge stops propagation). + expect(onSelect).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte index efef4fa..40f95cd 100644 --- a/src/features/tabs/ui/TabList.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -55,6 +55,46 @@ function cancelRename(): void { editingId = null; } + + // Click-to-copy the agent (conversation) id: clicking the ID badge copies the + // FULL conversationId to the clipboard (the stable, useful id — the badge + // only shows a short prefix) and briefly highlights the badge as feedback. + // Clipboard is the edge effect; absent (insecure context) → select the badge + // text so the user can Ctrl+C manually. + let copiedId = $state<string | null>(null); + let copyTimer: ReturnType<typeof setTimeout> | undefined; + + async function copyId(conversationId: string, el: HTMLElement): Promise<void> { + const clipboard = navigator.clipboard; + if (clipboard === undefined) { + selectText(el); + flashCopied(conversationId); + return; + } + try { + await clipboard.writeText(conversationId); + flashCopied(conversationId); + } catch { + selectText(el); + flashCopied(conversationId); + } + } + + function flashCopied(conversationId: string): void { + copiedId = conversationId; + clearTimeout(copyTimer); + copyTimer = setTimeout(() => { + copiedId = null; + }, 1200); + } + + function selectText(el: HTMLElement): void { + const range = document.createRange(); + range.selectNodeContents(el); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } </script> <div class="flex flex-col gap-2"> @@ -75,12 +115,24 @@ if (e.key === "Enter") onSelect(tab.conversationId); }} > - <span - class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60" - title="Tab ID" + <button + type="button" + class="shrink-0 rounded px-1 py-0.5 font-mono text-[10px] leading-none transition-colors {copiedId === + tab.conversationId + ? "bg-success text-success-content" + : "bg-base-300 text-base-content/60 hover:bg-primary hover:text-primary-content"}" + data-copy-id={tab.conversationId} + title={copiedId === tab.conversationId ? "Copied!" : "Click to copy conversation id"} + aria-label={`Copy conversation id ${tab.conversationId}`} + onclick={(e) => { + e.stopPropagation(); + void copyId(tab.conversationId, e.currentTarget); + }} > - {handles.get(tab.conversationId) ?? tab.conversationId} - </span> + {copiedId === tab.conversationId + ? "Copied!" + : (handles.get(tab.conversationId) ?? tab.conversationId)} + </button> {#if editingId === tab.conversationId} <input bind:this={editEl} |
