summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 18:26:29 +0900
committerAdam Malczewski <[email protected]>2026-06-27 18:26:29 +0900
commite81df4c4936549fa674584c076e9e59fa4c920d5 (patch)
treed01ee2c17e069a5d05528d27fafd7a9de23e2801 /src/features
parentf79e3fcd846f24582ea8c536cf55f1f72d75d967 (diff)
parent5bd0b63a7f0cafd53fbd4ba287f8e2cf2b59a4a1 (diff)
downloaddispatch-web-e81df4c4936549fa674584c076e9e59fa4c920d5.tar.gz
dispatch-web-e81df4c4936549fa674584c076e9e59fa4c920d5.zip
Merge branch 'dev' into feature/provider-concurrency
Diffstat (limited to 'src/features')
-rw-r--r--src/features/chat/ui/ChatView.svelte2
-rw-r--r--src/features/chat/ui/Composer.svelte2
-rw-r--r--src/features/tabs/index.ts2
-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.ts154
-rw-r--r--src/features/tabs/ui/TabBar.svelte177
-rw-r--r--src/features/tabs/ui/TabList.svelte177
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte11
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts14
10 files changed, 303 insertions, 280 deletions
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index e72f639..b49f7b4 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -210,7 +210,7 @@
{/if}
{/snippet}
-<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
+<div class="flex flex-col gap-2 pt-4 pb-4 pl-6" role="log" aria-live="polite">
{#if hasEarlier && onShowEarlier}
<!-- Chat limit: older chunks are unloaded; offer to page them back in. -->
<div class="flex justify-center">
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index 96b4b3a..2f3d820 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -136,7 +136,7 @@
<div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50">
<span class="shrink-0">
{#if status === "running"}
- <span class="loading loading-spinner loading-xs text-primary"></span>
+ <span class="loading loading-dots loading-xs text-primary"></span>
{:else if status === "error"}
<svg
xmlns="http://www.w3.org/2000/svg"
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index 6ac90a3..7520215 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -14,7 +14,7 @@ export {
} from "./tabs";
export type { TabsStorage, TabsStore } from "./tabs-store.svelte";
export { createTabsStore } from "./tabs-store.svelte";
-export { default as TabBar } from "./ui/TabBar.svelte";
+export { default as TabList } from "./ui/TabList.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts
index c31d2e7..ec93076 100644
--- a/src/features/tabs/tabs.test.ts
+++ b/src/features/tabs/tabs.test.ts
@@ -6,7 +6,6 @@ import {
createTab,
deriveTitle,
initialState,
- isStuckToEnd,
MIN_HANDLE_LENGTH,
newDraft,
selectTab,
@@ -194,29 +193,6 @@ describe("deriveTitle", () => {
});
});
-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);
- });
-});
-
describe("shortHandle", () => {
it("uses the minimum length when the id is unique", () => {
const h = shortHandle("3f9a1b2c-aaaa", ["3f9a1b2c-aaaa", "7c2d-bbbb"]);
diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts
index bc7e30b..63cda35 100644
--- a/src/features/tabs/tabs.ts
+++ b/src/features/tabs/tabs.ts
@@ -87,26 +87,6 @@ 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 087b28c..9af4cd1 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -1,8 +1,8 @@
-import { render, screen } from "@testing-library/svelte";
+import { fireEvent, render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { Tab } from "./tabs";
-import TabBar from "./ui/TabBar.svelte";
+import TabList from "./ui/TabList.svelte";
const sampleTabs: readonly Tab[] = [
{ conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" },
@@ -10,9 +10,9 @@ const sampleTabs: readonly Tab[] = [
{ conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" },
];
-describe("TabBar", () => {
+describe("TabList", () => {
it("renders one role=tab element per tab showing each title", () => {
- render(TabBar, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
@@ -29,8 +29,8 @@ describe("TabBar", () => {
expect(tabs[2]).toHaveTextContent("Third");
});
- it("applies tab-active to the active tab only", () => {
- render(TabBar, {
+ it("marks the active tab as aria-selected", () => {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c2",
@@ -41,24 +41,9 @@ describe("TabBar", () => {
});
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");
+ expect(tabs[0]).toHaveAttribute("aria-selected", "false");
+ expect(tabs[1]).toHaveAttribute("aria-selected", "true");
+ expect(tabs[2]).toHaveAttribute("aria-selected", "false");
});
it("calls onSelect with the conversationId when a tab is clicked", async () => {
@@ -66,7 +51,7 @@ describe("TabBar", () => {
const onClose = vi.fn();
const user = userEvent.setup();
- render(TabBar, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
@@ -91,7 +76,7 @@ describe("TabBar", () => {
const onClose = vi.fn();
const user = userEvent.setup();
- render(TabBar, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
@@ -115,7 +100,7 @@ describe("TabBar", () => {
const onNewDraft = vi.fn();
const user = userEvent.setup();
- render(TabBar, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
@@ -131,23 +116,8 @@ describe("TabBar", () => {
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, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: null,
@@ -162,7 +132,7 @@ describe("TabBar", () => {
});
it("does not show 'New Chat' text when a real tab is active", () => {
- render(TabBar, {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
@@ -181,7 +151,7 @@ describe("TabBar", () => {
{ conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" },
{ conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" },
];
- render(TabBar, {
+ render(TabList, {
props: {
tabs,
activeConversationId: "3f9a1b2c-1111",
@@ -195,8 +165,8 @@ describe("TabBar", () => {
expect(screen.getByText("7c2d")).toBeInTheDocument();
});
- it("renders fixed-width tabs", () => {
- render(TabBar, {
+ it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => {
+ render(TabList, {
props: {
tabs: sampleTabs,
activeConversationId: "c1",
@@ -206,8 +176,92 @@ describe("TabBar", () => {
},
});
- for (const t of screen.getAllByRole("tab")) {
- expect(t).toHaveClass("w-48");
- }
+ // The scroll region containing the tab rows is a vertical flex column.
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs.length).toBeGreaterThan(0);
+ const region = tabs[0]?.parentElement;
+ expect(region).toHaveClass("flex-col");
+ });
+
+ it("caps the tab list region at 80vh so a long set scrolls internally", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ const region = tabs[0]?.parentElement;
+ expect(region).toHaveClass("max-h-[80vh]");
+ expect(region).toHaveClass("overflow-y-auto");
+ });
+
+ it("calls onRename when a tab title is double-clicked and committed with Enter", async () => {
+ const onRename = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onRename,
+ },
+ });
+
+ const titleButtons = screen.getAllByRole("button");
+ // The inline-rename trigger is the title span (role=button) — find the one
+ // whose text matches the first tab's title.
+ const titleButton = titleButtons.find((b) => b.textContent === "First");
+ if (!titleButton) throw new Error("title button not found");
+ await user.dblClick(titleButton);
+
+ const input = screen.getByRole("textbox");
+ await user.clear(input);
+ await user.type(input, "Renamed{Enter}");
+
+ expect(onRename).toHaveBeenCalledTimes(1);
+ expect(onRename).toHaveBeenCalledWith("c1", "Renamed");
+ });
+
+ it("copies the conversation id to the clipboard and highlights the badge text when clicked", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText },
+ configurable: true,
+ });
+
+ const onSelect = vi.fn();
+
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect,
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const idBadge = screen.getByRole("button", { name: "Copy conversation id c1" });
+ await fireEvent.click(idBadge);
+
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(writeText).toHaveBeenCalledWith("c1");
+ // The badge text is NOT swapped (no width shift) — the highlight (selection)
+ // is the only indicator.
+ expect(idBadge).toHaveTextContent("c1");
+ expect(idBadge).not.toHaveTextContent("Copied");
+ // The badge text is selected (highlighted) as the copy indicator.
+ const selection = window.getSelection();
+ expect(selection?.toString()).toBe("c1");
+ // Clicking the ID must NOT switch tabs (the badge stops propagation).
+ expect(onSelect).not.toHaveBeenCalled();
});
});
diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte
deleted file mode 100644
index 211fd5c..0000000
--- a/src/features/tabs/ui/TabBar.svelte
+++ /dev/null
@@ -1,177 +0,0 @@
-<script lang="ts">
- 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();
-
- // 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;
- });
-
- 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();
- };
- });
- // 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 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;
- }
-</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>
diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte
new file mode 100644
index 0000000..b1190e4
--- /dev/null
+++ b/src/features/tabs/ui/TabList.svelte
@@ -0,0 +1,177 @@
+<script lang="ts">
+ import type { Tab } from "../tabs";
+ import { 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();
+
+ // 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;
+ });
+
+ // 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 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;
+ }
+
+ // Click-to-copy the agent (conversation) id: clicking the ID badge copies the
+ // FULL conversationId to the clipboard (the stable, useful id — the badge
+ // only shows a short prefix) and highlights the badge text as feedback. The
+ // highlight (text selection) is the indicator — no text is swapped, so the
+ // badge keeps a stable width. If the clipboard write fails, the selection is
+ // already in place so the user can Ctrl+C the text manually.
+ async function copyId(conversationId: string, el: HTMLElement): Promise<void> {
+ selectText(el);
+ const clipboard = navigator.clipboard;
+ if (clipboard === undefined) return;
+ try {
+ await clipboard.writeText(conversationId);
+ } catch {
+ // Selection already lets the user copy manually.
+ }
+ }
+
+ function selectText(el: HTMLElement): void {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ }
+</script>
+
+<div class="flex flex-col gap-2">
+ <!-- Single-column vertical tab list. Capped at 80% of the viewport height
+ so a long tab set scrolls inside this region instead of growing the
+ whole sidebar. -->
+ <div class="flex max-h-[80vh] flex-col gap-1 overflow-y-auto pr-1">
+ {#each tabs as tab (tab.conversationId)}
+ <div
+ class="flex items-center gap-1.5 rounded px-2 py-1.5 text-sm hover:bg-base-200"
+ class:bg-base-300={tab.conversationId === activeConversationId}
+ role="tab"
+ tabindex="0"
+ aria-selected={tab.conversationId === activeConversationId}
+ title={tab.title}
+ onclick={() => onSelect(tab.conversationId)}
+ onkeydown={(e) => {
+ if (e.key === "Enter") onSelect(tab.conversationId);
+ }}
+ >
+ <button
+ type="button"
+ class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content"
+ data-copy-id={tab.conversationId}
+ title="Click to copy conversation id"
+ aria-label={`Copy conversation id ${tab.conversationId}`}
+ onclick={(e) => {
+ e.stopPropagation();
+ void copyId(tab.conversationId, e.currentTarget);
+ }}
+ >
+ {handles.get(tab.conversationId) ?? tab.conversationId}
+ </button>
+ {#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-dots 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}
+ </div>
+
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm w-full border border-base-300"
+ class:btn-primary={activeConversationId === null}
+ aria-label="New chat"
+ onclick={() => onNewDraft()}
+ >
+ {#if activeConversationId === null}
+ New Chat
+ {:else}
+ + New chat
+ {/if}
+ </button>
+</div>
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
index 81b89e9..0ffc975 100644
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -188,7 +188,16 @@
</div>
<div class="flex justify-start">
- <a class="btn" href={workspacePath(ws.id)} target="_blank" rel="noopener noreferrer"> Open </a>
+ <a
+ class="btn"
+ href={workspacePath(ws.id)}
+ onclick={(e) => {
+ e.preventDefault();
+ onNavigate(workspacePath(ws.id));
+ }}
+ >
+ Open
+ </a>
</div>
{#if cwdError}
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
index 0736efb..0d03b8e 100644
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -119,16 +119,20 @@ describe("WorkspaceCard", () => {
expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null);
});
- it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => {
+ it("the Open link navigates to the workspace in the same tab (SPA navigation, no new tab)", async () => {
+ const user = userEvent.setup();
const store = fakeStore() as unknown as WorkspaceStore;
const onNavigate = vi.fn();
render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } });
const open = screen.getByRole("link", { name: "Open" });
- // Native new-tab link: href points at the workspace, and target="_blank"
- // opens it in a new browser tab rather than client-side navigating.
+ // Still a real link (progressive enhancement): href points at the workspace.
expect(open).toHaveAttribute("href", "/my-ws");
- expect(open).toHaveAttribute("target", "_blank");
- expect(open.getAttribute("rel") ?? "").toMatch(/noopener/);
+ // But it no longer opens a new browser tab.
+ expect(open).not.toHaveAttribute("target", "_blank");
+ // Clicking navigates in-place via the SPA callback.
+ await user.click(open);
+ expect(onNavigate).toHaveBeenCalledTimes(1);
+ expect(onNavigate).toHaveBeenCalledWith("/my-ws");
});
});