summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 22:21:55 +0900
committerAdam Malczewski <[email protected]>2026-06-26 22:23:39 +0900
commitc333fcec32b1f90bf0da6bb14d2609c20e38a74f (patch)
tree0db3ec77a6838c4f800c362df0de3c6cd8544431 /src/features/tabs
parent1285564f12238b22f6b39b9f3fbcecaca8456911 (diff)
downloaddispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.tar.gz
dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.zip
style: switch from tabs to 2-space indentation (incl. svelte)
Diffstat (limited to 'src/features/tabs')
-rw-r--r--src/features/tabs/index.ts26
-rw-r--r--src/features/tabs/tabs-store.svelte.ts118
-rw-r--r--src/features/tabs/tabs-store.test.ts282
-rw-r--r--src/features/tabs/tabs.test.ts418
-rw-r--r--src/features/tabs/tabs.ts120
-rw-r--r--src/features/tabs/ui.test.ts404
-rw-r--r--src/features/tabs/ui/TabBar.svelte325
7 files changed, 846 insertions, 847 deletions
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index 699c845..6ac90a3 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -1,16 +1,16 @@
export type { Tab, TabsState } from "./tabs";
export {
- activeTab,
- closeTab,
- createTab,
- deriveTitle,
- initialState,
- MIN_HANDLE_LENGTH,
- newDraft,
- selectTab,
- setModel,
- setTitle,
- shortHandle,
+ activeTab,
+ closeTab,
+ 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";
@@ -18,6 +18,6 @@ export { default as TabBar } from "./ui/TabBar.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "tabs",
- description: "Conversation tabs with title derivation and persistence",
+ name: "tabs",
+ description: "Conversation tabs with title derivation and persistence",
} as const;
diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts
index 2e876f9..d044b1f 100644
--- a/src/features/tabs/tabs-store.svelte.ts
+++ b/src/features/tabs/tabs-store.svelte.ts
@@ -1,73 +1,73 @@
import type { Tab, TabsState } from "./tabs";
import {
- initialState,
- closeTab as reduceCloseTab,
- createTab as reduceCreateTab,
- newDraft as reduceNewDraft,
- openTab as reduceOpenTab,
- selectTab as reduceSelectTab,
- setModel as reduceSetModel,
- setTitle as reduceSetTitle,
- activeTab as selectActiveTab,
+ initialState,
+ closeTab as reduceCloseTab,
+ createTab as reduceCreateTab,
+ newDraft as reduceNewDraft,
+ openTab as reduceOpenTab,
+ selectTab as reduceSelectTab,
+ setModel as reduceSetModel,
+ setTitle as reduceSetTitle,
+ activeTab as selectActiveTab,
} from "./tabs";
export interface TabsStorage {
- load(): TabsState | null;
- save(state: TabsState): void;
+ load(): TabsState | null;
+ save(state: TabsState): void;
}
export interface TabsStore {
- readonly tabs: readonly Tab[];
- readonly activeConversationId: string | null;
- readonly activeTab: Tab | null;
- newDraft(): void;
- createTab(tab: Tab): void;
- /** Add a tab WITHOUT focusing it (for `conversation.open`). No-op if already open. */
- openTab(tab: Tab): void;
- selectTab(conversationId: string): void;
- closeTab(conversationId: string): void;
- setModel(conversationId: string, model: string): void;
- setTitle(conversationId: string, title: string): void;
+ readonly tabs: readonly Tab[];
+ readonly activeConversationId: string | null;
+ readonly activeTab: Tab | null;
+ newDraft(): void;
+ createTab(tab: Tab): void;
+ /** Add a tab WITHOUT focusing it (for `conversation.open`). No-op if already open. */
+ openTab(tab: Tab): void;
+ selectTab(conversationId: string): void;
+ closeTab(conversationId: string): void;
+ setModel(conversationId: string, model: string): void;
+ setTitle(conversationId: string, title: string): void;
}
export function createTabsStore(storage: TabsStorage): TabsStore {
- let state = $state<TabsState>(storage.load() ?? initialState());
+ let state = $state<TabsState>(storage.load() ?? initialState());
- function apply(next: TabsState): void {
- state = next;
- storage.save(next);
- }
+ function apply(next: TabsState): void {
+ state = next;
+ storage.save(next);
+ }
- return {
- get tabs(): readonly Tab[] {
- return state.tabs;
- },
- get activeConversationId(): string | null {
- return state.activeConversationId;
- },
- get activeTab(): Tab | null {
- return selectActiveTab(state);
- },
- newDraft(): void {
- apply(reduceNewDraft(state));
- },
- createTab(tab: Tab): void {
- apply(reduceCreateTab(state, tab));
- },
- openTab(tab: Tab): void {
- apply(reduceOpenTab(state, tab));
- },
- selectTab(conversationId: string): void {
- apply(reduceSelectTab(state, conversationId));
- },
- closeTab(conversationId: string): void {
- apply(reduceCloseTab(state, conversationId));
- },
- setModel(conversationId: string, model: string): void {
- apply(reduceSetModel(state, conversationId, model));
- },
- setTitle(conversationId: string, title: string): void {
- apply(reduceSetTitle(state, conversationId, title));
- },
- };
+ return {
+ get tabs(): readonly Tab[] {
+ return state.tabs;
+ },
+ get activeConversationId(): string | null {
+ return state.activeConversationId;
+ },
+ get activeTab(): Tab | null {
+ return selectActiveTab(state);
+ },
+ newDraft(): void {
+ apply(reduceNewDraft(state));
+ },
+ createTab(tab: Tab): void {
+ apply(reduceCreateTab(state, tab));
+ },
+ openTab(tab: Tab): void {
+ apply(reduceOpenTab(state, tab));
+ },
+ selectTab(conversationId: string): void {
+ apply(reduceSelectTab(state, conversationId));
+ },
+ closeTab(conversationId: string): void {
+ apply(reduceCloseTab(state, conversationId));
+ },
+ setModel(conversationId: string, model: string): void {
+ apply(reduceSetModel(state, conversationId, model));
+ },
+ setTitle(conversationId: string, title: string): void {
+ apply(reduceSetTitle(state, conversationId, title));
+ },
+ };
}
diff --git a/src/features/tabs/tabs-store.test.ts b/src/features/tabs/tabs-store.test.ts
index 71a38dc..bb4df98 100644
--- a/src/features/tabs/tabs-store.test.ts
+++ b/src/features/tabs/tabs-store.test.ts
@@ -4,154 +4,154 @@ import type { TabsStorage } from "./tabs-store.svelte";
import { createTabsStore } from "./tabs-store.svelte";
function createMemoryStorage(initial?: TabsState): TabsStorage & { data: TabsState | null } {
- let data: TabsState | null = initial ?? null;
- return {
- get data() {
- return data;
- },
- set data(v: TabsState | null) {
- data = v;
- },
- load() {
- return data;
- },
- save(state: TabsState) {
- data = state;
- },
- };
+ let data: TabsState | null = initial ?? null;
+ return {
+ get data() {
+ return data;
+ },
+ set data(v: TabsState | null) {
+ data = v;
+ },
+ load() {
+ return data;
+ },
+ save(state: TabsState) {
+ data = state;
+ },
+ };
}
describe("createTabsStore", () => {
- it("loads persisted state on construct", () => {
- const persisted: TabsState = {
- tabs: [{ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }],
- activeConversationId: "c1",
- };
- const storage = createMemoryStorage(persisted);
- const store = createTabsStore(storage);
-
- expect(store.tabs).toHaveLength(1);
- expect(store.activeConversationId).toBe("c1");
- expect(store.activeTab?.conversationId).toBe("c1");
- });
-
- it("starts with empty draft when no persisted state", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- expect(store.tabs).toHaveLength(0);
- expect(store.activeConversationId).toBeNull();
- expect(store.activeTab).toBeNull();
- });
-
- it("saves after every mutation", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- expect(storage.data?.tabs).toHaveLength(1);
- expect(storage.data?.activeConversationId).toBe("c1");
-
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
- expect(storage.data?.tabs).toHaveLength(2);
-
- store.selectTab("c1");
- expect(storage.data?.activeConversationId).toBe("c1");
-
- store.closeTab("c1");
- expect(storage.data?.tabs).toHaveLength(1);
- expect(storage.data?.activeConversationId).toBe("c2");
-
- store.setModel("c2", "new-model");
- expect(storage.data?.tabs[0]?.model).toBe("new-model");
-
- store.setTitle("c2", "New Title");
- expect(storage.data?.tabs[0]?.title).toBe("New Title");
-
- store.newDraft();
- expect(storage.data?.activeConversationId).toBeNull();
- });
-
- it("createTab appends and activates", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- expect(store.tabs).toHaveLength(1);
- expect(store.activeConversationId).toBe("c1");
-
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
- expect(store.tabs).toHaveLength(2);
- expect(store.activeConversationId).toBe("c2");
- });
-
- it("selectTab changes active", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
-
- store.selectTab("c1");
- expect(store.activeConversationId).toBe("c1");
- });
-
- it("closeTab removes and activates neighbour", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
- store.createTab({ conversationId: "c3", model: "m3", title: "T3", workspaceId: "default" });
-
- store.selectTab("c2");
- store.closeTab("c2");
- expect(store.tabs).toHaveLength(2);
- expect(store.activeConversationId).toBe("c1");
- });
-
- it("closing the last tab returns to draft", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- store.closeTab("c1");
- expect(store.tabs).toHaveLength(0);
- expect(store.activeConversationId).toBeNull();
- });
-
- it("setModel updates the right tab", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "old", title: "T1", workspaceId: "default" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
-
- store.setModel("c1", "new-model");
- expect(store.tabs[0]?.model).toBe("new-model");
- expect(store.tabs[1]?.model).toBe("m2");
- });
+ it("loads persisted state on construct", () => {
+ const persisted: TabsState = {
+ tabs: [{ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }],
+ activeConversationId: "c1",
+ };
+ const storage = createMemoryStorage(persisted);
+ const store = createTabsStore(storage);
+
+ expect(store.tabs).toHaveLength(1);
+ expect(store.activeConversationId).toBe("c1");
+ expect(store.activeTab?.conversationId).toBe("c1");
+ });
+
+ it("starts with empty draft when no persisted state", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+ expect(store.activeTab).toBeNull();
+ });
+
+ it("saves after every mutation", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ expect(storage.data?.tabs).toHaveLength(1);
+ expect(storage.data?.activeConversationId).toBe("c1");
+
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+ expect(storage.data?.tabs).toHaveLength(2);
+
+ store.selectTab("c1");
+ expect(storage.data?.activeConversationId).toBe("c1");
+
+ store.closeTab("c1");
+ expect(storage.data?.tabs).toHaveLength(1);
+ expect(storage.data?.activeConversationId).toBe("c2");
+
+ store.setModel("c2", "new-model");
+ expect(storage.data?.tabs[0]?.model).toBe("new-model");
+
+ store.setTitle("c2", "New Title");
+ expect(storage.data?.tabs[0]?.title).toBe("New Title");
+
+ store.newDraft();
+ expect(storage.data?.activeConversationId).toBeNull();
+ });
+
+ it("createTab appends and activates", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ expect(store.tabs).toHaveLength(1);
+ expect(store.activeConversationId).toBe("c1");
+
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+ expect(store.tabs).toHaveLength(2);
+ expect(store.activeConversationId).toBe("c2");
+ });
+
+ it("selectTab changes active", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+
+ store.selectTab("c1");
+ expect(store.activeConversationId).toBe("c1");
+ });
+
+ it("closeTab removes and activates neighbour", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+ store.createTab({ conversationId: "c3", model: "m3", title: "T3", workspaceId: "default" });
+
+ store.selectTab("c2");
+ store.closeTab("c2");
+ expect(store.tabs).toHaveLength(2);
+ expect(store.activeConversationId).toBe("c1");
+ });
+
+ it("closing the last tab returns to draft", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ store.closeTab("c1");
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+ });
+
+ it("setModel updates the right tab", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "old", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+
+ store.setModel("c1", "new-model");
+ expect(store.tabs[0]?.model).toBe("new-model");
+ expect(store.tabs[1]?.model).toBe("m2");
+ });
- it("setTitle updates the right tab", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
+ it("setTitle updates the right tab", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "m1", title: "Old", workspaceId: "default" });
+ store.createTab({ conversationId: "c1", model: "m1", title: "Old", workspaceId: "default" });
- store.setTitle("c1", "New Title");
- expect(store.tabs[0]?.title).toBe("New Title");
- });
+ store.setTitle("c1", "New Title");
+ expect(store.tabs[0]?.title).toBe("New Title");
+ });
- it("newDraft clears active but keeps tabs", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
+ it("newDraft clears active but keeps tabs", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
- store.newDraft();
- expect(store.tabs).toHaveLength(2);
- expect(store.activeConversationId).toBeNull();
- expect(store.activeTab).toBeNull();
- });
+ store.newDraft();
+ expect(store.tabs).toHaveLength(2);
+ expect(store.activeConversationId).toBeNull();
+ expect(store.activeTab).toBeNull();
+ });
});
diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts
index e3ca4fa..c31d2e7 100644
--- a/src/features/tabs/tabs.test.ts
+++ b/src/features/tabs/tabs.test.ts
@@ -1,249 +1,249 @@
import { describe, expect, it } from "vitest";
import type { Tab, TabsState } from "./tabs";
import {
- activeTab,
- closeTab,
- createTab,
- deriveTitle,
- initialState,
- isStuckToEnd,
- MIN_HANDLE_LENGTH,
- newDraft,
- selectTab,
- setModel,
- setTitle,
- shortHandle,
+ activeTab,
+ closeTab,
+ createTab,
+ deriveTitle,
+ initialState,
+ isStuckToEnd,
+ MIN_HANDLE_LENGTH,
+ newDraft,
+ selectTab,
+ setModel,
+ setTitle,
+ shortHandle,
} from "./tabs";
const tab = (conversationId: string, model = "default", title = "Chat"): Tab => ({
- conversationId,
- model,
- title,
- workspaceId: "default",
+ conversationId,
+ model,
+ title,
+ workspaceId: "default",
});
describe("initialState", () => {
- it("returns empty draft state when no persisted state", () => {
- const state = initialState();
- expect(state.tabs).toEqual([]);
- expect(state.activeConversationId).toBeNull();
- });
-
- it("returns persisted state when provided", () => {
- const persisted: TabsState = {
- tabs: [tab("c1")],
- activeConversationId: "c1",
- };
- const state = initialState(persisted);
- expect(state.tabs).toHaveLength(1);
- expect(state.activeConversationId).toBe("c1");
- });
+ it("returns empty draft state when no persisted state", () => {
+ const state = initialState();
+ expect(state.tabs).toEqual([]);
+ expect(state.activeConversationId).toBeNull();
+ });
+
+ it("returns persisted state when provided", () => {
+ const persisted: TabsState = {
+ tabs: [tab("c1")],
+ activeConversationId: "c1",
+ };
+ const state = initialState(persisted);
+ expect(state.tabs).toHaveLength(1);
+ expect(state.activeConversationId).toBe("c1");
+ });
});
describe("newDraft", () => {
- it("sets activeConversationId to null", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
- const next = newDraft(state);
- expect(next.activeConversationId).toBeNull();
- });
-
- it("keeps existing tabs", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
- const next = newDraft(state);
- expect(next.tabs).toHaveLength(2);
- });
+ it("sets activeConversationId to null", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = newDraft(state);
+ expect(next.activeConversationId).toBeNull();
+ });
+
+ it("keeps existing tabs", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = newDraft(state);
+ expect(next.tabs).toHaveLength(2);
+ });
});
describe("createTab", () => {
- it("appends and activates", () => {
- const state = initialState();
- const next = createTab(state, tab("c1"));
- expect(next.tabs).toHaveLength(1);
- expect(next.tabs[0]?.conversationId).toBe("c1");
- expect(next.activeConversationId).toBe("c1");
- });
-
- it("does not duplicate an existing conversationId", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
- const next = createTab(state, tab("c1"));
- expect(next.tabs).toHaveLength(1);
- });
-
- it("activates an already-existing tab when createTab is called again", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" };
- const next = createTab(state, tab("c1"));
- expect(next.activeConversationId).toBe("c1");
- });
+ it("appends and activates", () => {
+ const state = initialState();
+ const next = createTab(state, tab("c1"));
+ expect(next.tabs).toHaveLength(1);
+ expect(next.tabs[0]?.conversationId).toBe("c1");
+ expect(next.activeConversationId).toBe("c1");
+ });
+
+ it("does not duplicate an existing conversationId", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = createTab(state, tab("c1"));
+ expect(next.tabs).toHaveLength(1);
+ });
+
+ it("activates an already-existing tab when createTab is called again", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" };
+ const next = createTab(state, tab("c1"));
+ expect(next.activeConversationId).toBe("c1");
+ });
});
describe("selectTab", () => {
- it("changes active", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
- const next = selectTab(state, "c2");
- expect(next.activeConversationId).toBe("c2");
- });
+ it("changes active", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = selectTab(state, "c2");
+ expect(next.activeConversationId).toBe("c2");
+ });
});
describe("closeTab", () => {
- it("removes the tab", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
- const next = closeTab(state, "c2");
- expect(next.tabs).toHaveLength(1);
- expect(next.tabs[0]?.conversationId).toBe("c1");
- });
-
- it("closing the active tab activates a neighbour (previous preferred)", () => {
- const state: TabsState = {
- tabs: [tab("c1"), tab("c2"), tab("c3")],
- activeConversationId: "c2",
- };
- const next = closeTab(state, "c2");
- expect(next.activeConversationId).toBe("c1");
- });
-
- it("closing the first active tab activates the next", () => {
- const state: TabsState = {
- tabs: [tab("c1"), tab("c2"), tab("c3")],
- activeConversationId: "c1",
- };
- const next = closeTab(state, "c1");
- expect(next.activeConversationId).toBe("c2");
- });
-
- it("closing the last tab returns to draft (null active)", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
- const next = closeTab(state, "c1");
- expect(next.tabs).toHaveLength(0);
- expect(next.activeConversationId).toBeNull();
- });
-
- it("closing a non-active tab does not change active", () => {
- const state: TabsState = {
- tabs: [tab("c1"), tab("c2"), tab("c3")],
- activeConversationId: "c3",
- };
- const next = closeTab(state, "c1");
- expect(next.activeConversationId).toBe("c3");
- });
-
- it("closing a non-existent tab is a no-op", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
- const next = closeTab(state, "missing");
- expect(next).toEqual(state);
- });
+ it("removes the tab", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = closeTab(state, "c2");
+ expect(next.tabs).toHaveLength(1);
+ expect(next.tabs[0]?.conversationId).toBe("c1");
+ });
+
+ it("closing the active tab activates a neighbour (previous preferred)", () => {
+ const state: TabsState = {
+ tabs: [tab("c1"), tab("c2"), tab("c3")],
+ activeConversationId: "c2",
+ };
+ const next = closeTab(state, "c2");
+ expect(next.activeConversationId).toBe("c1");
+ });
+
+ it("closing the first active tab activates the next", () => {
+ const state: TabsState = {
+ tabs: [tab("c1"), tab("c2"), tab("c3")],
+ activeConversationId: "c1",
+ };
+ const next = closeTab(state, "c1");
+ expect(next.activeConversationId).toBe("c2");
+ });
+
+ it("closing the last tab returns to draft (null active)", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = closeTab(state, "c1");
+ expect(next.tabs).toHaveLength(0);
+ expect(next.activeConversationId).toBeNull();
+ });
+
+ it("closing a non-active tab does not change active", () => {
+ const state: TabsState = {
+ tabs: [tab("c1"), tab("c2"), tab("c3")],
+ activeConversationId: "c3",
+ };
+ const next = closeTab(state, "c1");
+ expect(next.activeConversationId).toBe("c3");
+ });
+
+ it("closing a non-existent tab is a no-op", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = closeTab(state, "missing");
+ expect(next).toEqual(state);
+ });
});
describe("setModel", () => {
- it("updates the right tab", () => {
- const state: TabsState = { tabs: [tab("c1", "old"), tab("c2")], activeConversationId: "c1" };
- const next = setModel(state, "c1", "new-model");
- expect(next.tabs[0]?.model).toBe("new-model");
- expect(next.tabs[1]?.model).toBe("default");
- });
+ it("updates the right tab", () => {
+ const state: TabsState = { tabs: [tab("c1", "old"), tab("c2")], activeConversationId: "c1" };
+ const next = setModel(state, "c1", "new-model");
+ expect(next.tabs[0]?.model).toBe("new-model");
+ expect(next.tabs[1]?.model).toBe("default");
+ });
});
describe("setTitle", () => {
- it("updates the right tab", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
- const next = setTitle(state, "c1", "Updated title");
- expect(next.tabs[0]?.title).toBe("Updated title");
- expect(next.tabs[1]?.title).toBe("Chat");
- });
+ it("updates the right tab", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = setTitle(state, "c1", "Updated title");
+ expect(next.tabs[0]?.title).toBe("Updated title");
+ expect(next.tabs[1]?.title).toBe("Chat");
+ });
});
describe("activeTab", () => {
- it("returns the active tab", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" };
- expect(activeTab(state)?.conversationId).toBe("c2");
- });
-
- it("returns null when activeConversationId is null", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: null };
- expect(activeTab(state)).toBeNull();
- });
-
- it("returns null when active tab is not found in tabs", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: "missing" };
- expect(activeTab(state)).toBeNull();
- });
+ it("returns the active tab", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" };
+ expect(activeTab(state)?.conversationId).toBe("c2");
+ });
+
+ it("returns null when activeConversationId is null", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: null };
+ expect(activeTab(state)).toBeNull();
+ });
+
+ it("returns null when active tab is not found in tabs", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "missing" };
+ expect(activeTab(state)).toBeNull();
+ });
});
describe("deriveTitle", () => {
- it("truncates long messages with ellipsis", () => {
- const msg = "This is a very long message that should be truncated at some point";
- expect(deriveTitle(msg, 20)).toBe("This is a very long \u2026");
- });
-
- it("returns full message when under max", () => {
- expect(deriveTitle("Short", 40)).toBe("Short");
- });
-
- it("collapses whitespace", () => {
- expect(deriveTitle(" hello world ")).toBe("hello world");
- });
-
- it("falls back to 'New chat' for empty input", () => {
- expect(deriveTitle("")).toBe("New chat");
- expect(deriveTitle(" ")).toBe("New chat");
- });
-
- it("uses default max of ~40 chars", () => {
- const msg = "a".repeat(50);
- const result = deriveTitle(msg);
- expect(result).toBe(`${"a".repeat(40)}\u2026`);
- });
+ it("truncates long messages with ellipsis", () => {
+ const msg = "This is a very long message that should be truncated at some point";
+ expect(deriveTitle(msg, 20)).toBe("This is a very long \u2026");
+ });
+
+ it("returns full message when under max", () => {
+ expect(deriveTitle("Short", 40)).toBe("Short");
+ });
+
+ it("collapses whitespace", () => {
+ expect(deriveTitle(" hello world ")).toBe("hello world");
+ });
+
+ it("falls back to 'New chat' for empty input", () => {
+ expect(deriveTitle("")).toBe("New chat");
+ expect(deriveTitle(" ")).toBe("New chat");
+ });
+
+ it("uses default max of ~40 chars", () => {
+ const msg = "a".repeat(50);
+ const result = deriveTitle(msg);
+ 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);
- });
+ 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);
+ });
});
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");
- });
+ 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 53eda78..bc7e30b 100644
--- a/src/features/tabs/tabs.ts
+++ b/src/features/tabs/tabs.ts
@@ -1,40 +1,40 @@
export interface Tab {
- readonly conversationId: string;
- readonly model: string;
- readonly title: string;
- /** The workspace this tab belongs to (the workspace's URL slug). */
- readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ readonly title: string;
+ /** The workspace this tab belongs to (the workspace's URL slug). */
+ readonly workspaceId: string;
}
export interface TabsState {
- readonly tabs: readonly Tab[];
- readonly activeConversationId: string | null;
+ readonly tabs: readonly Tab[];
+ readonly activeConversationId: string | null;
}
const DEFAULT_TITLE = "New chat";
const DEFAULT_MAX_TITLE_LENGTH = 40;
export function initialState(persisted?: TabsState): TabsState {
- if (persisted !== undefined) {
- // Migrate tabs persisted before workspaces: assign them to the "default"
- // workspace (the fallback for conversations with no workspace).
- const tabs = persisted.tabs.map((t) => {
- const wid = (t as { workspaceId?: string }).workspaceId;
- return { ...t, workspaceId: wid ?? "default" };
- });
- return { tabs, activeConversationId: persisted.activeConversationId };
- }
- return { tabs: [], activeConversationId: null };
+ if (persisted !== undefined) {
+ // Migrate tabs persisted before workspaces: assign them to the "default"
+ // workspace (the fallback for conversations with no workspace).
+ const tabs = persisted.tabs.map((t) => {
+ const wid = (t as { workspaceId?: string }).workspaceId;
+ return { ...t, workspaceId: wid ?? "default" };
+ });
+ return { tabs, activeConversationId: persisted.activeConversationId };
+ }
+ return { tabs: [], activeConversationId: null };
}
export function newDraft(state: TabsState): TabsState {
- return { ...state, activeConversationId: null };
+ return { ...state, activeConversationId: null };
}
export function createTab(state: TabsState, tab: Tab): TabsState {
- const exists = state.tabs.some((t) => t.conversationId === tab.conversationId);
- const tabs = exists ? state.tabs : [...state.tabs, tab];
- return { tabs, activeConversationId: tab.conversationId };
+ const exists = state.tabs.some((t) => t.conversationId === tab.conversationId);
+ const tabs = exists ? state.tabs : [...state.tabs, tab];
+ return { tabs, activeConversationId: tab.conversationId };
}
/**
@@ -43,54 +43,54 @@ export function createTab(state: TabsState, tab: Tab): TabsState {
* strip but the user stays on their current tab. No-op if already open.
*/
export function openTab(state: TabsState, tab: Tab): TabsState {
- const exists = state.tabs.some((t) => t.conversationId === tab.conversationId);
- if (exists) return state;
- return { tabs: [...state.tabs, tab], activeConversationId: state.activeConversationId };
+ const exists = state.tabs.some((t) => t.conversationId === tab.conversationId);
+ if (exists) return state;
+ return { tabs: [...state.tabs, tab], activeConversationId: state.activeConversationId };
}
export function selectTab(state: TabsState, conversationId: string): TabsState {
- return { ...state, activeConversationId: conversationId };
+ return { ...state, activeConversationId: conversationId };
}
export function closeTab(state: TabsState, conversationId: string): TabsState {
- const idx = state.tabs.findIndex((t) => t.conversationId === conversationId);
- if (idx === -1) return state;
+ const idx = state.tabs.findIndex((t) => t.conversationId === conversationId);
+ if (idx === -1) return state;
- const tabs = state.tabs.filter((t) => t.conversationId !== conversationId);
+ const tabs = state.tabs.filter((t) => t.conversationId !== conversationId);
- if (state.activeConversationId !== conversationId) {
- return { tabs, activeConversationId: state.activeConversationId };
- }
+ if (state.activeConversationId !== conversationId) {
+ return { tabs, activeConversationId: state.activeConversationId };
+ }
- if (tabs.length === 0) {
- return { tabs, activeConversationId: null };
- }
+ if (tabs.length === 0) {
+ return { tabs, activeConversationId: null };
+ }
- // prefer previous tab, else next
- const neighborIdx = idx > 0 ? idx - 1 : 0;
- const neighbor = tabs[neighborIdx];
- return { tabs, activeConversationId: neighbor?.conversationId ?? null };
+ // prefer previous tab, else next
+ const neighborIdx = idx > 0 ? idx - 1 : 0;
+ const neighbor = tabs[neighborIdx];
+ return { tabs, activeConversationId: neighbor?.conversationId ?? null };
}
export function setModel(state: TabsState, conversationId: string, model: string): TabsState {
- const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t));
- return { tabs, activeConversationId: state.activeConversationId };
+ const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t));
+ return { tabs, activeConversationId: state.activeConversationId };
}
export function setTitle(state: TabsState, conversationId: string, title: string): TabsState {
- const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, title } : t));
- return { tabs, activeConversationId: state.activeConversationId };
+ const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, title } : t));
+ return { tabs, activeConversationId: state.activeConversationId };
}
export function activeTab(state: TabsState): Tab | null {
- if (state.activeConversationId === null) return null;
- return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null;
+ if (state.activeConversationId === null) return null;
+ return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null;
}
export interface ScrollMetrics {
- readonly scrollLeft: number;
- readonly clientWidth: number;
- readonly scrollWidth: number;
+ readonly scrollLeft: number;
+ readonly clientWidth: number;
+ readonly scrollWidth: number;
}
const STUCK_EPSILON = 1;
@@ -102,16 +102,16 @@ const STUCK_EPSILON = 1;
* 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;
+ 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;
- if (trimmed.length <= max) return trimmed;
- return `${trimmed.slice(0, max)}\u2026`;
+ const trimmed = message.trim().replace(/\s+/g, " ");
+ if (trimmed.length === 0) return DEFAULT_TITLE;
+ if (trimmed.length <= max) return trimmed;
+ return `${trimmed.slice(0, max)}\u2026`;
}
/** Minimum length of a tab handle (git-style short id). */
@@ -125,10 +125,10 @@ export const MIN_HANDLE_LENGTH = 4;
* 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;
+ 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 a46b692..087b28c 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -5,209 +5,209 @@ import type { Tab } from "./tabs";
import TabBar from "./ui/TabBar.svelte";
const sampleTabs: readonly Tab[] = [
- { conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" },
- { conversationId: "c2", model: "anthropic/claude-3", title: "Second", workspaceId: "default" },
- { conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" },
+ { conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" },
+ { conversationId: "c2", model: "anthropic/claude-3", title: "Second", workspaceId: "default" },
+ { conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" },
];
describe("TabBar", () => {
- it("renders one role=tab element per tab showing each title", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const tabs = screen.getAllByRole("tab");
- expect(tabs).toHaveLength(sampleTabs.length);
- expect(tabs[0]).toHaveTextContent("First");
- expect(tabs[1]).toHaveTextContent("Second");
- expect(tabs[2]).toHaveTextContent("Third");
- });
-
- it("applies tab-active to the active tab only", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c2",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const tabs = screen.getAllByRole("tab");
- expect(tabs[0]).not.toHaveClass("tab-active");
- expect(tabs[1]).toHaveClass("tab-active");
- expect(tabs[2]).not.toHaveClass("tab-active");
- });
-
- it("applies tab-active to New chat button 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).toHaveClass("tab-active");
- });
-
- it("calls onSelect with the conversationId when a tab is clicked", async () => {
- const onSelect = vi.fn();
- const onClose = vi.fn();
- const user = userEvent.setup();
-
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect,
- onClose,
- onNewDraft: vi.fn(),
- },
- });
-
- const tabs = screen.getAllByRole("tab");
- const secondTab = tabs[1];
- if (!secondTab) throw new Error("second tab not found");
- await user.click(secondTab);
-
- expect(onSelect).toHaveBeenCalledTimes(1);
- expect(onSelect).toHaveBeenCalledWith("c2");
- expect(onClose).not.toHaveBeenCalled();
- });
-
- it("calls onClose when the close button is clicked and does not call onSelect", async () => {
- const onSelect = vi.fn();
- const onClose = vi.fn();
- const user = userEvent.setup();
-
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect,
- onClose,
- onNewDraft: vi.fn(),
- },
- });
-
- const closeButtons = screen.getAllByRole("button", { name: "Close tab" });
- const firstClose = closeButtons[0];
- if (!firstClose) throw new Error("first close button not found");
- await user.click(firstClose);
-
- expect(onClose).toHaveBeenCalledTimes(1);
- expect(onClose).toHaveBeenCalledWith("c1");
- expect(onSelect).not.toHaveBeenCalled();
- });
-
- it("calls onNewDraft when the New chat button is clicked", async () => {
- const onNewDraft = vi.fn();
- const user = userEvent.setup();
-
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft,
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- await user.click(newChat);
-
- expect(onNewDraft).toHaveBeenCalledTimes(1);
- });
-
- it("the New chat button has the sticky class", () => {
- 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).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");
- });
-
- it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => {
- const tabs: readonly Tab[] = [
- { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" },
- { conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" },
- ];
- 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");
- }
- });
+ it("renders one role=tab element per tab showing each title", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs).toHaveLength(sampleTabs.length);
+ expect(tabs[0]).toHaveTextContent("First");
+ expect(tabs[1]).toHaveTextContent("Second");
+ expect(tabs[2]).toHaveTextContent("Third");
+ });
+
+ it("applies tab-active to the active tab only", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c2",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs[0]).not.toHaveClass("tab-active");
+ expect(tabs[1]).toHaveClass("tab-active");
+ expect(tabs[2]).not.toHaveClass("tab-active");
+ });
+
+ it("applies tab-active to New chat button 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).toHaveClass("tab-active");
+ });
+
+ it("calls onSelect with the conversationId when a tab is clicked", async () => {
+ const onSelect = vi.fn();
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect,
+ onClose,
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ const secondTab = tabs[1];
+ if (!secondTab) throw new Error("second tab not found");
+ await user.click(secondTab);
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("c2");
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("calls onClose when the close button is clicked and does not call onSelect", async () => {
+ const onSelect = vi.fn();
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect,
+ onClose,
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const closeButtons = screen.getAllByRole("button", { name: "Close tab" });
+ const firstClose = closeButtons[0];
+ if (!firstClose) throw new Error("first close button not found");
+ await user.click(firstClose);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledWith("c1");
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("calls onNewDraft when the New chat button is clicked", async () => {
+ const onNewDraft = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft,
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ await user.click(newChat);
+
+ expect(onNewDraft).toHaveBeenCalledTimes(1);
+ });
+
+ it("the New chat button has the sticky class", () => {
+ 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).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");
+ });
+
+ it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => {
+ const tabs: readonly Tab[] = [
+ { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" },
+ { conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" },
+ ];
+ 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 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>