diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/tabs/tabs.test.ts | 24 | ||||
| -rw-r--r-- | src/features/tabs/tabs.ts | 20 | ||||
| -rw-r--r-- | src/features/tabs/ui.test.ts | 30 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabBar.svelte | 52 |
4 files changed, 123 insertions, 3 deletions
diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts index 3034e76..5effa9b 100644 --- a/src/features/tabs/tabs.test.ts +++ b/src/features/tabs/tabs.test.ts @@ -6,6 +6,7 @@ import { createTab, deriveTitle, initialState, + isStuckToEnd, newDraft, selectTab, setModel, @@ -189,3 +190,26 @@ describe("deriveTitle", () => { expect(result).toBe(`${"a".repeat(40)}\u2026`); }); }); + +describe("isStuckToEnd", () => { + it("is false when the strip does not overflow", () => { + expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 500 })).toBe(false); + expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 400 })).toBe(false); + }); + + it("is true when overflowing and scrolled to the left", () => { + expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 1000 })).toBe(true); + }); + + it("is true when overflowing and scrolled to the middle", () => { + expect(isStuckToEnd({ scrollLeft: 250, clientWidth: 500, scrollWidth: 1000 })).toBe(true); + }); + + it("is false when overflowing but scrolled fully to the right", () => { + expect(isStuckToEnd({ scrollLeft: 500, clientWidth: 500, scrollWidth: 1000 })).toBe(false); + }); + + it("treats a 1px subpixel gap at the end as at-rest (epsilon)", () => { + expect(isStuckToEnd({ scrollLeft: 499, clientWidth: 500, scrollWidth: 1000 })).toBe(false); + }); +}); diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts index 9af522f..a4db6f3 100644 --- a/src/features/tabs/tabs.ts +++ b/src/features/tabs/tabs.ts @@ -66,6 +66,26 @@ export function activeTab(state: TabsState): Tab | null { return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null; } +export interface ScrollMetrics { + readonly scrollLeft: number; + readonly clientWidth: number; + readonly scrollWidth: number; +} + +const STUCK_EPSILON = 1; + +/** + * True when a right-pinned sticky element is floating over scrolled content — the + * strip overflows horizontally AND is not scrolled fully to the right. When it is + * at rest (no overflow, or scrolled to the end so it sits at its natural position) + * this returns false. Pure: layout measurements in, boolean out. + */ +export function isStuckToEnd(m: ScrollMetrics): boolean { + const overflows = m.scrollWidth > m.clientWidth + STUCK_EPSILON; + const notAtEnd = m.scrollLeft + m.clientWidth < m.scrollWidth - STUCK_EPSILON; + return overflows && notAtEnd; +} + export function deriveTitle(message: string, max: number = DEFAULT_MAX_TITLE_LENGTH): string { const trimmed = message.trim().replace(/\s+/g, " "); if (trimmed.length === 0) return DEFAULT_TITLE; diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 53df0be..1ae18c8 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -145,4 +145,34 @@ describe("TabBar", () => { const newChat = screen.getByRole("button", { name: "New chat" }); expect(newChat).toHaveClass("sticky"); }); + + it("shows visible 'New Chat' text when activeConversationId is null", () => { + render(TabBar, { + props: { + tabs: sampleTabs, + activeConversationId: null, + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const newChat = screen.getByRole("button", { name: "New chat" }); + expect(newChat).toHaveTextContent("New Chat"); + }); + + it("does not show 'New Chat' text when a real tab is active", () => { + render(TabBar, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const newChat = screen.getByRole("button", { name: "New chat" }); + expect(newChat).not.toHaveTextContent("New Chat"); + }); }); diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte index 76fab05..eb5b5e5 100644 --- a/src/features/tabs/ui/TabBar.svelte +++ b/src/features/tabs/ui/TabBar.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import type { Tab } from "../tabs"; + import { isStuckToEnd } from "../tabs"; let { tabs, @@ -14,9 +15,47 @@ onClose: (conversationId: string) => void; onNewDraft: () => 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); + + 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(); + }; + }); </script> -<div class="overflow-x-auto border-b border-base-300"> +<div bind:this={scrollEl} class="overflow-x-auto border-b border-base-300"> <div class="tabs tabs-border min-w-max"> {#each tabs as tab (tab.conversationId)} <div @@ -43,12 +82,19 @@ </div> {/each} <button - class="tab sticky right-0 z-10 bg-base-200 shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.2)]" + 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> </div> |
