diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 17:23:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 17:23:40 +0900 |
| commit | 5bd0b63a7f0cafd53fbd4ba287f8e2cf2b59a4a1 (patch) | |
| tree | ba0a6fb8ffa84c3fc86c250f8309018c79f74682 | |
| parent | e1f6986c32767fea445eedffece488f10a73b3d2 (diff) | |
| download | dispatch-web-5bd0b63a7f0cafd53fbd4ba287f8e2cf2b59a4a1.tar.gz dispatch-web-5bd0b63a7f0cafd53fbd4ba287f8e2cf2b59a4a1.zip | |
feat(sidebar-tabs): add dashboard home button + same-tab workspace open
| -rw-r--r-- | src/App.svelte | 2 | ||||
| -rw-r--r-- | src/app/App.svelte | 28 | ||||
| -rw-r--r-- | src/app/App.test.ts | 50 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.svelte | 11 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceCard.test.ts | 14 |
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"); }); }); |
