summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:13:28 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:13:31 +0900
commit2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch)
tree94e1923180ae38d571d34b578afecb0a18913c24 /src/features/tabs/ui
parent80f99665034a0e510300793205c162fc7a46769f (diff)
parent08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff)
downloaddispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.tar.gz
dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.zip
Merge branch 'dev' into feature/heartbeat
# Conflicts: # src/app/App.svelte # src/app/store.svelte.ts # src/app/store.test.ts # src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/tabs/ui')
-rw-r--r--src/features/tabs/ui/TabBar.svelte325
1 files changed, 162 insertions, 163 deletions
diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte
index 7ec2101..211fd5c 100644
--- a/src/features/tabs/ui/TabBar.svelte
+++ b/src/features/tabs/ui/TabBar.svelte
@@ -1,178 +1,177 @@
<script lang="ts">
- import type { Tab } from "../tabs";
- import { isStuckToEnd, shortHandle } from "../tabs";
+ import type { Tab } from "../tabs";
+ import { isStuckToEnd, shortHandle } from "../tabs";
- let {
- tabs,
- activeConversationId,
- statusFor,
- onSelect,
- onClose,
- onNewDraft,
- onRename,
- }: {
- tabs: readonly Tab[];
- activeConversationId: string | null;
- /** Returns the conversation's lifecycle status, or undefined when unknown. */
- statusFor?: (conversationId: string) => string | undefined;
- onSelect: (conversationId: string) => void;
- onClose: (conversationId: string) => void;
- onNewDraft: () => void;
- onRename?: (conversationId: string, title: string) => void;
- } = $props();
+ let {
+ tabs,
+ activeConversationId,
+ statusFor,
+ onSelect,
+ onClose,
+ onNewDraft,
+ onRename,
+ }: {
+ tabs: readonly Tab[];
+ activeConversationId: string | null;
+ /** Returns the conversation's lifecycle status, or undefined when unknown. */
+ statusFor?: (conversationId: string) => string | undefined;
+ onSelect: (conversationId: string) => void;
+ onClose: (conversationId: string) => void;
+ onNewDraft: () => void;
+ 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);
+ // 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(() => {
- const ids = tabs.map((t) => t.conversationId);
- const map = new Map<string, string>();
- for (const id of ids) map.set(id, shortHandle(id, ids));
- return map;
- });
+ // 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(() => {
+ const ids = tabs.map((t) => t.conversationId);
+ const map = new Map<string, string>();
+ for (const id of ids) map.set(id, shortHandle(id, ids));
+ 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,
- });
- }
+ 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();
+ $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);
+ 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("");
- let editEl = $state<HTMLInputElement>();
+ 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("");
+ let editEl = $state<HTMLInputElement>();
- function startRename(tab: Tab): void {
- if (onRename === undefined) return;
- editingId = tab.conversationId;
- editValue = tab.title;
- // Focus the input after it renders.
- queueMicrotask(() => editEl?.focus());
- }
+ function startRename(tab: Tab): void {
+ if (onRename === undefined) return;
+ editingId = tab.conversationId;
+ editValue = tab.title;
+ // Focus the input after it renders.
+ queueMicrotask(() => editEl?.focus());
+ }
- function commitRename(): void {
- const id = editingId;
- if (id !== null && onRename !== undefined) {
- const trimmed = editValue.trim();
- if (trimmed.length > 0) onRename(id, trimmed);
- }
- editingId = null;
- }
+ function commitRename(): void {
+ const id = editingId;
+ if (id !== null && onRename !== undefined) {
+ const trimmed = editValue.trim();
+ if (trimmed.length > 0) onRename(id, trimmed);
+ }
+ editingId = null;
+ }
- function cancelRename(): void {
- editingId = null;
- }
+ function cancelRename(): void {
+ editingId = null;
+ }
</script>
<div bind:this={scrollEl} class="min-w-0 flex-1 overflow-x-auto">
- <div class="tabs tabs-lift min-w-max">
- {#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}
- role="tab"
- tabindex="0"
- 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"
- >
- {handles.get(tab.conversationId) ?? tab.conversationId}
- </span>
- {#if editingId === tab.conversationId}
- <input
- bind:this={editEl}
- bind:value={editValue}
- class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary"
- onclick={(e) => e.stopPropagation()}
- onkeydown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- commitRename();
- } else if (e.key === "Escape") {
- e.preventDefault();
- cancelRename();
- }
- }}
- onblur={commitRename}
- />
- {:else}
- <span
- class="min-w-0 flex-1 cursor-pointer truncate text-left"
- role="button"
- tabindex="-1"
- title={tab.title}
- ondblclick={(e) => {
- e.stopPropagation();
- startRename(tab);
- }}
- >
- {tab.title}
- </span>
- {/if}
- {#if statusFor?.(tab.conversationId) === "active"}
- <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span>
- {/if}
- <button
- class="btn btn-ghost btn-xs shrink-0"
- aria-label="Close tab"
- onclick={(e) => {
- e.stopPropagation();
- onClose(tab.conversationId);
- }}
- >
- &times;
- </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>
+ <div class="tabs tabs-lift min-w-max">
+ {#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}
+ role="tab"
+ tabindex="0"
+ 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"
+ >
+ {handles.get(tab.conversationId) ?? tab.conversationId}
+ </span>
+ {#if editingId === tab.conversationId}
+ <input
+ bind:this={editEl}
+ bind:value={editValue}
+ class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary"
+ onclick={(e) => e.stopPropagation()}
+ onkeydown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitRename();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ cancelRename();
+ }
+ }}
+ onblur={commitRename}
+ />
+ {:else}
+ <span
+ class="min-w-0 flex-1 cursor-pointer truncate text-left"
+ role="button"
+ tabindex="-1"
+ title={tab.title}
+ ondblclick={(e) => {
+ e.stopPropagation();
+ startRename(tab);
+ }}
+ >
+ {tab.title}
+ </span>
+ {/if}
+ {#if statusFor?.(tab.conversationId) === "active"}
+ <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span>
+ {/if}
+ <button
+ class="btn btn-ghost btn-xs shrink-0"
+ aria-label="Close tab"
+ onclick={(e) => {
+ e.stopPropagation();
+ onClose(tab.conversationId);
+ }}
+ >
+ &times;
+ </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>
</div>