summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 17:23:40 +0900
committerAdam Malczewski <[email protected]>2026-06-27 17:23:40 +0900
commit5bd0b63a7f0cafd53fbd4ba287f8e2cf2b59a4a1 (patch)
treeba0a6fb8ffa84c3fc86c250f8309018c79f74682 /src
parente1f6986c32767fea445eedffece488f10a73b3d2 (diff)
downloaddispatch-web-5bd0b63a7f0cafd53fbd4ba287f8e2cf2b59a4a1.tar.gz
dispatch-web-5bd0b63a7f0cafd53fbd4ba287f8e2cf2b59a4a1.zip
feat(sidebar-tabs): add dashboard home button + same-tab workspace open
Diffstat (limited to 'src')
-rw-r--r--src/App.svelte2
-rw-r--r--src/app/App.svelte28
-rw-r--r--src/app/App.test.ts50
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte11
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts14
5 files changed, 83 insertions, 22 deletions
diff --git a/src/App.svelte b/src/App.svelte
index 0dbc958..713c844 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -70,5 +70,5 @@
{#if route.kind === "home"}
<WorkspacesHome store={workspaceStore} onNavigate={navigate} computers={store.computers} />
{:else}
- <App {store} />
+ <App {store} onNavigate={navigate} />
{/if}
diff --git a/src/app/App.svelte b/src/app/App.svelte
index f8e6e5f..5822725 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -82,7 +82,7 @@
import { createLocalStore } from "../adapters/local-storage";
import { untrack } from "svelte";
- let { store }: { store: AppStore } = $props();
+ let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props();
// The backend's conversation-scoped cache-warming surface. Referenced by id at
// the composition root (sanctioned discovery-by-id) to give it a dedicated view
@@ -445,6 +445,32 @@
>
{topBarTitle}
</span>
+ <a
+ href="/"
+ class="btn btn-ghost btn-sm shrink-0 px-2"
+ aria-label="Back to dashboard"
+ title="Back to dashboard"
+ onclick={(e) => {
+ e.preventDefault();
+ onNavigate("/");
+ }}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25"
+ />
+ </svg>
+ </a>
<span
class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
title="Build version (git short hash)"
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 3d6e70c..dcdcb9b 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -2,7 +2,7 @@ import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contrac
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
import App from "./App.svelte";
import { createAppStore } from "./store.svelte";
@@ -127,7 +127,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument();
@@ -145,7 +145,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
expect(store.activeConversationId).toBeNull();
expect(screen.getByTestId("top-bar-title")).toHaveTextContent("New Tab");
@@ -162,7 +162,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
// Promote draft → tab: the tab's title is derived from the first message.
store.send("Refactor the auth module");
@@ -176,6 +176,28 @@ describe("App component interaction tests", () => {
store.dispose();
});
+ it("the dashboard home button navigates to '/' (back to the workspaces home)", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const onNavigate = vi.fn();
+ render(App, { props: { store, onNavigate } });
+
+ const homeBtn = screen.getByRole("link", { name: "Back to dashboard" });
+ expect(homeBtn).toHaveAttribute("href", "/");
+ await userEvent.setup().click(homeBtn);
+
+ expect(onNavigate).toHaveBeenCalledTimes(1);
+ expect(onNavigate).toHaveBeenCalledWith("/");
+
+ store.dispose();
+ });
+
it("auto-subscribes to every catalog entry on render (no buttons to click)", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -194,7 +216,7 @@ describe("App component interaction tests", () => {
],
});
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
const subscribed = sentMessages(ws)
.filter((m: { type: string }) => m.type === "subscribe")
@@ -222,7 +244,7 @@ describe("App component interaction tests", () => {
],
});
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
// No interaction: specs arrive and both surfaces render expanded.
ws.feedSurfaceMessage({
@@ -261,7 +283,7 @@ describe("App component interaction tests", () => {
message: "Something went wrong",
});
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("Something went wrong");
@@ -283,7 +305,7 @@ describe("App component interaction tests", () => {
catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
});
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
const user = userEvent.setup();
// Surface is auto-subscribed; its spec arrives and renders expanded.
@@ -330,7 +352,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
const user = userEvent.setup();
const textarea = screen.getByRole("textbox", { name: "Message input" });
@@ -363,7 +385,7 @@ describe("App component interaction tests", () => {
store.send("test");
const convId = activeConversationId(store);
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
ws.feedServerMessage({
type: "chat.delta",
@@ -403,7 +425,7 @@ describe("App component interaction tests", () => {
catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
});
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
// Auto-subscribed; the custom-table spec arrives and renders expanded.
ws.feedSurfaceMessage({
@@ -441,7 +463,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
// Extensions view is pre-populated in the fake storage, so the modules table renders immediately.
expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument();
@@ -491,7 +513,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
// The modal should appear with the error text
const dialog = await screen.findByRole("dialog", { name: "Error" }, { timeout: 3000 });
@@ -517,7 +539,7 @@ describe("App component interaction tests", () => {
});
ws.resolveOpen();
- render(App, { props: { store } });
+ render(App, { props: { store, onNavigate: vi.fn() } });
// Wait a tick for boot async to settle
await new Promise((resolve) => setTimeout(resolve, 200));
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");
});
});