diff options
Diffstat (limited to 'src/features/tabs/ui')
| -rw-r--r-- | src/features/tabs/ui/TabList.svelte (renamed from src/features/tabs/ui/TabBar.svelte) | 133 |
1 files changed, 69 insertions, 64 deletions
diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabList.svelte index f783412..6d85065 100644 --- a/src/features/tabs/ui/TabBar.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { Tab } from "../tabs"; - import { isStuckToEnd, shortHandle } from "../tabs"; + import { shortHandle } from "../tabs"; let { tabs, @@ -21,13 +21,6 @@ onRename?: (conversationId: string, title: string) => void; } = $props(); - // The new-chat button is `position: sticky; right: 0`. It floats over the tabs - // only while the strip overflows and isn't scrolled fully right; we square its - // right edge only in that "stuck" state. Pure decision (`isStuckToEnd`) + - // DOM-measurement at the edge here. - let scrollEl = $state<HTMLDivElement>(); - let stuck = $state(false); - // Git-style short handle (shortest unique prefix) per open tab — the visible // "tab ID". Derived from the set of open conversation ids; pure helper. const handles = $derived.by(() => { @@ -37,36 +30,6 @@ return map; }); - function recompute(): void { - const el = scrollEl; - if (el === undefined) { - stuck = false; - return; - } - stuck = isStuckToEnd({ - scrollLeft: el.scrollLeft, - clientWidth: el.clientWidth, - scrollWidth: el.scrollWidth, - }); - } - - $effect(() => { - const el = scrollEl; - if (el === undefined) return; - // Re-evaluate when the tab set changes (overflow may appear/disappear). - void tabs; - recompute(); - - el.addEventListener("scroll", recompute, { passive: true }); - const ro = - typeof ResizeObserver !== "undefined" ? new ResizeObserver(recompute) : undefined; - ro?.observe(el); - - return () => { - el.removeEventListener("scroll", recompute); - ro?.disconnect(); - }; - }); // Inline rename: double-click a tab's title to edit, Enter/blur to save. let editingId = $state<string | null>(null); let editValue = $state(""); @@ -92,27 +55,63 @@ 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 highlights the badge text as feedback. The + // highlight (text selection) is the indicator — no text is swapped, so the + // badge keeps a stable width. If the clipboard write fails, the selection is + // already in place so the user can Ctrl+C the text manually. + async function copyId(conversationId: string, el: HTMLElement): Promise<void> { + selectText(el); + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + try { + await clipboard.writeText(conversationId); + } catch { + // Selection already lets the user copy manually. + } + } + + function selectText(el: HTMLElement): void { + const range = document.createRange(); + range.selectNodeContents(el); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } </script> -<div bind:this={scrollEl} class="min-w-0 flex-1 overflow-x-auto"> - <div class="tabs tabs-lift min-w-max"> +<div class="flex flex-col gap-2"> + <!-- Single-column vertical tab list. Fixed at 40% of the viewport height so a + long tab set scrolls inside this region instead of growing the whole sidebar. --> + <div class="flex h-[40vh] flex-col gap-1 overflow-y-auto pr-1"> {#each tabs as tab (tab.conversationId)} <div - class="tab flex w-48 shrink-0 items-center gap-1.5" - class:tab-active={tab.conversationId === activeConversationId} + class="flex items-center gap-1.5 rounded px-2 py-1.5 text-sm hover:bg-base-300" + class:bg-base-300={tab.conversationId === activeConversationId} role="tab" tabindex="0" + aria-selected={tab.conversationId === activeConversationId} + title={tab.title} onclick={() => onSelect(tab.conversationId)} onkeydown={(e) => { 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 bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content" + data-copy-id={tab.conversationId} + title="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> + </button> {#if editingId === tab.conversationId} <input bind:this={editEl} @@ -135,7 +134,7 @@ class="min-w-0 flex-1 cursor-pointer truncate text-left" role="button" tabindex="-1" - title="Double-click to rename" + title={tab.title} ondblclick={(e) => { e.stopPropagation(); startRename(tab); @@ -144,8 +143,15 @@ {tab.title} </span> {/if} - {#if statusFor?.(tab.conversationId) === "active"} - <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span> + {#if statusFor?.(tab.conversationId) === "queued"} + <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). --> + <span + class="loading loading-ring loading-xs shrink-0 text-primary" + aria-label="Queued" + title="Waiting for a concurrency slot" + ></span> + {:else if statusFor?.(tab.conversationId) === "active"} + <span class="loading loading-dots loading-xs shrink-0 text-primary"></span> {/if} <button class="btn btn-ghost btn-xs shrink-0" @@ -159,20 +165,19 @@ </button> </div> {/each} - <button - class="tab sticky right-0 z-10 bg-base-200 shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.2)] {stuck - ? '!rounded-se-none !rounded-ee-none' - : ''}" - class:tab-active={activeConversationId === null} - aria-label="New chat" - onclick={() => onNewDraft()} - > - {#if activeConversationId === null} - <span class="max-w-[120px] truncate">New Chat</span> - <span class="btn btn-ghost btn-xs ml-1" aria-hidden="true">+</span> - {:else} - + - {/if} - </button> </div> + + <button + type="button" + class="btn btn-ghost btn-sm w-full border border-base-300" + class:btn-primary={activeConversationId === null} + aria-label="New chat" + onclick={() => onNewDraft()} + > + {#if activeConversationId === null} + New Chat + {:else} + + New chat + {/if} + </button> </div> |
