summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs/ui
diff options
context:
space:
mode:
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>