summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 14:49:30 +0900
committerAdam Malczewski <[email protected]>2026-06-07 14:49:30 +0900
commit29aef69f00906a7ef973bd68df7a56c7a212206f (patch)
tree4229199b4aed1d9225a50ca95e05ffb8f204e418 /src/features
parent0cb08678ffead285afb1f93ba50cd5a144ed5e7d (diff)
downloaddispatch-web-29aef69f00906a7ef973bd68df7a56c7a212206f.tar.gz
dispatch-web-29aef69f00906a7ef973bd68df7a56c7a212206f.zip
feat(tabs): polish new-chat button — stuck-only square edge, New Chat labelHEADmain
- isStuckToEnd (pure): square the sticky '+' right edge only while it floats over scrolled tabs; rounded at rest. Edge-measured in TabBar via a disposed scroll + ResizeObserver effect (RO guarded for non-browser envs). - Show a temporary 'New Chat' title when the draft is selected, with the '+' moved to the trailing close-button slot for consistency with real tabs.
Diffstat (limited to 'src/features')
-rw-r--r--src/features/tabs/tabs.test.ts24
-rw-r--r--src/features/tabs/tabs.ts20
-rw-r--r--src/features/tabs/ui.test.ts30
-rw-r--r--src/features/tabs/ui/TabBar.svelte52
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>