summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/tabs')
-rw-r--r--src/features/tabs/index.ts2
-rw-r--r--src/features/tabs/tabs.test.ts33
-rw-r--r--src/features/tabs/tabs.ts19
-rw-r--r--src/features/tabs/ui.test.ts35
-rw-r--r--src/features/tabs/ui/TabBar.svelte27
5 files changed, 110 insertions, 6 deletions
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index 835788a..50de62a 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -5,10 +5,12 @@ export {
createTab,
deriveTitle,
initialState,
+ MIN_HANDLE_LENGTH,
newDraft,
selectTab,
setModel,
setTitle,
+ shortHandle,
} from "./tabs";
export type { TabsStorage, TabsStore } from "./tabs-store.svelte";
export { createTabsStore } from "./tabs-store.svelte";
diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts
index 5effa9b..3c2a8c2 100644
--- a/src/features/tabs/tabs.test.ts
+++ b/src/features/tabs/tabs.test.ts
@@ -7,10 +7,12 @@ import {
deriveTitle,
initialState,
isStuckToEnd,
+ MIN_HANDLE_LENGTH,
newDraft,
selectTab,
setModel,
setTitle,
+ shortHandle,
} from "./tabs";
const tab = (conversationId: string, model = "default", title = "Chat"): Tab => ({
@@ -213,3 +215,34 @@ describe("isStuckToEnd", () => {
expect(isStuckToEnd({ scrollLeft: 499, clientWidth: 500, scrollWidth: 1000 })).toBe(false);
});
});
+
+describe("shortHandle", () => {
+ it("uses the minimum length when the id is unique", () => {
+ const h = shortHandle("3f9a1b2c-aaaa", ["3f9a1b2c-aaaa", "7c2d-bbbb"]);
+ expect(h).toBe("3f9a");
+ expect(h.length).toBe(MIN_HANDLE_LENGTH);
+ });
+
+ it("grows the prefix until unique among open tabs", () => {
+ // two ids share the first 5 chars → handle grows to 6 to disambiguate
+ expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde1");
+ expect(shortHandle("abcde2-yyyy", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde2");
+ });
+
+ it("shrinks back to the minimum when the colliding sibling is gone", () => {
+ expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx"])).toBe("abcd");
+ });
+
+ it("ignores the id itself when present in the list", () => {
+ expect(shortHandle("deadbeef", ["deadbeef"])).toBe("dead");
+ });
+
+ it("returns the whole id when shorter than the minimum length", () => {
+ expect(shortHandle("ab", ["ab", "cd"])).toBe("ab");
+ });
+
+ it("falls back to the full id when one id is a prefix of another", () => {
+ // "abcd" is a prefix of "abcd1234" → no unique shorter prefix exists for it
+ expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd");
+ });
+});
diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts
index a4db6f3..61ae58e 100644
--- a/src/features/tabs/tabs.ts
+++ b/src/features/tabs/tabs.ts
@@ -92,3 +92,22 @@ export function deriveTitle(message: string, max: number = DEFAULT_MAX_TITLE_LEN
if (trimmed.length <= max) return trimmed;
return `${trimmed.slice(0, max)}\u2026`;
}
+
+/** Minimum length of a tab handle (git-style short id). */
+export const MIN_HANDLE_LENGTH = 4;
+
+/**
+ * The short "handle" shown on a tab: the shortest prefix of `conversationId`
+ * (at least `MIN_HANDLE_LENGTH` chars) that is unique among all open tabs — a
+ * git-style short id. Grows by a char only when another open tab shares the
+ * prefix, and shrinks back when that sibling closes. Pure: the id + every open
+ * id in, the handle string out. (`allIds` may include `conversationId` itself.)
+ */
+export function shortHandle(conversationId: string, allIds: readonly string[]): string {
+ const others = allIds.filter((id) => id !== conversationId);
+ for (let len = MIN_HANDLE_LENGTH; len < conversationId.length; len++) {
+ const candidate = conversationId.slice(0, len);
+ if (!others.some((id) => id.startsWith(candidate))) return candidate;
+ }
+ return conversationId;
+}
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index 1ae18c8..6cd66bd 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -175,4 +175,39 @@ describe("TabBar", () => {
const newChat = screen.getByRole("button", { name: "New chat" });
expect(newChat).not.toHaveTextContent("New Chat");
});
+
+ it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => {
+ const tabs: readonly Tab[] = [
+ { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha" },
+ { conversationId: "7c2db4e5-2222", model: "m", title: "Beta" },
+ ];
+ render(TabBar, {
+ props: {
+ tabs,
+ activeConversationId: "3f9a1b2c-1111",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ expect(screen.getByText("3f9a")).toBeInTheDocument();
+ expect(screen.getByText("7c2d")).toBeInTheDocument();
+ });
+
+ it("renders fixed-width tabs", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ for (const t of screen.getAllByRole("tab")) {
+ expect(t).toHaveClass("w-48");
+ }
+ });
});
diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte
index eb5b5e5..812a663 100644
--- a/src/features/tabs/ui/TabBar.svelte
+++ b/src/features/tabs/ui/TabBar.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Tab } from "../tabs";
- import { isStuckToEnd } from "../tabs";
+ import { isStuckToEnd, shortHandle } from "../tabs";
let {
tabs,
@@ -23,6 +23,15 @@
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;
+ });
+
function recompute(): void {
const el = scrollEl;
if (el === undefined) {
@@ -55,11 +64,11 @@
});
</script>
-<div bind:this={scrollEl} class="overflow-x-auto border-b border-base-300">
- <div class="tabs tabs-border min-w-max">
+<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"
+ class="tab flex w-48 shrink-0 items-center gap-1.5"
class:tab-active={tab.conversationId === activeConversationId}
role="tab"
tabindex="0"
@@ -68,9 +77,15 @@
if (e.key === "Enter") onSelect(tab.conversationId);
}}
>
- <span class="max-w-[120px] truncate">{tab.title}</span>
+ <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>
+ <span class="min-w-0 flex-1 truncate text-left">{tab.title}</span>
<button
- class="btn btn-ghost btn-xs ml-1"
+ class="btn btn-ghost btn-xs shrink-0"
aria-label="Close tab"
onclick={(e) => {
e.stopPropagation();