diff --git a/CLAUDE.md b/CLAUDE.md index 7a378bcc67..b62c28d3fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,6 @@ only routes to them. | Config fields that persist to TOML | `context/config-persistence.md` | | Database schema migrations or the SQLite connection layer | `context/db-migrations.md` | | Deferred merge behavior | `context/deferred-merge.md` | -| Embed routes or host bridges | `context/embeds.md` | | Daemon startup, discovery, host/origin validation, or SSE replay | `context/server-runtime.md` | | Fleet settings, snapshots, host routing, or peer transports | `context/fleet-architecture.md` | | API failures or frontend error branching | `context/error-handling.md` | diff --git a/context/embeds.md b/context/embeds.md deleted file mode 100644 index 4b6e232d3f..0000000000 --- a/context/embeds.md +++ /dev/null @@ -1,28 +0,0 @@ -# Embeds - -Use this document for embed routes, host-owned panels, the workspace bridge, or -embed-shell startup behavior. - -kenn-forge embed routes are intended to run in an isolated browser context, such -as an iframe, WebView, or host-owned panel that loads one kenn-forge document per -surface. They are not designed for multiple kenn-forge instances mounted into the -same `window`. - -The embed shell is intentionally smaller than the standalone app shell. It -initializes theme handling, the workspace bridge, the shared API provider, and -the single requested workspace surface. It does not run standalone startup work: -settings hydration, sync polling, pull/issue preloads, event-stream connection, -container/sidebar setup, or global keyboard shortcuts. - -- Workspace embeds hydrate terminal settings before mount and persist terminal-option - saves through the daemon settings API; embed mode must not leave an active Save control - as a silent no-op (`frontend/src/lib/components/settings/TerminalSettings.svelte::save`). - -Hosts communicate with embeds through the `window.__kenn_forge_*` bridge on that -isolated browsing context. Because the bridge and browser history are -document-global, callers that need more than one embed at a time should allocate -one iframe/WebView per embed instance rather than sharing a single document. - -- Default-browser launches belong to the client host, never the remote daemon; - Electron hosts must handle `window.open` requests with `webContents.setWindowOpenHandler`, - open HTTP(S) URLs via `shell.openExternal`, and deny creation of an embedded window. diff --git a/context/mobile-ux.md b/context/mobile-ux.md index ae55cd619e..5d5f2cb4b8 100644 --- a/context/mobile-ux.md +++ b/context/mobile-ux.md @@ -59,7 +59,7 @@ Viewport size chooses presentation, not route identity. Do not collapse these concepts: -- **Compact/narrow presentation**: a desktop window, split pane, or embedded surface that is too narrow for sidebars or dense desktop chrome. It can use focus presentation, but it should retain desktop-scale typography and desktop action geometry. +- **Compact/narrow presentation**: a desktop window or split pane that is too narrow for sidebars or dense desktop chrome. It can use focus presentation, but it should retain desktop-scale typography and desktop action geometry. - **Phone-like presentation**: a touch/mobile-user-agent context where larger mobile tokens, hit targets, and phone-specific action layouts are appropriate. A phone stays phone-like in landscape: a coarse-pointer, mobile-user-agent device keeps phone presentation up to the handheld landscape bound, while wider or single-signal devices stay desktop-narrow (`frontend/src/lib/utils/phone-presentation.ts::isPhoneLikeViewport`). diff --git a/context/platform-sync-invariants.md b/context/platform-sync-invariants.md index 94f69d4759..469869b3f3 100644 --- a/context/platform-sync-invariants.md +++ b/context/platform-sync-invariants.md @@ -513,11 +513,6 @@ Repository import requests and route/query shapes should carry (`internal/server/pullapi/helpers.go::visibleMergeRequest`, `internal/server/issueapi/mutation_handlers.go::requireVisibleIssue`, `internal/db/queries_stacks.go::ListStackPlacementsForMRs`) -- Embedded navigation events for repo-bound routes must publish identity from - parsed route state, not from global embed config. When a route carries repo - identity, event payloads should include `provider`, `platform_host`, and - `repo_path` and may keep `repo` as the display/canonical path. Global - `ui.repo` config is only a fallback for non-repo-bound pages. ## Testing diff --git a/context/ui-design-system.md b/context/ui-design-system.md index cf474ea6dc..e4a8af705f 100644 --- a/context/ui-design-system.md +++ b/context/ui-design-system.md @@ -1,5 +1,9 @@ # UI Design System +Desktop and mobile remain supported application experiences. External host embeds +have no consumers and are retired; keep shared detail and workspace views available +to the application. + Use this document as the intent-level guide for frontend UI work in `kenn-forge`. It should stay short, stable, and useful in model context. ## Purpose @@ -90,10 +94,8 @@ otherwise fails only in the Vitest/Playwright transform tier, not in `frontend/src/lib/components/terminal/agentHarness.ts::harnessForAgentKey`). The glyph only replaces the generic kind icon; the target's own label always stays. - Theme resolution: kit's theme store owns dark/light/system resolution - and persistence (`kenn-forge-theme` key); `theme.svelte.ts` adapts it. A - host-forced mode applies classes directly and never persists via - `setThemeMode`; an explicit user toggle persists even under a forced - mode. Relative timestamps use kit `formatRelativeTime`; + and persistence (`kenn-forge-theme` key); `theme.svelte.ts` adapts it. + Relative timestamps use kit `formatRelativeTime`; `parseAPITimestamp`/`localDate*Label` stay app-side. - Dialogs: every dialog pushes a keyboard modal-stack frame. Background Escape surfaces cannot detect dialogs via `defaultPrevented` (kit's @@ -113,7 +115,7 @@ otherwise fails only in the Vitest/Playwright transform tier, not in accent border only; `app.css` drops the kit `TextInput` outline ring that would otherwise stack on the same wrapper (every tap or Tab into a text field is `:focus-visible`). Hover-only controls nested inside a - list row button (`.star-btn`, `.import-btn`) carry `tabindex="-1"` so + list row button (`.star-btn`) carry `tabindex="-1"` so Tab moves row to row instead of stopping on an invisible target. - Tab strips are one tab stop: only the selected tab has `tabindex="0"`, Left/Right/Home/End move focus and selection via @@ -141,7 +143,7 @@ otherwise fails only in the Vitest/Playwright transform tier, not in `expandUsed` footprint and blocks re-expansion). Select tabs via `.kit-top-bar__tabs .kit-top-bar__tab`, never the bare class. Provider-mode repo selector visibility must not move the tab row; non-provider - modes reserve its footprint unless embed config hides it + modes reserve its footprint (`frontend/src/lib/components/layout/AppHeader.svelte::reserveProviderRepoSelectorSlot`). - `AdaptiveActionGrid`: the issue detail action row on every layout (`frontend/src/lib/components/detail/IssueDetail.svelte::issue-actions-grid`, @@ -509,7 +511,7 @@ Promise-required library callbacks may observe `AppExecution.exit`, but the comm When an `$effect` launches an Effect fiber, wrap `runCommand` itself in `untrack`; fibers begin synchronously, so untracking only program construction can subscribe the outer Svelte effect to the fiber's rune transitions (`frontend/src/App.svelte:542`). -App-wide health polling belongs to the root runtime lifetime, not the full-shell lifetime, because embedded routes still depend on daemon availability (`frontend/src/App.svelte::roborevPollingExecution`). +App-wide health polling belongs to the root runtime lifetime so every route observes daemon availability (`frontend/src/App.svelte::roborevPollingExecution`). Provider list, activity, and sync controllers expose synchronous launchers; their Effect workflows own cancellation, shared demand, bounded reads, and sequential cadence so Svelte callers never rebuild Promise generations or timer overlap guards (`frontend/src/lib/stores/`). @@ -531,7 +533,7 @@ Component lifetime owns polling and live-event subscriptions; teardown interrupt A `$state` record written by full-object reassignment (`x = { ...x, k: v }`) that is also read inside the same reactive scope — an `$effect`, or a `{@attach ...}` callback, which Svelte runs as one — is a self-referential dependency: Svelte detects it as `effect_update_depth_exceeded` and the attachment tears itself down and reattaches forever. Mutate the specific key instead (`x[k] = v`) (`frontend/src/lib/stores/workspace-host.svelte.ts::registerSlotElement`). -For TypeScript/Svelte state and routing contracts, avoid anonymous object type literals when the shape represents a domain concept that is reused or exposed across modules. Name shared item identity shapes, route payloads, embed callbacks, and API view models near the module that owns the concept, then import those types at call sites. PR/issue/file/focus route identity and URL construction belongs in the shared route item module at `frontend/src/lib/routes.ts`; the frontend router remains the browser-location adapter over those builders. New routed item callers should use those named refs and builders instead of repeating `{ owner; name; number; platformHost }` shapes or hand-building `/pulls`, `/issues`, or `/focus` URLs. +For TypeScript/Svelte state and routing contracts, avoid anonymous object type literals when the shape represents a domain concept that is reused or exposed across modules. Name shared item identity shapes, route payloads and API view models near the module that owns the concept, then import those types at call sites. PR/issue/file/focus route identity and URL construction belongs in the shared route item module at `frontend/src/lib/routes.ts`; the frontend router remains the browser-location adapter over those builders. New routed item callers should use those named refs and builders instead of repeating `{ owner; name; number; platformHost }` shapes or hand-building `/pulls`, `/issues`, or `/focus` URLs. When TypeScript complains, prefer making the owning type more precise over adding call-site assertions. Generated OpenAPI types, named domain unions, and shared option arrays should carry their real values so components can consume them directly. Good cleanups look like `handleCommandResult(result: void | Promise, ...)` or a typed dropdown option returning `TimeRange`; they remove runtime probing and casts by tightening the contract. Bad cleanups add `as unknown as`, broad `as any`, defensive `instanceof` branches, or response-normalization functions around data that is already typed by the API schema. diff --git a/context/ui-interaction-contracts.md b/context/ui-interaction-contracts.md index 52f8012802..10ed54f51b 100644 --- a/context/ui-interaction-contracts.md +++ b/context/ui-interaction-contracts.md @@ -200,7 +200,6 @@ Examples of transient state that should usually reset on identity change: - inline edit drafts - merge/close/reopen dialogs - approve/review forms -- embedded detail-tab selection when the parent surface owns the item ## Persistence Scope @@ -216,7 +215,7 @@ Persisted controls must state their scope clearly. storage (`frontend/src/lib/components/layout/ForgeSelector.svelte`). - Browser-local preferences belong in `localStorage` only when the behavior is intentionally per-browser and not worth server settings. -- Agent status visibility uses Forge config across browsers and embedded views. +- Agent status visibility uses Forge config across browsers. Keep PR, Issue, and Activity labels on the right, matching Workspaces (`frontend/src/lib/components/shared/AgentStatusIndicator.svelte`). - `Involves me` is three independent browser-local preferences for Pulls, Issues, and @@ -350,10 +349,7 @@ Persisted controls must state their scope clearly. - Server-backed settings forms must render and build mutations from the reactive authoritative store; a page-load snapshot can republish stale sibling fields after hydration (`frontend/src/lib/components/settings/WorkspaceSettings.svelte::workspaces`). -- Settings that select a runtime must hydrate before that runtime starts, but - the gate must abort timed-out or superseded reads and expose retry rather than strand the surface - (`frontend/src/lib/components/terminal/WorkspaceEmbedShell.svelte::loadTerminalSettings`). -- Concurrent startup and embedded-shell callers share the last successful settings snapshot; +- Concurrent startup callers share the last successful settings snapshot; every accepted settings command invalidates that cache entry through the same acknowledged workflow, backend readiness is not part of the settings-request timeout, and an invalidated in-flight read cannot publish into the next generation @@ -905,10 +901,6 @@ Rows that contain buttons, links, or toggles need clear event ownership. `tracked_repo_path`, because selections created from catalog rows use the current route, which diverges after a provider-side rename (`frontend/src/lib/utils/repo-filter-values.ts::normalizeInteractiveRepoFilterSelection`). - A host-pinned scope (`ui.hideRepoSelector`) is exempt: with no picker to - rescope, dropping it would unscope every request, so pinned selections pass - through normalization untouched - (`frontend/src/lib/utils/repo-filter-values.ts::normalizeGlobalRepoSelection`). - Roborev has no event replay cursor: reconnect after authoritative job-list reconciliation; a lost mutation response retains and fences its original target until authoritative observation, never replays the write. A confirmed POST stays acknowledged when its follow-up refresh fails; report @@ -943,9 +935,8 @@ Rows that contain buttons, links, or toggles need clear event ownership. - Frontend uncertainty fences live for one browser application runtime. A deliberate reload clears unresolved evidence, so the user must verify fresh authoritative state before attempting that mutation again (`frontend/src/lib/app/runtime.ts::makeAppRuntime`). -- Project registration, clone, and new-worktree commands capture host/project identity and remain - application-owned after acceptance; retained worktree acknowledgements are generation-owned, so an - older reconciler cannot clear a replacement command or presentation fence +- Project registration and clone commands capture host identity and remain + application-owned after acceptance; returning to an accepted command must not duplicate it (`frontend/src/lib/components/terminal/project-mutation-workflow.ts::ProjectMutationWorkflow`). - Workspace runtime commands remain application-owned after acceptance and retain presentation by `(hostKey, workspaceId)` across surface replacement; one-shot delete presenters may shadow the route presenter, @@ -958,9 +949,6 @@ Rows that contain buttons, links, or toggles need clear event ownership. browser application runtime; a deliberate page reload clears them, so a user who reloads during an unresolved outcome must verify authoritative workspace state before attempting the action again (`frontend/src/lib/components/terminal/workspace-runtime-workflow.ts::makeWorkspaceRuntimeWorkflow`). -- Embedding host callbacks settle only after mutations are durably visible to the next authoritative - snapshot; negative or malformed acknowledgements reconcile before the command is offered again - (`frontend/src/lib/components/terminal/project-mutation-workflow.ts::ProjectMutationWorkflow`). - Repository-browser commands use a mount-bound facade and fence every state publication; automatic README-first selection yields to user selection, and stale teardown cannot affect a successor (`frontend/src/lib/stores/repo-browser.svelte.ts::RepoBrowserMount`). @@ -1169,6 +1157,11 @@ responses, and discard stale responses instead of patching another item. - Onboarding repository setup owns its initial sync through `triggerSyncEffect`: a rejected trigger returns the flow to a retryable repository step with the failure visible, while an accepted trigger advances only after the ordered sync command settles (`frontend/src/lib/components/onboarding/OnboardingFlow.svelte::startSync`). +- Project registration and cloning continue to Activity; the workspace list is not + the setup landing page + (`frontend/src/lib/components/terminal/WorkspaceFirstRunPanel.svelte::runProjectSubmission`). +- Removing external embeds does not require a replacement project/worktree screen; + preserve the standalone desktop and mobile workflows. ## Testing Expectations diff --git a/frontend/index.html b/frontend/index.html index fd2bf46d4b..c12183c52d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,41 +18,13 @@ diff --git a/frontend/src/App.flash-presentations.browser.svelte.ts b/frontend/src/App.flash-presentations.browser.svelte.ts index 3335cdff8c..5560c950a8 100644 --- a/frontend/src/App.flash-presentations.browser.svelte.ts +++ b/frontend/src/App.flash-presentations.browser.svelte.ts @@ -1,11 +1,4 @@ -// Shell coverage matrix for the shared flash store: a flash raised through -// The shared flash store must render in a mounted kit FlashBanner in -// every app presentation, not just the desktop shell. The jsdom App test -// covers the focus/phone presentation (its 1024px #app classifies compact); -// this browser suite covers the desktop shell (wide viewport) and the -// workspace embed shell (no header, banner pinned to the pane top), which -// previously had no banner at all — its showFlash calls went to the shared -// store and were never rendered. +// Shared flashes remain visible across desktop, phone, and modal presentations. import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; @@ -35,7 +28,6 @@ describe("flash rendering across app shells", () => { overlayTarget = null; mounted?.unmount(); mounted = null; - delete window.__kenn_forge_config; }); async function visibleFlash(message: string): Promise { @@ -52,7 +44,7 @@ describe("flash rendering across app shells", () => { const header = document.querySelector(".app-top-bar"); expect(header).not.toBeNull(); expect(Math.abs(stack.getBoundingClientRect().top - header!.getBoundingClientRect().bottom)).toBeLessThan(1); - expect(stack.closest(".focus-layout, .desktop-layout, .embed-layout")).toBeNull(); + expect(stack.closest(".focus-layout, .desktop-layout")).toBeNull(); expect(stack.querySelector(".kit-flash-banner")?.getAttribute("data-kit-tone")).toBe("danger"); } @@ -71,7 +63,7 @@ describe("flash rendering across app shells", () => { const stack = await visibleFlash("compact shell flash"); expect(stack.getBoundingClientRect().top).toBe(0); - expect(stack.closest(".focus-layout, .desktop-layout, .embed-layout")).toBeNull(); + expect(stack.closest(".focus-layout, .desktop-layout")).toBeNull(); expect(stack.querySelector(".kit-flash-banner")?.getAttribute("data-kit-tone")).toBe("danger"); }); @@ -96,17 +88,6 @@ describe("flash rendering across app shells", () => { expect(Math.abs(stack.getBoundingClientRect().top - header.getBoundingClientRect().bottom)).toBeLessThan(1); }); - it("pins flashes to the page edge when embed config hides the header", async () => { - await page.viewport(1280, 900); - window.__kenn_forge_config = { embed: { hideHeader: true } }; - mounted = await mountBrowserApp("/settings"); - await vi.waitFor(() => expect(document.querySelector(".app-main")).not.toBeNull(), WAIT); - - const stack = await visibleFlash("hidden header flash"); - expect(document.querySelector(".app-top-bar")).toBeNull(); - expect(stack.getBoundingClientRect().top).toBe(0); - }); - it("keeps flashes above an open modal backdrop", async () => { await page.viewport(1280, 900); mounted = await mountBrowserApp("/pulls"); @@ -127,15 +108,4 @@ describe("flash rendering across app shells", () => { Number.parseInt(getComputedStyle(overlay).zIndex, 10), ); }); - - it("renders shared-store flashes in the workspace embed shell", async () => { - await page.viewport(1280, 900); - mounted = await mountBrowserApp("/workspaces/embed/empty/noSelection"); - await vi.waitFor(() => expect(document.querySelector(".embed-layout")).not.toBeNull(), WAIT); - - const stack = await visibleFlash("embed shell flash"); - expect(stack.getBoundingClientRect().top).toBe(0); - expect(stack.closest(".focus-layout, .desktop-layout, .embed-layout")).toBeNull(); - expect(stack.querySelector(".kit-flash-banner")?.getAttribute("data-kit-tone")).toBe("danger"); - }); }); diff --git a/frontend/src/App.mobile-detail-header.browser.svelte.ts b/frontend/src/App.mobile-detail-header.browser.svelte.ts index 71196aab91..135b5c3e29 100644 --- a/frontend/src/App.mobile-detail-header.browser.svelte.ts +++ b/frontend/src/App.mobile-detail-header.browser.svelte.ts @@ -1,3 +1,4 @@ +import { navigate } from "./lib/stores/router.svelte.js"; // Phone-like PR and issue detail routes render inside the phone shell: the // same top bar as every other phone view plus a detail header whose Back // control returns to the list that opened the item. The forced-mobile flag @@ -100,9 +101,9 @@ describe("phone detail header", () => { // discarded and the list starts at the top. document.querySelector(".mobile-shell .pull-item")!.click(); await vi.waitFor(() => expect(count(".mobile-shell .focus-layout--phone .pull-detail")).toBe(1), WAIT); - window.__kenn_forge_navigate_to_route!("/m"); + navigate("/m"); await vi.waitFor(() => expect(count(".mobile-shell .focus-list")).toBe(0), WAIT); - window.__kenn_forge_navigate_to_route!("/m/pulls"); + navigate("/m/pulls"); await vi.waitFor(() => expect(count(".mobile-shell .pull-item")).toBe(rows), WAIT); await vi.waitFor(() => expect(viewport().scrollHeight).toBeGreaterThan(viewport().clientHeight), WAIT); expect(viewport().scrollTop).toBe(0); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 24c7e1717a..004d96a3ae 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -17,8 +17,8 @@ import MobileDetailHeader from "./lib/components/mobile/MobileDetailHeader.svelte"; import ReviewsView from "./lib/views/ReviewsView.svelte"; import FocusListView from "./lib/views/FocusListView.svelte"; - import { normalizeGlobalRepoSelection } from "./lib/utils/repo-filter-values.js"; - import type { ActionRegistry, NavigateCallback, StoreInstances } from "./lib/types.js"; + import { normalizeInteractiveRepoFilterSelection } from "./lib/utils/repo-filter-values.js"; + import type { NavigateCallback, StoreInstances } from "./lib/types.js"; import type { ActivityItem, ModeVisibility } from "./lib/api/types.js"; import { buildFocusPullRequestFilesRoute, @@ -33,16 +33,12 @@ type RoutedItemRef, } from "./lib/routes.js"; import { - ACTIONS_KEY, - EVENT_KEY, HOST_STATE_KEY, NAVIGATE_KEY, - PREPARE_ROUTE_KEY, ROBOREV_CLIENT_KEY, SIDEBAR_KEY, STORES_KEY, UI_CONFIG_KEY, - WORKSPACE_COMMAND_KEY, } from "./lib/context.js"; import AppHeader from "./lib/components/layout/AppHeader.svelte"; @@ -63,7 +59,6 @@ import WorkspaceHost from "./lib/components/terminal/WorkspaceHost.svelte"; import SessionTerminalPool from "./lib/components/terminal/SessionTerminalPool.svelte"; import WorkspacePaneControls from "./lib/components/terminal/WorkspacePaneControls.svelte"; - import WorkspaceEmbedShell from "./lib/components/terminal/WorkspaceEmbedShell.svelte"; import WorkspaceFirstRunPanel from "./lib/components/terminal/WorkspaceFirstRunPanel.svelte"; import DesignSystemPage from "./lib/components/design-system/DesignSystemPage.svelte"; import OnboardingFlow from "./lib/components/onboarding/OnboardingFlow.svelte"; @@ -86,14 +81,12 @@ import { initTheme, cleanupTheme, - reapplyTheme, } from "./lib/stores/theme.svelte.js"; import { isSidebarCollapsed, getSidebarWidth, setSidebarWidth, toggleSidebar, - isSidebarToggleEnabled, initSidebar, setNarrowOverride, } from "./lib/stores/sidebar.svelte.js"; @@ -136,24 +129,9 @@ import { docsHref } from "./lib/api/docs/route.js"; import { getGlobalRepo, - applyConfigRepo, setGlobalRepo, parseRepoFilterValue, } from "./lib/stores/filter.svelte.js"; - import { - getUIConfig, - isEmbedded, - getPullRequestActions, - getIssueActions, - getActiveWorktreeKey, - invokeAction, - emitWorkspaceCommand, - isHeaderHidden, - isStatusBarHidden, - emitLayoutChanged, - initWorkspaceBridge, - } from "./lib/stores/embed-config.svelte.js"; - import { shouldUseFullAppShell } from "./lib/utils/appShell.js"; import { readOnboardingState, shouldStartOnboarding, @@ -185,32 +163,7 @@ let stores = $state.raw(); let workflowActionsEnabled = false; const appRuntime = untrack(() => runtime); - const appActions: ActionRegistry = { - pull: getPullRequestActions().map((action) => ({ - id: action.id, - label: action.label, - handler: (context) => - invokeAction(appRuntime, action, { - surface: context.surface, - owner: context.owner, - name: context.name, - number: context.number, - ...(context.meta != null && { meta: context.meta }), - }), - })), - issue: getIssueActions().map((action) => ({ - id: action.id, - label: action.label, - handler: (context) => - invokeAction(appRuntime, action, { - surface: context.surface, - owner: context.owner, - name: context.name, - number: context.number, - ...(context.meta != null && { meta: context.meta }), - }), - })), - }; + const getActiveWorktreeKey = () => window.__kenn_forge_active_worktree_key; const appNavigate: NavigateCallback = (event, options) => { const path = typeof event === "string" ? event : event.path; if (options?.replace) replaceUrl(path, options.state); @@ -230,7 +183,6 @@ }, getActivitySelection: () => drawerItem, config: { - hideStar: getUIConfig().hideStar, basePath: getBasePath(), }, getPage, @@ -256,14 +208,10 @@ console.warn("Roborev daemon polling stopped unexpectedly:", failure); }, }); - setContext(ACTIONS_KEY, appActions); setContext(NAVIGATE_KEY, appNavigate); - setContext(EVENT_KEY, () => {}); - setContext(PREPARE_ROUTE_KEY, null); - setContext(WORKSPACE_COMMAND_KEY, emitWorkspaceCommand); setContext(STORES_KEY, appComposition.stores); - setContext(UI_CONFIG_KEY, { hideStar: getUIConfig().hideStar, basePath: getBasePath() }); - setContext(SIDEBAR_KEY, { isEmbedded, isSidebarToggleEnabled, toggleSidebar }); + setContext(UI_CONFIG_KEY, { basePath: getBasePath() }); + setContext(SIDEBAR_KEY, { toggleSidebar }); setContext(HOST_STATE_KEY, { getGlobalRepo: getNormalizedGlobalRepo, getGroupByRepo: appComposition.stores.grouping.getGroupByRepo, @@ -350,7 +298,6 @@ routeStores: StoreInstances | undefined = stores, ): void { if (!routeStores) return; - if (getUIConfig().hideRepoSelector) return; if (!routeStores.settings.hasConfiguredRepos()) return; const currentRepo = untrack(getGlobalRepo); if (currentRepo === undefined) return; @@ -523,9 +470,6 @@ appReady = false; initTheme(); initSidebar(); - initWorkspaceBridge(); - const ui = getUIConfig(); - applyConfigRepo(ui.repo, ui.hideRepoSelector); const appEl = document.getElementById("app")!; const cleanupContainer = initContainerObserver(runtime, appEl); const cleanupItemRefs = initItemRefHandler(appRuntime); @@ -546,10 +490,6 @@ } $effect(() => { - if (!shouldUseFullAppShell(getPage())) { - stopFullAppShell(); - return; - } if (stores && cleanupFullAppShell === undefined) { stopFullAppShell(); startFullAppShell(stores); @@ -559,7 +499,7 @@ function syncWorkflowActionsAvailability(): void { const appStores = stores; if (!appStores?.settings.isSettingsLoaded()) return; - const enabled = !isEmbedded() && appStores.settings.isModeVisible("actions"); + const enabled = appStores.settings.isModeVisible("actions"); if (enabled !== workflowActionsEnabled) { workflowActionsEnabled = enabled; untrack(() => appStores.workflowActions.setEnabled(enabled)); @@ -692,7 +632,6 @@ function flashTopOffset(): string { if (onboardingActive) return "0"; if (shouldUseFocusPresentation() && !useFocusLayoutClass()) return "0"; - if (isHeaderHidden()) return "0"; if ( isMobilePage(getPage()) || shouldUseResponsiveMobileActivityPresentation() @@ -882,10 +821,9 @@ } function getNormalizedGlobalRepo(repo: string | undefined = getGlobalRepo()): string | undefined { - return normalizeGlobalRepoSelection( + return normalizeInteractiveRepoFilterSelection( repo, stores?.settings.getConfiguredRepos?.() ?? [], - getUIConfig().hideRepoSelector, ); } @@ -914,14 +852,7 @@ }); $effect(() => { - if (isSidebarToggleEnabled()) { - setNarrowOverride(isNarrow()); - } - }); - - $effect(() => { - if (!shouldUseFullAppShell(getPage())) return; - reapplyTheme(); + setNarrowOverride(isNarrow()); }); // Sync route state: restore drawer, select items, clear stale. @@ -1122,14 +1053,9 @@ function handleSidebarResize(width: number): void { setSidebarWidth(width); - emitLayoutChanged(appRuntime, { - sidebar: { width }, - pinnedPanel: { width: 0, visible: false }, - }); } $effect(() => { - if (!shouldUseFullAppShell(getPage())) return; if (!stores) return; setStoreInstances(() => stores!); const cleanupDefaults = registerScopedActions("app:defaults", defaultActions); @@ -1261,9 +1187,6 @@ -{#if !shouldUseFullAppShell(getPage())} - -{:else} {#snippet focusPresentation(phone: boolean)} {@const r = getRoute()}

{issue.Title}

- {#if !uiConfig.hideStar && !staleIssue} + {#if !staleIssue} - {/each} -
- {/snippet} {#if !workspace} {staleIssue @@ -1475,7 +1453,6 @@ { id: "workspace", content: workspaceAction }, ...(workspace && inlineWorkspace ? [{ id: "workspace-secondary", content: workspaceSecondaryAction }] : []), ...(capabilities.state_mutation ? [{ id: "state", content: stateAction }] : []), - ...((actions.issue ?? []).length > 0 ? [{ id: "embedding", content: embeddingActions }] : []), ]} /> @@ -1899,14 +1876,6 @@ padding: var(--space-4) 0; } - /* Embed-host actions are one custom grid item so each host action stays a - * separate button; the wrapper lays them out like the grid's own row. */ - .issue-detail :global(.embedding-actions) { - display: flex; - flex-wrap: wrap; - gap: var(--space-4); - } - .refresh-banner { display: flex; align-items: center; diff --git a/frontend/src/lib/components/detail/IssueDetail.test.ts b/frontend/src/lib/components/detail/IssueDetail.test.ts index 61c7817de9..fb2531eb20 100644 --- a/frontend/src/lib/components/detail/IssueDetail.test.ts +++ b/frontend/src/lib/components/detail/IssueDetail.test.ts @@ -6,7 +6,7 @@ import { makeAppRuntime, type OwnedAppRuntime } from "../../app/runtime.js"; import type { GeneratedClient } from "../../api/generated-api.js"; import type { IssueDetail, Label } from "../../api/types.js"; import type { MutationCallbacks } from "../../stores/ordered-mutations.js"; -import { ACTIONS_KEY, NAVIGATE_KEY, STORES_KEY, UI_CONFIG_KEY } from "../../context.js"; +import { NAVIGATE_KEY, STORES_KEY } from "../../context.js"; import { createDetailActivityViewStore } from "../../stores/detail-activity-view.svelte.js"; import { createIssuesStore } from "../../stores/issues.svelte.js"; import { makeTestAppRuntime } from "../../testing/effect-layers.js"; @@ -16,7 +16,6 @@ import { nextWorkspaceLifecycleTick, resetWorkspaceCreatePendingForTest, } from "../../stores/workspace-create-pending.svelte.js"; -import type { ActionRegistry } from "../../types.js"; import type { InlineWorkspaceController, WorkspaceItemIdentity } from "../../workspace-inline.js"; import { openLabelPickerFor } from "./labelPickerCommand.js"; import { createTestController } from "../workspace/inlineWorkspaceTestController.svelte.js"; @@ -187,7 +186,6 @@ function renderIssueDetail( refreshFailure?: string; inlineWorkspace?: InlineWorkspaceController | null; onOpenWorkspace?: (workspaceId: string) => void; - actions?: ActionRegistry; runtimeClient?: GeneratedClient; } = {}, apiClient: { GET: ReturnType; POST: ReturnType } = { @@ -264,8 +262,6 @@ function renderIssueDetail( }, }, ], - [ACTIONS_KEY, options.actions ?? { issue: [] }], - [UI_CONFIG_KEY, { hideStar: true }], [NAVIGATE_KEY, navigate], ]), }); @@ -453,8 +449,6 @@ describe("IssueDetail activity view", () => { }, }, ], - [ACTIONS_KEY, { issue: [] }], - [UI_CONFIG_KEY, { hideStar: true }], [NAVIGATE_KEY, vi.fn()], ]), }); @@ -489,23 +483,12 @@ describe("IssueDetail activity view", () => { const detail = issueDetail(); detail.issue.Body = "Action placement marker"; - renderIssueDetail(detail, undefined, { - actions: { - issue: [ - { - id: "extension-action", - label: "Extension action", - handler: vi.fn(), - }, - ], - }, - }); + renderIssueDetail(detail); const description = screen.getByText("Description"); for (const action of [ screen.getByRole("button", { name: "Create Workspace" }), screen.getByRole("button", { name: "Close issue" }), - screen.getByRole("button", { name: "Extension action" }), ]) { expect(action.compareDocumentPosition(description) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); } diff --git a/frontend/src/lib/components/detail/PullDetail.svelte b/frontend/src/lib/components/detail/PullDetail.svelte index a585efe0f4..8bdbfaa6df 100644 --- a/frontend/src/lib/components/detail/PullDetail.svelte +++ b/frontend/src/lib/components/detail/PullDetail.svelte @@ -29,8 +29,8 @@ import type { ConflictReason } from "../../api/problems.js"; import { showFlash } from "../../stores/flash.svelte.js"; import { - getStores, getActions, - getUIConfig, getNavigate, + getStores, + getNavigate, } from "../../context.js"; import MarkdownHtml from "../shared/MarkdownHtml.svelte"; import WorkflowDispatchDialog from "../actions/WorkflowDispatchDialog.svelte"; @@ -142,8 +142,6 @@ workflowActions, } = getStores(); const runtime = getAppRuntime(); - const actions = getActions(); - const uiConfig = getUIConfig(); const navigate = getNavigate(); let manualRefreshPending = $state(false); let manualRefreshGeneration = 0; @@ -1240,23 +1238,6 @@ const hasWorktreeLinks = $derived( worktreeLinks.length > 0, ); - const importAction = $derived( - (actions.pull ?? []).find( - (a) => a.id === "import-worktree", - ), - ); - const navigateAction = $derived( - (actions.pull ?? []).find( - (a) => a.id === "navigate-worktree", - ), - ); - const otherActions = $derived( - (actions.pull ?? []).filter( - (a) => - a.id !== "import-worktree" && - a.id !== "navigate-worktree", - ), - ); const labels = $derived(detailStore.getDetail()?.merge_request?.labels ?? []); const prAssignees = $derived(detailStore.getDetail()?.merge_request?.assignees ?? []); const prReviewers = $derived(detailStore.getDetail()?.merge_request?.requested_reviewers ?? []); @@ -2272,7 +2253,7 @@ title={contentGate.unavailable ? contentGate.reason : undefined} >Edit {/if} - {#if !uiConfig.hideStar && !stalePR} + {#if !stalePR} - - {/if} - {#if hasWorktreeLinks && navigateAction} -
- {#each worktreeLinks as link (link.worktree_key)} - - {/each} -
- {/if} - {#if otherActions.length > 0} -
- {#each otherActions as action (action.id)} - - {/each} -
- {/if} - {#if showMergeModal && mergeModalSettings} {@const d = detailStore.getDetail()!} {@const p = d.merge_request} diff --git a/frontend/src/lib/components/detail/PullDetail.test.ts b/frontend/src/lib/components/detail/PullDetail.test.ts index ca18434713..47071f4b05 100644 --- a/frontend/src/lib/components/detail/PullDetail.test.ts +++ b/frontend/src/lib/components/detail/PullDetail.test.ts @@ -4,7 +4,7 @@ import { tick, type ComponentProps } from "svelte"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { DiffResult, Label, PullDetail } from "../../api/types.js"; import { makeAppRuntime, type OwnedAppRuntime } from "../../app/runtime.js"; -import { ACTIONS_KEY, NAVIGATE_KEY, STORES_KEY, UI_CONFIG_KEY } from "../../context.js"; +import { NAVIGATE_KEY, STORES_KEY } from "../../context.js"; import { createDetailActivityViewStore } from "../../stores/detail-activity-view.svelte.js"; import { createDetailStore } from "../../stores/detail.svelte.js"; import { makeTestAppRuntime } from "../../testing/effect-layers.js"; @@ -233,7 +233,6 @@ function renderPullDetail( phonePresentation?: boolean; onOpenWorkspace?: (workspaceId: string) => void; hideTabs?: boolean; - actions?: { pull: unknown[] }; detailLoading?: boolean; detailSyncing?: boolean; deferRefresh?: boolean; @@ -247,7 +246,6 @@ function renderPullDetail( detailProps?: Partial>; } = {}, ) { - const actions = options.actions ?? { pull: [] }; let envelopeTick = 0; let pendingRefreshCallbacks: DetailSyncCallbacks | null = null; const runProviderAction = (path: string, body: unknown, callbacks: ProviderActionCallbacks): void => { @@ -405,8 +403,6 @@ function renderPullDetail( workflowActions, }, ], - [ACTIONS_KEY, actions], - [UI_CONFIG_KEY, { hideStar: true }], [NAVIGATE_KEY, navigate], ]), }); @@ -1095,8 +1091,6 @@ describe("PullDetail activity refresh", () => { }, }, ], - [ACTIONS_KEY, { pull: [] }], - [UI_CONFIG_KEY, { hideStar: true }], [NAVIGATE_KEY, vi.fn()], ]), }); @@ -1167,48 +1161,6 @@ describe("PullDetail approvals", () => { } }); - it("forwards worktree link host key to navigate actions", async () => { - const detail = pullDetail(); - detail.worktree_links = [ - { - host_key: "hub", - worktree_key: "worktree:/srv/widget-feature", - worktree_path: "/srv/widget-feature", - worktree_branch: "feature", - }, - ]; - const navigate = vi.fn(); - - renderPullDetail(detail, undefined, undefined, { - actions: { - pull: [ - { - id: "navigate-worktree", - label: "Open Worktree", - handler: navigate, - }, - ], - }, - }); - - await fireEvent.click( - screen.getByRole("button", { - name: "Open Worktree: worktree:/srv/widget-feature", - }), - ); - - expect(navigate).toHaveBeenCalledWith({ - surface: "pull-detail", - owner: "acme", - name: "widget", - number: 1, - meta: { - host_key: "hub", - worktree_key: "worktree:/srv/widget-feature", - }, - }); - }); - it("normalizes backend review decision casing before enabling approver popup", async () => { const detail = pullDetail(); detail.merge_request.ReviewDecision = "approved"; @@ -1989,8 +1941,6 @@ describe("PullDetail approvals", () => { }, }, ], - [ACTIONS_KEY, { pull: [] }], - [UI_CONFIG_KEY, { hideStar: true }], [NAVIGATE_KEY, vi.fn()], ]), }); diff --git a/frontend/src/lib/components/layout/AppHeader.svelte b/frontend/src/lib/components/layout/AppHeader.svelte index 0ffe48251c..2daa19ac51 100644 --- a/frontend/src/lib/components/layout/AppHeader.svelte +++ b/frontend/src/lib/components/layout/AppHeader.svelte @@ -44,12 +44,9 @@ parseRepoFilterValue, setGlobalRepo, } from "../../stores/filter.svelte.js"; - import { isEmbedded, getUIConfig } from "../../stores/embed-config.svelte.js"; - import { isThemeToggleVisible } from "../../stores/theme.svelte.js"; import { isSidebarCollapsed, toggleSidebar, - isSidebarToggleEnabled, } from "../../stores/sidebar.svelte.js"; import { openPalette } from "../../stores/keyboard/palette-state.svelte.js"; import { syncRepoForRoute } from "../../utils/repoSelectionSync.js"; @@ -222,7 +219,6 @@ sync.triggerRepoSync(repo); } - const hideProviderRepoSelector = $derived(getUIConfig().hideRepoSelector); const isProviderRepoSelectorPage = $derived( getPage() === "activity" || getPage() === "repos" || @@ -232,8 +228,8 @@ getPage() === "workspaces" || getPage() === "terminal", ); - const showProviderRepoSelector = $derived(!hideProviderRepoSelector && isProviderRepoSelectorPage); - const reserveProviderRepoSelectorSlot = $derived(!hideProviderRepoSelector && !isProviderRepoSelectorPage); + const showProviderRepoSelector = $derived(isProviderRepoSelectorPage); + const reserveProviderRepoSelectorSlot = $derived(!isProviderRepoSelectorPage); let settingsReturnPath = "/"; function currentAppPath(): string { @@ -266,8 +262,7 @@ const tabs: TopBarTab[] = $derived.by(() => { const entries: TopBarTab[] = modeNavOptions - .filter((option) => settings.isModeVisible(option.mode) - && (option.value !== "actions" || !isEmbedded())) + .filter((option) => settings.isModeVisible(option.mode)) .map(({ value, label }) => { const tab: TopBarTab = { id: value, label }; if (value === "reviews" && reviewsDaemonUnavailable) { @@ -279,7 +274,7 @@ if (getPage() === "design-system") { entries.push({ id: "design-system", label: "Design system" }); } - if (!isEmbedded() && getPage() === "settings") { + if (getPage() === "settings") { entries.push({ id: "settings", label: "Settings" }); } @@ -372,7 +367,7 @@ + app-startup/focus/routing specs to assert header presence. -->
{#snippet left()} - {#if isSidebarCollapsed() && isSidebarToggleEnabled() && !hasSidebarStrip} + {#if isSidebarCollapsed() && !hasSidebarStrip} - {#if !getUIConfig().hideSync} -
- - - {#if syncMenuOpen} - + +
+
- {/if} - {#if isThemeToggleVisible()} - - {/if} - {#if !isEmbedded()} - {syncing ? "Syncing..." : "Sync"} + {/if} + + + {#if syncMenuOpen} + + {/if} +
+ + + + + + {/snippet}
@@ -690,7 +683,7 @@ background: var(--bg-inset); } - /* Narrow containers (embedded or split panes under 500px) keep the + /* Narrow containers (split panes under 500px) keep the two-row header: the left region wraps onto the first row and the collapsed nav dropdown shares the second row with the action buttons. kit's measurement keeps the tabs collapsed here — the wrap only reorders diff --git a/frontend/src/lib/components/layout/AppHeader.test.ts b/frontend/src/lib/components/layout/AppHeader.test.ts index ec1402cd5c..3ec134b7c2 100644 --- a/frontend/src/lib/components/layout/AppHeader.test.ts +++ b/frontend/src/lib/components/layout/AppHeader.test.ts @@ -135,8 +135,6 @@ describe("AppHeader", () => { mockedSync.triggerSync.mockClear(); mockedSync.triggerRepoSync.mockClear(); setGlobalRepo(undefined); - delete window.__kenn_forge_config; - window.__kenn_forge_notify_config_changed?.(); mockedSettings.value = createSettingsStore(); resetPaletteState(); }); @@ -154,8 +152,6 @@ describe("AppHeader", () => { mockedSync.running = false; mockedSync.providerAvailable = true; setGlobalRepo(undefined); - delete window.__kenn_forge_config; - window.__kenn_forge_notify_config_changed?.(); mockedSettings.value = undefined; resetPaletteState(); }); @@ -389,20 +385,6 @@ describe("AppHeader", () => { view.unmount(); }); - it("never exposes Actions navigation in an embedded shell", () => { - initTheme(); - mockedSettings.value?.setModeVisibility({ - ...mockedSettings.value.getModeVisibility(), - actions: true, - }); - window.__kenn_forge_config = { embed: {} }; - window.__kenn_forge_notify_config_changed?.(); - - render(AppHeader); - - expect(screen.queryByRole("button", { name: "Actions" })).toBeNull(); - }); - it("marks the Workspaces tab current on terminal routes", () => { // One tabs list drives both the expanded tab row and kit's collapsed // dropdown, so the terminal → workspaces active mapping only needs @@ -455,17 +437,6 @@ describe("AppHeader", () => { expectReservedRepoSelectorSlot(container); }); - it("does not reserve the repo selector slot when embed config hides it", () => { - initTheme(); - window.__kenn_forge_config = { ui: { hideRepoSelector: true } }; - window.__kenn_forge_notify_config_changed?.(); - navigate("/docs"); - const { container } = render(AppHeader); - - expect(screen.queryByTitle("Select repository")).toBeNull(); - expect(container.querySelector(".repo-selector-placeholder")).toBeNull(); - }); - it("remembers the Docs route when the nav switches to Activity", async () => { initTheme(); showImportedModes(); diff --git a/frontend/src/lib/components/onboarding/OnboardingFlow.test.ts b/frontend/src/lib/components/onboarding/OnboardingFlow.test.ts index 2a92cacfad..c5b3bb3ba1 100644 --- a/frontend/src/lib/components/onboarding/OnboardingFlow.test.ts +++ b/frontend/src/lib/components/onboarding/OnboardingFlow.test.ts @@ -8,7 +8,7 @@ import { createdWorkspaceRef, resetWorkspaceCreatePendingForTest, } from "../../stores/workspace-create-pending.svelte.js"; -import type { ToolingStatusValue } from "../../stores/embed-config.svelte.js"; +import type { ToolingStatusValue } from "../../stores/tooling-status.svelte.js"; import type { StoreInstances } from "../../types.js"; const mocks = vi.hoisted(() => ({ diff --git a/frontend/src/lib/components/onboarding/ProviderReadinessStep.svelte b/frontend/src/lib/components/onboarding/ProviderReadinessStep.svelte index 5a948a42a9..a19f74fb3a 100644 --- a/frontend/src/lib/components/onboarding/ProviderReadinessStep.svelte +++ b/frontend/src/lib/components/onboarding/ProviderReadinessStep.svelte @@ -1,7 +1,7 @@ - -
- {message} -
- - diff --git a/frontend/src/lib/components/terminal/WorkspaceEmbedEmptyState.test.ts b/frontend/src/lib/components/terminal/WorkspaceEmbedEmptyState.test.ts deleted file mode 100644 index 0ec2d632d0..0000000000 --- a/frontend/src/lib/components/terminal/WorkspaceEmbedEmptyState.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { cleanup, render, screen } from "@testing-library/svelte"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import WorkspaceEmbedEmptyState from "./WorkspaceEmbedEmptyState.svelte"; - -describe("WorkspaceEmbedEmptyState", () => { - afterEach(() => cleanup()); - - it("renders the noSelection message", () => { - render(WorkspaceEmbedEmptyState, { - props: { reason: "noSelection" }, - }); - expect(screen.getByText("Select a workspace from the sidebar")).toBeTruthy(); - }); - - it("renders the noRepo message", () => { - render(WorkspaceEmbedEmptyState, { - props: { reason: "noRepo" }, - }); - expect(screen.getByText("Select a repository to see workspaces")).toBeTruthy(); - }); - - it("renders the noWorkspace message", () => { - render(WorkspaceEmbedEmptyState, { - props: { reason: "noWorkspace" }, - }); - expect(screen.getByText("No workspace for this item yet")).toBeTruthy(); - }); -}); diff --git a/frontend/src/lib/components/terminal/WorkspaceEmbedShell.svelte b/frontend/src/lib/components/terminal/WorkspaceEmbedShell.svelte deleted file mode 100644 index bbcc0e04de..0000000000 --- a/frontend/src/lib/components/terminal/WorkspaceEmbedShell.svelte +++ /dev/null @@ -1,184 +0,0 @@ - - - - -
- {#if r.page === "embed-workspace-list"} - - {:else if r.page === "embed-workspace-terminal"} - {#if terminalSettingsReady} - - - - {:else if terminalSettingsError} - -
- - diff --git a/frontend/src/lib/components/terminal/WorkspaceEmbedShell.test.ts b/frontend/src/lib/components/terminal/WorkspaceEmbedShell.test.ts deleted file mode 100644 index b70ef4e0a8..0000000000 --- a/frontend/src/lib/components/terminal/WorkspaceEmbedShell.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; -import { Effect } from "effect"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { OwnedAppRuntime } from "../../app/runtime.js"; -import { makeAppRuntime } from "../../app/runtime.js"; -import { createAppStores } from "../../app-stores.svelte.js"; -import { DEFAULT_TERMINAL_SETTINGS } from "../../api/types.js"; -import { STORES_KEY } from "../../context.js"; -import { replaceUrl } from "../../stores/router.svelte.js"; -import { makeStartupSnapshot } from "../../../test/startupSnapshot.js"; - -const mocks = vi.hoisted(() => ({ - runtime: undefined as unknown as OwnedAppRuntime, - settingsRequested: () => {}, - settingsSignals: [] as AbortSignal[], - showFlash: vi.fn(), -})); - -vi.mock("../../app/runtime-context.js", () => ({ - getAppRuntime: () => mocks.runtime, -})); - -vi.mock("../../stores/flash.svelte.js", () => ({ - showFlash: mocks.showFlash, -})); - -import WorkspaceEmbedShell from "./WorkspaceEmbedShell.svelte"; - -function renderShell() { - const stores = createAppStores({ runtime: mocks.runtime }).stores; - return render(WorkspaceEmbedShell, { context: new Map([[STORES_KEY, stores]]) }); -} - -const settings = makeStartupSnapshot({ - terminal: { - ...DEFAULT_TERMINAL_SETTINGS, - font_size: 14, - }, -}); - -describe("WorkspaceEmbedShell settings requests", () => { - beforeEach(() => { - vi.useFakeTimers(); - mocks.runtime = makeAppRuntime(); - mocks.settingsRequested = () => {}; - mocks.settingsSignals = []; - mocks.showFlash.mockReset(); - const fetch: typeof globalThis.fetch = (input, init) => { - const request = input instanceof Request ? input : new Request(input, init); - if (new URL(request.url).pathname.endsWith("/healthz")) { - return Promise.resolve(Response.json({ ok: true })); - } - if (!new URL(request.url).pathname.endsWith("/api/v1/settings")) { - return Promise.resolve(Response.json({})); - } - mocks.settingsSignals.push(request.signal); - mocks.settingsRequested(); - return new Promise((_resolve, reject) => { - request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true }); - }); - }; - vi.stubGlobal("fetch", fetch); - replaceUrl("/workspaces/embed/terminal/ws-1"); - }); - - afterEach(async () => { - cleanup(); - await Effect.runPromise(mocks.runtime.disposeEffect); - vi.unstubAllGlobals(); - vi.useRealTimers(); - replaceUrl("/"); - }); - - it("aborts settings requests on timeout, retry, and unmount", async () => { - const firstRequest = new Promise((resolve) => { - mocks.settingsRequested = resolve; - }); - const mounted = renderShell(); - await firstRequest; - const firstSignal = mocks.settingsSignals[0]; - - await vi.advanceTimersByTimeAsync(8_000); - await waitFor(() => expect(screen.getByRole("button", { name: "Retry terminal settings" })).toBeTruthy()); - expect(firstSignal?.aborted).toBe(true); - - const secondRequest = new Promise((resolve) => { - mocks.settingsRequested = resolve; - }); - await fireEvent.click(screen.getByRole("button", { name: "Retry terminal settings" })); - await secondRequest; - const secondSignal = mocks.settingsSignals[1]; - expect(secondSignal?.aborted).toBe(false); - - mounted.unmount(); - await vi.waitFor(() => expect(secondSignal?.aborted).toBe(true)); - }); - - it("loads terminal settings once when hydration succeeds", async () => { - const fetch: typeof globalThis.fetch = (input, init) => { - const request = input instanceof Request ? input : new Request(input, init); - if (new URL(request.url).pathname.endsWith("/healthz")) { - return Promise.resolve(Response.json({ ok: true })); - } - if (!new URL(request.url).pathname.endsWith("/api/v1/settings")) { - return Promise.resolve(Response.json({})); - } - mocks.settingsSignals.push(request.signal); - mocks.settingsRequested(); - return Promise.resolve(Response.json(settings)); - }; - vi.stubGlobal("fetch", fetch); - replaceUrl("/workspaces/embed/empty/noSelection"); - - const settingsRequest = new Promise((resolve) => { - mocks.settingsRequested = resolve; - }); - renderShell(); - await settingsRequest; - - expect(mocks.settingsSignals).toHaveLength(1); - }); -}); diff --git a/frontend/src/lib/components/terminal/WorkspaceFirstRunPanel.svelte b/frontend/src/lib/components/terminal/WorkspaceFirstRunPanel.svelte index 9f3f86704c..c8b81f76d6 100644 --- a/frontend/src/lib/components/terminal/WorkspaceFirstRunPanel.svelte +++ b/frontend/src/lib/components/terminal/WorkspaceFirstRunPanel.svelte @@ -1,7 +1,6 @@ - -
- {#if loading} -

Loading project…

- {:else if loadError} - - - {:else if project} -
-

- {project.display_name} -

-

- {project.local_path} -

- {#if project.platform_identity} -

- - {#if project.platform_identity.platform} - - {/if} - {platformChip(project.platform_identity)} - -

- {/if} - {#if project.default_branch} -

- Default branch: - {project.default_branch} -

- {/if} -
- -
-

Worktrees

- {#if worktrees.length === 0} -

- This project has no worktrees yet. -

- {:else} -
    - {#each worktrees as worktree (worktree.id)} -
  • - - {worktree.branch} - - - {worktree.path} - -
  • - {/each} -
- {/if} -
- - - {/if} -
- - diff --git a/frontend/src/lib/components/terminal/WorkspaceProjectCard.test.ts b/frontend/src/lib/components/terminal/WorkspaceProjectCard.test.ts deleted file mode 100644 index 317b2c6660..0000000000 --- a/frontend/src/lib/components/terminal/WorkspaceProjectCard.test.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { GeneratedProblemResponse } from "../../api/runtime.js"; - -import WorkspaceProjectCard from "./WorkspaceProjectCardRuntimeHarness.svelte"; - -const win = window as any; - -const mocks = vi.hoisted(() => ({ - showFlash: vi.fn(), -})); - -vi.mock("../../stores/flash.svelte.js", () => ({ - showFlash: mocks.showFlash, -})); - -const { projectGet, worktreesGet } = vi.hoisted(() => ({ projectGet: vi.fn(), worktreesGet: vi.fn() })); - -vi.mock("../../app/runtime.ts", async (importOriginal) => { - const actual = await importOriginal(); - const { makeGeneratedClient } = await import("../../testing/generated-client.js"); - const client = makeGeneratedClient({ - ProjectsService: { getProject: projectGet, listWorktrees: worktreesGet }, - FleetService: { getFleetProject: projectGet, listFleetProjectWorktrees: worktreesGet }, - }); - return { - ...actual, - makeAppRuntime: () => actual.makeAppRuntime(client), - }; -}); - -interface ProjectFixture { - id: string; - display_name: string; - local_path: string; - default_branch?: string; - platform_identity?: { - platform?: string; - platform_host: string; - owner: string; - name: string; - }; -} - -function setProjectResponse(project: ProjectFixture | { error: string }): void { - projectGet.mockReset(); - if ("error" in project) { - const problem = { type: "about:blank", title: "Project not found", status: 404, detail: project.error } as const; - projectGet.mockRejectedValue(new GeneratedProblemResponse(problem, new Response(null, { status: 404 }))); - return; - } - projectGet.mockResolvedValue({ - created_at: "2026-08-04T00:00:00Z", - updated_at: "2026-08-04T00:00:00Z", - ...project, - }); -} - -function setWorktreesResponse( - worktrees: Array<{ - id: string; - project_id: string; - branch: string; - path: string; - }>, -): void { - worktreesGet.mockReset(); - worktreesGet.mockResolvedValue({ worktrees }); -} - -describe("WorkspaceProjectCard", () => { - beforeEach(() => { - delete win.__kenn_forge_config; - mocks.showFlash.mockReset(); - }); - - afterEach(() => { - cleanup(); - projectGet.mockReset(); - worktreesGet.mockReset(); - }); - - it("renders project metadata and the create-first-worktree CTA when empty", async () => { - setProjectResponse({ - id: "prj_1", - display_name: "myrepo", - local_path: "/Users/wesm/code/myrepo", - default_branch: "main", - }); - setWorktreesResponse([]); - - render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - - expect(await screen.findByText("myrepo")).toBeTruthy(); - expect(screen.getByText("/Users/wesm/code/myrepo")).toBeTruthy(); - expect(screen.getByText("main")).toBeTruthy(); - expect(screen.getByText("This project has no worktrees yet.")).toBeTruthy(); - expect( - screen.getByRole("button", { - name: /Create your first worktree/i, - }), - ).toBeTruthy(); - }); - - it("aborts a pending project read when the card unmounts", async () => { - let requestSignal: AbortSignal | undefined; - projectGet.mockImplementation((_path: unknown, options: { signal?: AbortSignal }) => { - requestSignal = options.signal; - return new Promise(() => {}); - }); - setWorktreesResponse([]); - const view = render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - await waitFor(() => { - expect(projectGet).toHaveBeenCalledOnce(); - }); - - view.unmount(); - - expect(requestSignal?.aborted).toBe(true); - }); - - it("hides the platform chip row when platform_identity is absent", async () => { - setProjectResponse({ - id: "prj_1", - display_name: "no-remote-repo", - local_path: "/tmp/no-remote", - }); - setWorktreesResponse([]); - - render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - await screen.findByText("no-remote-repo"); - // Chip uses platform host / owner / name format; absence is the no-platform path. - expect(screen.queryByText(/github\.com \/ /)).toBeNull(); - }); - - it("renders platform identity from platform_host", async () => { - setProjectResponse({ - id: "prj_1", - display_name: "remote-repo", - local_path: "/tmp/remote-repo", - platform_identity: { - platform_host: "gitlab.example.com", - owner: "group/subgroup", - name: "project", - }, - }); - setWorktreesResponse([]); - - render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - - expect(await screen.findByText("gitlab.example.com / group/subgroup / project")).toBeTruthy(); - }); - - it("renders provider brand icon beside platform identity when platform is present", async () => { - setProjectResponse({ - id: "prj_1", - display_name: "remote-repo", - local_path: "/tmp/remote-repo", - platform_identity: { - platform: "gitlab", - platform_host: "gitlab.example.com", - owner: "group/subgroup", - name: "project", - }, - }); - setWorktreesResponse([]); - - render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - - expect(await screen.findByRole("img", { name: "GitLab" })).toBeTruthy(); - }); - - it("renders existing worktrees and switches the CTA label", async () => { - setProjectResponse({ - id: "prj_1", - display_name: "myrepo", - local_path: "/tmp/myrepo", - }); - setWorktreesResponse([ - { - id: "wtr_1", - project_id: "prj_1", - branch: "feature-x", - path: "/tmp/myrepo-worktrees/feature-x", - }, - ]); - - render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - await screen.findByText("feature-x"); - expect(screen.getByText("/tmp/myrepo-worktrees/feature-x")).toBeTruthy(); - expect(screen.getByRole("button", { name: /Create another worktree/i })).toBeTruthy(); - }); - - it("loads project data through fleet routes when host scoped", async () => { - setProjectResponse({ - id: "prj_1", - display_name: "myrepo", - local_path: "/srv/myrepo", - }); - setWorktreesResponse([]); - - render(WorkspaceProjectCard, { - props: { projectId: "prj_1", hostKey: " epyc " }, - }); - - expect(await screen.findByText("myrepo")).toBeTruthy(); - expect(projectGet).toHaveBeenCalledWith( - { hostKey: "epyc", projectId: "prj_1" }, - { signal: expect.any(AbortSignal) }, - ); - expect(worktreesGet).toHaveBeenCalledWith( - { hostKey: "epyc", projectId: "prj_1" }, - { signal: expect.any(AbortSignal) }, - ); - }); - - it("renders an error and a retry button when the project fetch fails", async () => { - setProjectResponse({ error: "project not found" }); - render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - expect(await screen.findByText("project not found")).toBeTruthy(); - expect(screen.getByRole("button", { name: /Retry/i })).toBeTruthy(); - }); - - it("invokes the new-worktree action with the project id when clicked", async () => { - const newWorktreeHandler = vi.fn().mockResolvedValue({ ok: true }); - win.__kenn_forge_config = { - actions: { - project: [ - { - id: "new-worktree", - label: "New Worktree", - handler: newWorktreeHandler, - }, - ], - }, - }; - win.__kenn_forge_notify_config_changed?.(); - - setProjectResponse({ - id: "prj_1", - display_name: "myrepo", - local_path: "/tmp/myrepo", - }); - setWorktreesResponse([]); - - render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - await screen.findByText("myrepo"); - - await fireEvent.click( - screen.getByRole("button", { - name: /Create your first worktree/i, - }), - ); - await waitFor(() => { - expect(newWorktreeHandler).toHaveBeenCalledWith({ - surface: "project-card", - projectId: "prj_1", - }); - }); - }); - - it("includes the host key in new-worktree action context", async () => { - const newWorktreeHandler = vi.fn().mockResolvedValue({ ok: true }); - win.__kenn_forge_config = { - actions: { - project: [ - { - id: "new-worktree", - label: "New Worktree", - handler: newWorktreeHandler, - }, - ], - }, - }; - win.__kenn_forge_notify_config_changed?.(); - - setProjectResponse({ - id: "prj_1", - display_name: "myrepo", - local_path: "/tmp/myrepo", - }); - setWorktreesResponse([]); - - render(WorkspaceProjectCard, { - props: { projectId: "prj_1", hostKey: " epyc " }, - }); - await screen.findByText("myrepo"); - - await fireEvent.click( - screen.getByRole("button", { - name: /Create your first worktree/i, - }), - ); - await waitFor(() => { - expect(newWorktreeHandler).toHaveBeenCalledWith({ - surface: "project-card", - projectId: "prj_1", - hostKey: "epyc", - }); - }); - }); - - it("adopts a retained action before offering a new worktree intent", async () => { - let completeAction: ((result: CommandResult) => void) | undefined; - const pendingAction = new Promise((resolve) => { - completeAction = resolve; - }); - const newWorktreeHandler = vi.fn(() => pendingAction); - win.__kenn_forge_config = { - actions: { - project: [ - { - id: "new-worktree", - label: "New Worktree", - handler: newWorktreeHandler, - }, - ], - }, - }; - win.__kenn_forge_notify_config_changed?.(); - setProjectResponse({ - id: "prj_1", - display_name: "myrepo", - local_path: "/tmp/myrepo", - }); - setWorktreesResponse([]); - const view = render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - await screen.findByText("myrepo"); - await fireEvent.click( - screen.getByRole("button", { - name: /Create your first worktree/i, - }), - ); - await waitFor(() => { - expect(newWorktreeHandler).toHaveBeenCalledWith({ - surface: "project-card", - projectId: "prj_1", - }); - }); - - await view.rerender({ projectId: "prj_2" }); - await waitFor(() => { - expect(projectGet).toHaveBeenCalledTimes(2); - }); - if (!completeAction) throw new Error("project action did not start"); - completeAction({ ok: true }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(projectGet).toHaveBeenCalledTimes(2); - - await view.rerender({ projectId: "prj_1" }); - await waitFor(() => expect(projectGet).toHaveBeenCalledTimes(4)); - await fireEvent.click(screen.getByRole("button", { name: /Create your first worktree/i })); - await waitFor(() => expect(newWorktreeHandler).toHaveBeenCalledTimes(2)); - - expect(projectGet).toHaveBeenCalledTimes(5); - }); - - it("keeps the current retained-action owner fenced while an earlier waiter settles", async () => { - let completeAction: ((result: CommandResult) => void) | undefined; - const pendingAction = new Promise((resolve) => { - completeAction = resolve; - }); - const newWorktreeHandler = vi.fn(() => pendingAction); - win.__kenn_forge_config = { - actions: { - project: [ - { - id: "new-worktree", - label: "New Worktree", - handler: newWorktreeHandler, - }, - ], - }, - }; - win.__kenn_forge_notify_config_changed?.(); - - const refreshResolvers: Array<(response: ProjectFixture & { created_at: string; updated_at: string }) => void> = []; - let firstProjectLoads = 0; - projectGet.mockImplementation(({ projectId }: { projectId: string }) => { - const project = { - id: projectId, - display_name: projectId === "prj_1" ? "Retained Project" : "Navigation Target", - local_path: `/tmp/${projectId}`, - created_at: "2026-08-04T00:00:00Z", - updated_at: "2026-08-04T00:00:00Z", - }; - if (projectId === "prj_1") { - firstProjectLoads += 1; - if (firstProjectLoads >= 3) { - return new Promise((resolve) => { - refreshResolvers.push(resolve); - }); - } - } - return Promise.resolve(project); - }); - setWorktreesResponse([]); - - const view = render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - await screen.findByText("Retained Project"); - await fireEvent.click(screen.getByRole("button", { name: /Create your first worktree/i })); - await waitFor(() => expect(newWorktreeHandler).toHaveBeenCalledOnce()); - - await view.rerender({ projectId: "prj_2" }); - await screen.findByText("Navigation Target"); - await view.rerender({ projectId: "prj_1" }); - await waitFor(() => expect(firstProjectLoads).toBe(2)); - - if (!completeAction) throw new Error("project action did not start"); - completeAction({ ok: true }); - await waitFor(() => expect(refreshResolvers).toHaveLength(2)); - refreshResolvers[0]?.({ - id: "prj_1", - display_name: "Retained Project", - local_path: "/tmp/prj_1", - created_at: "2026-08-04T00:00:00Z", - updated_at: "2026-08-04T00:00:00Z", - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - const button = screen.queryByRole("button", { name: /Create (your first|another) worktree/i }); - expect(button === null || button.hasAttribute("disabled")).toBe(true); - - for (const resolve of refreshResolvers.slice(1)) { - resolve({ - id: "prj_1", - display_name: "Retained Project", - local_path: "/tmp/prj_1", - created_at: "2026-08-04T00:00:00Z", - updated_at: "2026-08-04T00:00:00Z", - }); - } - }); - - it("surfaces an invalid new-worktree acknowledgement", async () => { - win.__kenn_forge_config = { - actions: { - project: [ - { - id: "new-worktree", - label: "New Worktree", - handler: vi.fn().mockResolvedValue(undefined), - }, - ], - }, - }; - win.__kenn_forge_notify_config_changed?.(); - setProjectResponse({ - id: "prj_1", - display_name: "myrepo", - local_path: "/tmp/myrepo", - }); - setWorktreesResponse([]); - render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - await screen.findByText("myrepo"); - - await fireEvent.click(screen.getByRole("button", { name: /Create your first worktree/i })); - - await waitFor(() => { - expect(mocks.showFlash).toHaveBeenCalledWith("The host returned an invalid worktree acknowledgement.", { - tone: "danger", - }); - }); - }); - - it("surfaces a failure message when the new-worktree action returns ok: false", async () => { - win.__kenn_forge_config = { - actions: { - project: [ - { - id: "new-worktree", - label: "New Worktree", - handler: () => - Promise.resolve({ - ok: false, - message: "user cancelled the sheet", - }), - }, - ], - }, - }; - win.__kenn_forge_notify_config_changed?.(); - - setProjectResponse({ - id: "prj_1", - display_name: "myrepo", - local_path: "/tmp/myrepo", - }); - setWorktreesResponse([]); - - render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - await screen.findByText("myrepo"); - await fireEvent.click( - screen.getByRole("button", { - name: /Create your first worktree/i, - }), - ); - await waitFor(() => { - expect(mocks.showFlash).toHaveBeenCalledWith("user cancelled the sheet", { - tone: "danger", - }); - }); - expect(screen.queryByText("user cancelled the sheet")).toBeNull(); - }); - - it("renders an upgrade-host hint when the new-worktree action is missing", async () => { - win.__kenn_forge_config = { actions: { project: [] } }; - win.__kenn_forge_notify_config_changed?.(); - - setProjectResponse({ - id: "prj_1", - display_name: "myrepo", - local_path: "/tmp/myrepo", - }); - setWorktreesResponse([]); - - render(WorkspaceProjectCard, { props: { projectId: "prj_1" } }); - await screen.findByText("myrepo"); - await fireEvent.click( - screen.getByRole("button", { - name: /Create your first worktree/i, - }), - ); - expect(mocks.showFlash).toHaveBeenCalledWith( - "New Worktree is not available in this build. Please update the host application.", - { tone: "danger" }, - ); - expect(screen.queryByText(/not available in this build/i)).toBeNull(); - }); -}); diff --git a/frontend/src/lib/components/terminal/WorkspaceProjectCardRuntimeHarness.svelte b/frontend/src/lib/components/terminal/WorkspaceProjectCardRuntimeHarness.svelte deleted file mode 100644 index 8b91ba03ca..0000000000 --- a/frontend/src/lib/components/terminal/WorkspaceProjectCardRuntimeHarness.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/frontend/src/lib/components/terminal/WorkspaceTerminalView.svelte b/frontend/src/lib/components/terminal/WorkspaceTerminalView.svelte index d0de183664..ce9b0b0b91 100644 --- a/frontend/src/lib/components/terminal/WorkspaceTerminalView.svelte +++ b/frontend/src/lib/components/terminal/WorkspaceTerminalView.svelte @@ -165,12 +165,8 @@ createdAt: string; } - // hideWorkspaceList / hideRightSidebar let an embedding host - // render only the terminal/home/empty surface and compose the - // workspace list and per-item detail sidebar separately via - // the /workspaces/embed/list and /workspaces/embed/detail - // routes. Both default to false to preserve the standalone - // /workspaces and /terminal/{id} layout. + // Internal pane hosts can supply their own list and detail sidebar. + // Standalone workspace and terminal routes show both by default. interface Props { workspaceId: string; workspaceHostKey?: string | undefined; diff --git a/frontend/src/lib/components/terminal/WorkspaceTerminalViewEmbed.test.ts b/frontend/src/lib/components/terminal/WorkspaceTerminalViewPane.test.ts similarity index 89% rename from frontend/src/lib/components/terminal/WorkspaceTerminalViewEmbed.test.ts rename to frontend/src/lib/components/terminal/WorkspaceTerminalViewPane.test.ts index f74f35b1a0..6ab5588596 100644 --- a/frontend/src/lib/components/terminal/WorkspaceTerminalViewEmbed.test.ts +++ b/frontend/src/lib/components/terminal/WorkspaceTerminalViewPane.test.ts @@ -1,14 +1,4 @@ -// Pins the embed-only props on WorkspaceTerminalView so a refactor that -// loses the conditional rendering around the workspace list column or the -// right detail sidebar fails loudly rather than silently breaking -// embedders that mount the surface via /workspaces/embed/terminal. -// -// Lives in its own file because the broader WorkspaceTerminalView test -// suite stubs globalThis.fetch *after* the runtime client module has -// captured it; that's a pre-existing test-infrastructure issue -// (introduced in #182) which affects neither this branch nor the embed -// props themselves. Mocking the api/runtime module here avoids the -// captured-fetch problem entirely. +// Workspace panes can hide the list and detail sidebar while sharing terminal state. import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; import { Effect } from "effect"; @@ -167,7 +157,7 @@ const readyWorkspaceData = { }, item_type: "pull_request", item_number: 7, - git_head_ref: "feature/embed-props", + git_head_ref: "feature/pane-props", worktree_path: "/tmp/worktree", tmux_session: "kenn-forge-ws-1", status: "ready", @@ -182,7 +172,7 @@ const readyIssueWorkspaceData = { associated_pr_number: null, }; -describe("WorkspaceTerminalView embed props", () => { +describe("WorkspaceTerminalView pane props", () => { afterAll(async () => { if (runtimeState.appRuntime !== undefined) { await Effect.runPromise(runtimeState.appRuntime.disposeEffect); @@ -233,7 +223,7 @@ describe("WorkspaceTerminalView embed props", () => { // Wait for the header branch element that only renders once the // workspace payload resolves; this confirms the component reached // steady state rather than failing the load early. - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); // The workspace-list column header reads "Workspaces"; with // hideWorkspaceList the entire column is skipped so the heading @@ -246,7 +236,7 @@ describe("WorkspaceTerminalView embed props", () => { props: { workspaceId: "ws-1" }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); expect(screen.queryByText("Workspaces")).not.toBeNull(); }); @@ -260,7 +250,7 @@ describe("WorkspaceTerminalView embed props", () => { }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); expect(screen.queryByRole("button", { name: "PR" })).toBeNull(); expect(screen.queryByRole("button", { name: "Reviews" })).toBeNull(); @@ -271,7 +261,7 @@ describe("WorkspaceTerminalView embed props", () => { props: { workspaceId: "ws-1" }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); expect(screen.getByRole("button", { name: "PR" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Reviews" })).toBeTruthy(); @@ -288,7 +278,7 @@ describe("WorkspaceTerminalView embed props", () => { }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); expect(screen.queryByRole("button", { name: "PR" })).toBeNull(); await fireEvent.click(screen.getByRole("button", { name: "Refresh workspace details" })); @@ -313,7 +303,7 @@ describe("WorkspaceTerminalView embed props", () => { }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); await fireEvent.click(screen.getByRole("button", { name: "Refresh workspace details" })); @@ -369,7 +359,7 @@ describe("WorkspaceTerminalView embed props", () => { render(WorkspaceTerminalView, { props: { workspaceId: "ws-1", hideWorkspaceList: true, onWorkspaceDeleted }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); gone = true; await waitFor(() => expect(mocks.workspaceEventsSubscriber).toBeTypeOf("function")); @@ -384,7 +374,7 @@ describe("WorkspaceTerminalView embed props", () => { }); // The dead cached envelope must not keep rendering as live. await waitFor(() => { - expect(screen.queryAllByText("feature/embed-props")).toHaveLength(0); + expect(screen.queryAllByText("feature/pane-props")).toHaveLength(0); expect(screen.getAllByText("workspace not found").length).toBeGreaterThan(0); }); }); @@ -413,7 +403,7 @@ describe("WorkspaceTerminalView embed props", () => { props: { workspaceId: "ws-1", hideWorkspaceList: true }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); expect(screen.queryByRole("button", { name: "Expand Terminal" })).toBeNull(); expect(screen.queryByRole("button", { name: "Collapse Terminal" })).toBeNull(); @@ -425,7 +415,7 @@ describe("WorkspaceTerminalView embed props", () => { props: { workspaceId: "ws-1", hideWorkspaceList: true, inlineDock }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); const deleteButton = screen.getByRole("button", { name: "Delete" }); const container = deleteButton.closest(".header-end"); @@ -445,7 +435,7 @@ describe("WorkspaceTerminalView embed props", () => { }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); await fireEvent.click(screen.getByRole("button", { name: "Expand Terminal" })); expect(setMode).toHaveBeenCalledWith("expanded"); @@ -471,7 +461,7 @@ describe("WorkspaceTerminalView embed props", () => { }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); await fireEvent.click(screen.getByRole("button", { name: "Collapse Terminal" })); expect(setMode).toHaveBeenCalledWith("collapsed"); @@ -553,12 +543,12 @@ describe("WorkspaceTerminalView embed props", () => { props: { workspaceId: "ws-1", hideWorkspaceList: true, inlineDock }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); await rerender({ workspaceId: "ws-2", hideWorkspaceList: true, inlineDock }); await waitFor(() => expect(screen.getByText("boom")).toBeTruthy()); - expect(screen.queryByText("feature/embed-props")).toBeNull(); + expect(screen.queryByText("feature/pane-props")).toBeNull(); const collapse = screen.getByRole("button", { name: "Collapse Terminal" }); expect(collapse.hasAttribute("disabled")).toBe(false); @@ -578,12 +568,12 @@ describe("WorkspaceTerminalView embed props", () => { props: { workspaceId: "ws-1", hideWorkspaceList: true, inlineDock }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); await rerender({ workspaceId: "ws-2", hideWorkspaceList: true, inlineDock }); await waitFor(() => expect(screen.getByText("Setting up workspace...")).toBeTruthy()); - expect(screen.queryByText("feature/embed-props")).toBeNull(); + expect(screen.queryByText("feature/pane-props")).toBeNull(); expect(screen.getByRole("button", { name: "Collapse Terminal" })).toBeTruthy(); }); @@ -608,7 +598,7 @@ describe("WorkspaceTerminalView embed props", () => { }, }); - await waitFor(() => expect(screen.getAllByText("feature/embed-props").length).toBeGreaterThan(0)); + await waitFor(() => expect(screen.getAllByText("feature/pane-props").length).toBeGreaterThan(0)); const expandButton = screen.getByRole("button", { name: "Expand Terminal" }); expect(expandButton.hasAttribute("disabled")).toBe(false); diff --git a/frontend/src/lib/components/terminal/XtermTerminalPane.svelte b/frontend/src/lib/components/terminal/XtermTerminalPane.svelte index 0c7a54f1d5..97c53c448c 100644 --- a/frontend/src/lib/components/terminal/XtermTerminalPane.svelte +++ b/frontend/src/lib/components/terminal/XtermTerminalPane.svelte @@ -17,7 +17,6 @@ import { createWorkspaceSwitchPaneTimer } from "../../instrumentation/workspaceSwitchTiming.js"; import { traceHeadersForRequest } from "../../instrumentation/traceContext.js"; import { createTerminalPastePayload } from "./bracketedPaste.js"; - import { embeddedWebSocketUrl } from "./embeddedWebSocket.js"; import { parseOsc52ClipboardWrite } from "./osc52Clipboard.js"; import { createBrowserTerminalClipboardPort, @@ -262,8 +261,7 @@ } if (url.protocol !== "http:" && url.protocol !== "https:") return; - // Open on the client while the click's user activation is live. Embedded - // hosts can handle this window request with their system browser opener. + // Open on the client while the click's user activation is live. window.open(url.href, "_blank", "noopener,noreferrer"); } @@ -712,8 +710,6 @@ if (/^wss?:\/\//.test(withConnectionParams)) { return withConnectionParams; } - const embeddedUrl = embeddedWebSocketUrl(withBasePath(withConnectionParams)); - if (embeddedUrl) return embeddedUrl; const devUrl = buildDevApiWsUrl(withConnectionParams); if (devUrl) return devUrl; const proto = location.protocol === "https:" ? "wss" : "ws"; diff --git a/frontend/src/lib/components/terminal/embeddedWebSocket.ts b/frontend/src/lib/components/terminal/embeddedWebSocket.ts deleted file mode 100644 index 5633fac24f..0000000000 --- a/frontend/src/lib/components/terminal/embeddedWebSocket.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function embeddedWebSocketUrl(path: string): string | null { - const raw = window.__KENN_EMBEDDED_WEBSOCKET_BASE_URL__?.trim(); - if (!raw) return null; - - try { - const base = new URL(raw); - if (base.protocol !== "ws:" && base.protocol !== "wss:") return null; - const requested = new URL(path, "http://forge.local"); - const basePath = base.pathname.replace(/\/$/, ""); - base.pathname = `${basePath}${requested.pathname}`; - base.search = requested.search; - base.hash = ""; - return base.toString(); - } catch { - return null; - } -} diff --git a/frontend/src/lib/components/terminal/project-card-workflow.ts b/frontend/src/lib/components/terminal/project-card-workflow.ts deleted file mode 100644 index 8434f15ccd..0000000000 --- a/frontend/src/lib/components/terminal/project-card-workflow.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Effect, Schema } from "effect"; -import type { ProjectResponse, WorktreeResponse } from "../../api/generated/models/index.js"; - -import { InvalidExternalPayload, type ApiProblemError, type TransientTransportError } from "../../api/effect-errors.js"; -import { executeGeneratedApiRequest, executeOpaqueGeneratedApiRequest } from "../../api/generated-api.js"; -import { decodeProjectResponse } from "../../api/project-intake.js"; - -export type WorkspaceProject = ProjectResponse; -export type WorkspaceProjectWorktree = WorktreeResponse; - -export interface ProjectCardSnapshot { - readonly project: WorkspaceProject; - readonly worktrees: readonly WorkspaceProjectWorktree[]; -} - -export type ProjectCardFailure = ApiProblemError | InvalidExternalPayload | TransientTransportError; - -export function projectCardFailureMessage(failure: ProjectCardFailure): string { - switch (failure._tag) { - case "ApiProblemError": - return failure.problem.detail ?? failure.problem.title ?? "Couldn't load this project."; - case "InvalidExternalPayload": - return "The project service returned an invalid response."; - case "TransientTransportError": - return "Couldn't reach the project service."; - } -} - -const Worktree = Schema.Struct({ - $schema: Schema.optionalKey(Schema.String), - branch: Schema.String, - created_at: Schema.String, - id: Schema.String, - is_hidden: Schema.Boolean, - is_primary: Schema.Boolean, - linked_issue_numbers: Schema.NullOr(Schema.Array(Schema.Number)), - path: Schema.String, - project_id: Schema.String, - session_backend: Schema.String, - updated_at: Schema.String, -}); - -const WorktreeList = Schema.Struct({ - $schema: Schema.optionalKey(Schema.String), - worktrees: Schema.NullOr(Schema.Array(Worktree)), -}); - -const decodeWorktreeList = Effect.fn("ProjectCard.decodeWorktreeList")(function* (input: unknown) { - const decoded = yield* Schema.decodeUnknownEffect(WorktreeList)(input).pipe( - Effect.mapError((cause) => InvalidExternalPayload.make({ operation: "decode fleet project worktrees", cause })), - ); - const worktrees: readonly WorkspaceProjectWorktree[] = (decoded.worktrees ?? []).map((worktree) => ({ - ...worktree, - linked_issue_numbers: [...(worktree.linked_issue_numbers ?? [])], - })); - return worktrees; -}); - -export const loadProjectCardSnapshot = Effect.fn("ProjectCard.loadSnapshot")(function* ( - projectId: string, - hostKey?: string, -) { - const project = hostKey - ? yield* executeOpaqueGeneratedApiRequest("load fleet project", (client, signal) => - client.FleetService.getFleetProject({ hostKey: hostKey, projectId: projectId }, { signal }), - ).pipe(Effect.flatMap(decodeProjectResponse)) - : yield* executeGeneratedApiRequest("load project", (client, signal) => - client.ProjectsService.getProject({ projectId: projectId }, { signal }), - ); - - const worktrees = hostKey - ? yield* executeOpaqueGeneratedApiRequest("load fleet project worktrees", (client, signal) => - client.FleetService.listFleetProjectWorktrees({ hostKey: hostKey, projectId: projectId }, { signal }), - ).pipe(Effect.flatMap(decodeWorktreeList)) - : yield* executeGeneratedApiRequest("load project worktrees", (client, signal) => - client.ProjectsService.listWorktrees({ projectId: projectId }, { signal }), - ).pipe(Effect.map((data) => data.worktrees ?? [])); - - return { project, worktrees } satisfies ProjectCardSnapshot; -}); diff --git a/frontend/src/lib/components/terminal/project-mutation-workflow.test.ts b/frontend/src/lib/components/terminal/project-mutation-workflow.test.ts index a165106e98..d2ca9b68c8 100644 --- a/frontend/src/lib/components/terminal/project-mutation-workflow.test.ts +++ b/frontend/src/lib/components/terminal/project-mutation-workflow.test.ts @@ -1,93 +1,8 @@ -import { layer } from "@effect/vitest"; -import { Effect, Fiber, Layer, Option } from "effect"; -import { describe, expect, it, vi } from "vite-plus/test"; -import { GeneratedApiLive } from "../../api/generated-api.js"; -import type { ProjectActionHook } from "../../stores/embed-config.svelte.js"; -import { - newWorktreeMutationKey, - ProjectMutationWorkflow, - ProjectMutationWorkflowLive, - projectMutationKey, -} from "./project-mutation-workflow.js"; - -const TestLayer = Layer.provide(ProjectMutationWorkflowLive, GeneratedApiLive); +import { expect, it } from "vite-plus/test"; +import { projectMutationKey } from "./project-mutation-workflow.js"; it("distinguishes the self host from a fleet host named local", () => { expect(projectMutationKey("register", undefined, ["/srv/repo"])).not.toBe( projectMutationKey("register", "local", ["/srv/repo"]), ); - expect(newWorktreeMutationKey("prj_1")).not.toBe(newWorktreeMutationKey("prj_1", "local")); -}); - -describe("ProjectMutationWorkflow", () => { - layer(TestLayer)((it) => { - it.effect("retains an accepted worktree acknowledgement when its first waiter is interrupted", () => - Effect.gen(function* () { - let startAction: (() => void) | undefined; - let completeAction: ((result: CommandResult) => void) | undefined; - const started = new Promise((resolve) => { - startAction = resolve; - }); - const pending = new Promise((resolve) => { - completeAction = resolve; - }); - const handler = vi.fn(() => { - startAction?.(); - return pending; - }); - const action: ProjectActionHook = { - id: "new-worktree", - label: "New Worktree", - handler, - }; - const workflow = yield* ProjectMutationWorkflow; - const command = { - key: "new-worktree\0local\0prj_1", - action, - context: { surface: "project-card", projectId: "prj_1" }, - }; - - const firstAcknowledgement = yield* workflow.acceptNewWorktree(command); - const firstWaiter = yield* Effect.forkChild(firstAcknowledgement); - yield* Effect.promise(() => started); - yield* Fiber.interrupt(firstWaiter); - yield* Effect.sync(() => completeAction?.({ ok: true })); - - const retainedAcknowledgement = yield* workflow.acceptNewWorktree(command); - const result = yield* retainedAcknowledgement; - - expect(result).toEqual({ ok: true }); - expect(handler).toHaveBeenCalledOnce(); - }), - ); - - it.effect("does not let an older owner forget a replacement worktree command", () => - Effect.gen(function* () { - const firstHandler = vi.fn(() => Promise.resolve({ ok: true })); - const secondHandler = vi.fn(() => Promise.resolve({ ok: true })); - const workflow = yield* ProjectMutationWorkflow; - const key = "new-worktree\0local\0prj_1"; - const firstAcknowledgement = yield* workflow.acceptNewWorktree({ - key, - action: { id: "new-worktree", label: "New Worktree", handler: firstHandler }, - context: { surface: "project-card", projectId: "prj_1" }, - }); - yield* firstAcknowledgement; - yield* workflow.forgetNewWorktree(key, firstAcknowledgement); - - const secondAcknowledgement = yield* workflow.acceptNewWorktree({ - key, - action: { id: "new-worktree", label: "New Worktree", handler: secondHandler }, - context: { surface: "project-card", projectId: "prj_1" }, - }); - yield* workflow.forgetNewWorktree(key, firstAcknowledgement); - - const retained = yield* workflow.retainedNewWorktree(key); - expect(Option.isSome(retained)).toBe(true); - if (Option.isSome(retained)) expect(retained.value).toBe(secondAcknowledgement); - yield* secondAcknowledgement; - expect(secondHandler).toHaveBeenCalledOnce(); - }), - ); - }); }); diff --git a/frontend/src/lib/components/terminal/project-mutation-workflow.ts b/frontend/src/lib/components/terminal/project-mutation-workflow.ts index 35d40c78a7..bcc459a691 100644 --- a/frontend/src/lib/components/terminal/project-mutation-workflow.ts +++ b/frontend/src/lib/components/terminal/project-mutation-workflow.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer, Option, Ref, Semaphore } from "effect"; +import { Context, Effect, Layer, Ref, Semaphore } from "effect"; import type { Scope } from "effect/Scope"; import { GeneratedApi } from "../../api/generated-api.js"; import { @@ -9,18 +9,6 @@ import { type ProjectResponse, } from "../../api/project-intake.js"; import { CommandQueueClosed, makeOrderedCommandQueue } from "../../effect/ordered-command-queue.js"; -import { - emitWorkspaceCommand, - InvalidEmbeddingAcknowledgement, - invokeProjectAction, - type ProjectActionContext, - type ProjectActionHook, -} from "../../stores/embed-config.svelte.js"; - -export interface RegisteredProject { - readonly project: ProjectResponse; - readonly acknowledgement: CommandResult; -} export interface RegisterExistingProjectCommand { readonly key: string; @@ -36,18 +24,11 @@ export interface CloneProjectCommand { readonly hostKey?: string | undefined; } -export interface NewWorktreeCommand { - readonly key: string; - readonly action: ProjectActionHook; - readonly context: ProjectActionContext; -} - type ProjectIntakeCommand = | ({ readonly _tag: "RegisterExisting" } & RegisterExistingProjectCommand) | ({ readonly _tag: "Clone" } & CloneProjectCommand); -export type ProjectMutationFailure = ProjectIntakeFailure | InvalidEmbeddingAcknowledgement | CommandQueueClosed; -export type NewWorktreeFailure = InvalidEmbeddingAcknowledgement | CommandQueueClosed; +export type ProjectMutationFailure = ProjectIntakeFailure | CommandQueueClosed; interface RetainedCommand { readonly key: string; @@ -58,11 +39,6 @@ interface RetainedCommandQueue { input: Input, ) => Effect.Effect, CommandQueueClosed>; readonly forget: (key: string) => Effect.Effect; - readonly forgetIfCurrent: ( - key: string, - acknowledgement: Effect.Effect, - ) => Effect.Effect; - readonly retained: (key: string) => Effect.Effect>>; } const makeRetainedCommandQueue = ( @@ -87,7 +63,6 @@ const makeRetainedCommandQueue = Ref.get(retained).pipe(Effect.map((current) => Option.fromNullishOr(current.get(key)))), forget: (key) => Ref.update(retained, (current) => { if (!current.has(key)) return current; @@ -95,13 +70,6 @@ const makeRetainedCommandQueue = - Ref.update(retained, (current) => { - if (current.get(key) !== acknowledgement) return current; - const next = new Map(current); - next.delete(key); - return next; - }), }; }); @@ -110,21 +78,11 @@ export class ProjectMutationWorkflow extends Context.Service< { readonly acceptRegisterExisting: ( command: RegisterExistingProjectCommand, - ) => Effect.Effect, CommandQueueClosed>; + ) => Effect.Effect, CommandQueueClosed>; readonly acceptClone: ( command: CloneProjectCommand, - ) => Effect.Effect, CommandQueueClosed>; - readonly acceptNewWorktree: ( - command: NewWorktreeCommand, - ) => Effect.Effect, CommandQueueClosed>; - readonly retainedNewWorktree: ( - key: string, - ) => Effect.Effect>>; + ) => Effect.Effect, CommandQueueClosed>; readonly forgetProject: (key: string) => Effect.Effect; - readonly forgetNewWorktree: ( - key: string, - acknowledgement: Effect.Effect, - ) => Effect.Effect; } >()("kenn-forge/ProjectMutationWorkflow") {} @@ -144,46 +102,16 @@ export const ProjectMutationWorkflowLive = Layer.effect(ProjectMutationWorkflow) return project; }), ); - const worktrees = yield* makeRetainedCommandQueue( - "project worktree mutations", - Effect.fn("ProjectMutationWorkflow.executeNewWorktree")(function* (command: NewWorktreeCommand) { - return yield* invokeProjectAction(command.action, command.context); - }), - ); - - const acknowledgeProject = ( - command: RegisterExistingProjectCommand | CloneProjectCommand, - project: ProjectResponse, - ) => { - const payload: Record = { projectId: project.id }; - if (command.hostKey) payload.hostKey = command.hostKey; - return emitWorkspaceCommand("project-registered", payload).pipe( - Effect.map((acknowledgement) => ({ project, acknowledgement })), - ); - }; - - const acceptProject = ( - command: ProjectIntakeCommand, - ): Effect.Effect, CommandQueueClosed> => - intake - .accept(command) - .pipe( - Effect.map((committed) => committed.pipe(Effect.flatMap((project) => acknowledgeProject(command, project)))), - ); - return { acceptRegisterExisting: (command: RegisterExistingProjectCommand) => - acceptProject({ ...command, _tag: "RegisterExisting" }), - acceptClone: (command: CloneProjectCommand) => acceptProject({ ...command, _tag: "Clone" }), - acceptNewWorktree: worktrees.accept, - retainedNewWorktree: worktrees.retained, + intake.accept({ ...command, _tag: "RegisterExisting" }), + acceptClone: (command: CloneProjectCommand) => intake.accept({ ...command, _tag: "Clone" }), forgetProject: intake.forget, - forgetNewWorktree: worktrees.forgetIfCurrent, }; }), ); -export function projectMutationFailureMessage(failure: ProjectMutationFailure, fallback: string): string { +export function projectMutationFailureMessage(failure: ProjectMutationFailure): string { switch (failure._tag) { case "ApiProblemError": case "InvalidExternalPayload": @@ -192,8 +120,6 @@ export function projectMutationFailureMessage(failure: ProjectMutationFailure, f return projectIntakeFailureMessage(failure); case "CommandQueueClosed": return "The project request stopped before it completed."; - case "InvalidEmbeddingAcknowledgement": - return fallback; } } @@ -204,7 +130,3 @@ export function projectMutationKey( ): string { return JSON.stringify([kind, hostKey ?? null, ...values.map((value) => value.trim())]); } - -export function newWorktreeMutationKey(projectId: string, hostKey?: string): string { - return JSON.stringify(["new-worktree", hostKey ?? null, projectId]); -} diff --git a/frontend/src/lib/context.ts b/frontend/src/lib/context.ts index 58792c433f..8216099dfd 100644 --- a/frontend/src/lib/context.ts +++ b/frontend/src/lib/context.ts @@ -1,28 +1,14 @@ import { getContext } from "svelte"; -import type { - ActionRegistry, - NavigateCallback, - HostStateAccessors, - StoreInstances, - UIConfig, - SidebarAccessors, -} from "./types.js"; +import type { NavigateCallback, HostStateAccessors, StoreInstances, UIConfig, SidebarAccessors } from "./types.js"; import type { RoborevClient } from "./api/roborev/client.js"; -export const ACTIONS_KEY = Symbol("kenn-forge-actions"); export const NAVIGATE_KEY = Symbol("kenn-forge-navigate"); -export const EVENT_KEY = Symbol("kenn-forge-event"); -export const PREPARE_ROUTE_KEY = Symbol("kenn-forge-prepare-route"); -export const WORKSPACE_COMMAND_KEY = Symbol("kenn-forge-workspace-command"); export const WORKSPACE_DELETED_KEY = Symbol("kenn-forge-workspace-deleted"); export const STORES_KEY = Symbol("kenn-forge-stores"); export const UI_CONFIG_KEY = Symbol("kenn-forge-ui-config"); export const SIDEBAR_KEY = Symbol("kenn-forge-sidebar"); export const HOST_STATE_KEY = Symbol("kenn-forge-host-state"); -export function getActions(): ActionRegistry { - return getContext(ACTIONS_KEY); -} export function getNavigate(): NavigateCallback { return getContext(NAVIGATE_KEY); } diff --git a/frontend/src/lib/stores/embed-config.svelte.test.ts b/frontend/src/lib/stores/embed-config.svelte.test.ts deleted file mode 100644 index a3cbc90214..0000000000 --- a/frontend/src/lib/stores/embed-config.svelte.test.ts +++ /dev/null @@ -1,468 +0,0 @@ -import { Effect, Exit } from "effect"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { makeAppRuntime, type OwnedAppRuntime } from "../app/runtime.js"; -import { - isEmbedded, - getThemeMode, - getThemeColors, - getThemeFonts, - getThemeRadii, - getUIConfig, - getPullRequestActions, - getIssueActions, - invokeAction, - getOnNavigate, - getProjectActions, - getProjectAction, - invokeProjectAction, - emitWorkspaceCommand, - getToolingStatus, - initWorkspaceBridge, - emitLayoutChanged, -} from "./embed-config.svelte.js"; -import type { ActionHook, ProjectActionHook } from "./embed-config.svelte.js"; - -const win = window as any; -let runtime: OwnedAppRuntime; - -beforeEach(() => { - runtime = makeAppRuntime(); -}); - -afterEach(async () => { - delete win.__kenn_forge_config; - vi.useRealTimers(); - await Effect.runPromise(runtime.disposeEffect); -}); - -describe("isEmbedded", () => { - it("returns false when no config set", () => { - expect(isEmbedded()).toBe(false); - }); - - it("returns true when the embed block is present", () => { - win.__kenn_forge_config = { embed: {} }; - win.__kenn_forge_notify_config_changed(); - expect(isEmbedded()).toBe(true); - }); - - it("daemon-served ui-only config does not imply embedding", () => { - win.__kenn_forge_config = { - ui: { activeWorktreeKey: "wt-1" }, - }; - win.__kenn_forge_notify_config_changed(); - expect(isEmbedded()).toBe(false); - }); -}); - -describe("theme config", () => { - it("returns undefined mode when not set", () => { - expect(getThemeMode()).toBeUndefined(); - }); - - it("returns mode from config", () => { - win.__kenn_forge_config = { theme: { mode: "dark" } }; - win.__kenn_forge_notify_config_changed(); - expect(getThemeMode()).toBe("dark"); - }); - - it("returns partial colors", () => { - win.__kenn_forge_config = { - theme: { colors: { bgPrimary: "#111" } }, - }; - win.__kenn_forge_notify_config_changed(); - expect(getThemeColors()?.bgPrimary).toBe("#111"); - }); - - it("returns fonts", () => { - win.__kenn_forge_config = { - theme: { fonts: { sans: "SF Pro" } }, - }; - win.__kenn_forge_notify_config_changed(); - expect(getThemeFonts()?.sans).toBe("SF Pro"); - }); - - it("returns radii", () => { - win.__kenn_forge_config = { - theme: { radii: { sm: "2px" } }, - }; - win.__kenn_forge_notify_config_changed(); - expect(getThemeRadii()?.sm).toBe("2px"); - }); -}); - -describe("UI config", () => { - it("returns defaults when not set", () => { - const ui = getUIConfig(); - expect(ui.hideSync).toBe(false); - expect(ui.hideRepoSelector).toBe(false); - expect(ui.hideStar).toBe(false); - expect(ui.sidebarCollapsed).toBeUndefined(); - expect(ui.repo).toBeUndefined(); - }); - - it("reads flags from config", () => { - win.__kenn_forge_config = { - ui: { hideSync: true, repo: { owner: "a", name: "b" } }, - }; - win.__kenn_forge_notify_config_changed(); - const ui = getUIConfig(); - expect(ui.hideSync).toBe(true); - expect(ui.repo?.owner).toBe("a"); - }); -}); - -describe("reset semantics", () => { - it("reverts to defaults when properties removed", () => { - win.__kenn_forge_config = { - theme: { mode: "dark" }, - ui: { hideSync: true }, - }; - win.__kenn_forge_notify_config_changed(); - expect(getThemeMode()).toBe("dark"); - expect(getUIConfig().hideSync).toBe(true); - - // Remove properties and notify - delete win.__kenn_forge_config.theme; - delete win.__kenn_forge_config.ui; - win.__kenn_forge_notify_config_changed(); - expect(getThemeMode()).toBeUndefined(); - expect(getUIConfig().hideSync).toBe(false); - }); -}); - -describe("actions (migrated from hooks)", () => { - it("returns empty arrays when no actions", () => { - expect(getPullRequestActions()).toEqual([]); - expect(getIssueActions()).toEqual([]); - }); - - it("returns PR actions from config", () => { - const handler = vi.fn(); - win.__kenn_forge_config = { - actions: { - pullRequest: [{ id: "pr1", label: "Test", handler }], - }, - }; - win.__kenn_forge_notify_config_changed(); - const actions = getPullRequestActions(); - expect(actions).toHaveLength(1); - expect(actions[0]!.id).toBe("pr1"); - }); - - it("returns issue actions from config", () => { - const handler = vi.fn(); - win.__kenn_forge_config = { - actions: { - issue: [{ id: "iss1", label: "Issue", handler }], - }, - }; - win.__kenn_forge_notify_config_changed(); - expect(getIssueActions()).toHaveLength(1); - }); - - it("picks up in-place mutation via notify", () => { - const config = { actions: { issue: [] as ActionHook[] } }; - win.__kenn_forge_config = config; - win.__kenn_forge_notify_config_changed(); - expect(getIssueActions()).toHaveLength(0); - - config.actions.issue.push({ - id: "mut", - label: "Mutated", - handler: vi.fn(), - }); - win.__kenn_forge_notify_config_changed(); - expect(getIssueActions()).toHaveLength(1); - }); -}); - -describe("invokeAction", () => { - it("passes correct context to handler", () => { - const handler = vi.fn(); - const action: ActionHook = { id: "a", label: "A", handler }; - invokeAction(runtime, action, { - surface: "pull-detail", - owner: "org", - name: "repo", - number: 42, - }); - expect(handler).toHaveBeenCalledWith({ - surface: "pull-detail", - owner: "org", - name: "repo", - number: 42, - }); - }); - - it("catches sync errors from handler", () => { - const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - const action: ActionHook = { - id: "b", - label: "B", - handler: () => { - throw new Error("boom"); - }, - }; - invokeAction(runtime, action, { - surface: "test", - owner: "o", - name: "n", - number: 1, - }); - expect(spy).toHaveBeenCalledWith("Embedding action error:", expect.any(Error)); - spy.mockRestore(); - }); - - it("catches async errors from handler", async () => { - const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - const action: ActionHook = { - id: "c", - label: "C", - handler: () => Promise.reject(new Error("async boom")), - }; - invokeAction(runtime, action, { - surface: "test", - owner: "o", - name: "n", - number: 1, - }); - await vi.waitFor(() => { - expect(spy).toHaveBeenCalledWith("Embedding action error:", expect.any(Error)); - }); - spy.mockRestore(); - }); - - it("does not report a rejected handler after the application runtime is disposed", async () => { - let reject!: (cause: unknown) => void; - const pending = new Promise((_resolve, rejectPromise) => { - reject = rejectPromise; - }); - const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - invokeAction( - runtime, - { id: "late", label: "Late", handler: () => pending }, - { - surface: "test", - owner: "o", - name: "n", - number: 1, - }, - ); - - await Effect.runPromise(runtime.disposeEffect); - reject(new Error("late failure")); - await Promise.resolve(); - await Promise.resolve(); - - expect(spy).not.toHaveBeenCalled(); - spy.mockRestore(); - }); -}); - -describe("layout callback", () => { - it("debounces to the latest layout inside the application runtime", async () => { - vi.useFakeTimers(); - const handler = vi.fn(); - win.__kenn_forge_config = { onLayoutChanged: handler }; - win.__kenn_forge_notify_config_changed(); - emitLayoutChanged(runtime, { - sidebar: { width: 240 }, - pinnedPanel: { width: 0, visible: false }, - }); - emitLayoutChanged(runtime, { - sidebar: { width: 320 }, - pinnedPanel: { width: 0, visible: false }, - }); - - await vi.advanceTimersByTimeAsync(150); - - expect(handler).toHaveBeenCalledOnce(); - expect(handler).toHaveBeenCalledWith({ - sidebar: { width: 320 }, - pinnedPanel: { width: 0, visible: false }, - }); - }); -}); - -describe("onNavigate callback", () => { - it("returns undefined when not set", () => { - expect(getOnNavigate()).toBeUndefined(); - }); - - it("returns callback from config", () => { - const cb = vi.fn(); - win.__kenn_forge_config = { onNavigate: cb }; - win.__kenn_forge_notify_config_changed(); - expect(getOnNavigate()).toBe(cb); - }); - - it("reverts to undefined when removed", () => { - const cb = vi.fn(); - win.__kenn_forge_config = { onNavigate: cb }; - win.__kenn_forge_notify_config_changed(); - delete win.__kenn_forge_config.onNavigate; - win.__kenn_forge_notify_config_changed(); - expect(getOnNavigate()).toBeUndefined(); - }); -}); - -describe("project actions", () => { - it("returns empty array when not configured", () => { - expect(getProjectActions()).toEqual([]); - expect(getProjectAction("add-existing")).toBeUndefined(); - }); - - it("returns project actions from config", () => { - const handler = vi.fn().mockResolvedValue({ ok: true }); - win.__kenn_forge_config = { - actions: { - project: [{ id: "add-existing", label: "Add existing", handler }], - }, - }; - win.__kenn_forge_notify_config_changed(); - expect(getProjectActions()).toHaveLength(1); - expect(getProjectAction("add-existing")?.id).toBe("add-existing"); - expect(getProjectAction("missing")).toBeUndefined(); - }); -}); - -describe("invokeProjectAction", () => { - it("passes context to handler and returns its CommandResult", async () => { - const handler = vi.fn().mockResolvedValue({ ok: true }); - const action: ProjectActionHook = { - id: "clone", - label: "Clone", - handler, - }; - const result = await Effect.runPromise( - invokeProjectAction(action, { - surface: "first-run-panel", - }), - ); - expect(handler).toHaveBeenCalledWith({ - surface: "first-run-panel", - }); - expect(result).toEqual({ ok: true }); - }); - - it("propagates handler-supplied failure", async () => { - const action: ProjectActionHook = { - id: "clone", - label: "Clone", - handler: () => ({ ok: false, message: "auth failed" }), - }; - const result = await Effect.runPromise( - invokeProjectAction(action, { - surface: "first-run-panel", - }), - ); - expect(result).toEqual({ ok: false, message: "auth failed" }); - }); - - it("normalizes thrown errors into a failure result", async () => { - const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - const action: ProjectActionHook = { - id: "clone", - label: "Clone", - handler: () => { - throw new Error("boom"); - }, - }; - const result = await Effect.runPromise( - invokeProjectAction(action, { - surface: "first-run-panel", - }), - ); - expect(result).toEqual({ ok: false, message: "boom" }); - spy.mockRestore(); - }); - - it("normalizes async rejections into a failure result", async () => { - const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - const action: ProjectActionHook = { - id: "clone", - label: "Clone", - handler: () => Promise.reject(new Error("async boom")), - }; - const result = await Effect.runPromise( - invokeProjectAction(action, { - surface: "first-run-panel", - }), - ); - expect(result).toEqual({ ok: false, message: "async boom" }); - spy.mockRestore(); - }); - - it("rejects a malformed project action acknowledgement", async () => { - const action: ProjectActionHook = { - id: "clone", - label: "Clone", - handler: vi.fn().mockResolvedValue(undefined), - }; - - const result = await Effect.runPromise(Effect.exit(invokeProjectAction(action, { surface: "first-run-panel" }))); - - expect(Exit.isFailure(result)).toBe(true); - }); - - it("rejects a malformed workspace command acknowledgement", async () => { - win.__kenn_forge_config = { - onWorkspaceCommand: vi.fn().mockResolvedValue({ message: "missing ok" }), - }; - win.__kenn_forge_notify_config_changed(); - - const result = await Effect.runPromise( - Effect.exit(emitWorkspaceCommand("project-registered", { projectId: "prj_1" })), - ); - - expect(Exit.isFailure(result)).toBe(true); - }); -}); - -describe("tooling status", () => { - it("returns undefined when no tooling on embed config", () => { - expect(getToolingStatus()).toBeUndefined(); - }); - - it("returns tooling block when set", () => { - win.__kenn_forge_config = { - embed: { - tooling: { - git: { available: true, version: "2.45.0" }, - gh: { available: true, authenticated: false }, - }, - }, - }; - win.__kenn_forge_notify_config_changed(); - const tooling = getToolingStatus(); - expect(tooling?.git?.available).toBe(true); - expect(tooling?.gh?.authenticated).toBe(false); - }); - - it("__kenn_forge_update_tooling pushes new state and notifies", () => { - initWorkspaceBridge(); - win.__kenn_forge_config = {}; - win.__kenn_forge_notify_config_changed(); - expect(getToolingStatus()).toBeUndefined(); - - win.__kenn_forge_update_tooling({ - git: { available: false }, - gh: { available: false, authenticated: false }, - }); - expect(getToolingStatus()?.git?.available).toBe(false); - expect(getToolingStatus()?.gh?.authenticated).toBe(false); - }); - - it("__kenn_forge_update_tooling is a no-op when config is unset", () => { - initWorkspaceBridge(); - delete win.__kenn_forge_config; - expect(() => - win.__kenn_forge_update_tooling({ - git: { available: true }, - gh: { available: true, authenticated: true }, - }), - ).not.toThrow(); - expect(getToolingStatus()).toBeUndefined(); - }); -}); diff --git a/frontend/src/lib/stores/embed-config.svelte.ts b/frontend/src/lib/stores/embed-config.svelte.ts deleted file mode 100644 index f9f93a46c3..0000000000 --- a/frontend/src/lib/stores/embed-config.svelte.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { Effect, Schema } from "effect"; -import type { AppExecution, AppRuntime } from "../app/runtime.js"; -import { setGlobalRepo } from "../stores/filter.svelte.js"; - -class EmbeddingCallbackError extends Schema.TaggedError()("EmbeddingCallbackError", { - operation: Schema.String, - cause: Schema.Defect(), -}) {} - -export class InvalidEmbeddingAcknowledgement extends Schema.TaggedError()( - "InvalidEmbeddingAcknowledgement", - { - operation: Schema.String, - cause: Schema.Defect(), - }, -) {} - -const CommandResultSchema = Schema.Struct({ - ok: Schema.Boolean, - message: Schema.optionalKey(Schema.String), -}); - -const decodeCommandResult = Effect.fn("Embedding.decodeCommandResult")(function* (operation: string, input: unknown) { - return yield* Schema.decodeUnknownEffect(CommandResultSchema)(input).pipe( - Effect.mapError((cause) => InvalidEmbeddingAcknowledgement.make({ operation, cause })), - ); -}); - -// Bridge: repo filter (module-scope, not workspace-specific) -window.__kenn_forge_set_repo_filter = (repo: { owner: string; name: string } | null) => { - setGlobalRepo(repo ? `${repo.owner}/${repo.name}` : undefined); -}; - -export interface ActionHook { - id: string; - label: string; - handler: (context: ActionContext) => void | Promise; -} - -export interface ActionContext { - surface: string; - owner: string; - name: string; - number: number; - meta?: Record; -} - -export interface ProjectActionContext { - surface: string; - projectId?: string; - hostKey?: string; - meta?: Record; -} - -export interface ProjectActionHook { - id: string; - label: string; - handler: (context: ProjectActionContext) => CommandResult | Promise; -} - -// Re-export ToolingStatus from the global ambient module so .svelte -// files can import it explicitly. Lint in .svelte files does not pick -// up ambient globals declared in vite-env.d.ts. -export type ToolingStatusValue = ToolingStatus; - -type UIRepoConfig = NonNullable["repo"]>; - -interface UIDefaults { - hideSync: boolean; - hideRepoSelector: boolean; - hideStar: boolean; - sidebarCollapsed: boolean | undefined; - repo: UIRepoConfig | undefined; -} - -const UI_DEFAULTS: UIDefaults = { - hideSync: false, - hideRepoSelector: false, - hideStar: false, - sidebarCollapsed: undefined, - repo: undefined, -}; - -let _generation = $state(0); - -function readConfig(): ForgeConfig | undefined { - void _generation; // reactive dependency - return window.__kenn_forge_config; -} - -// Install the notify function on window. The embedder calls this -// after mutating window.__kenn_forge_config. -window.__kenn_forge_notify_config_changed = () => { - _generation++; -}; - -export function isEmbedded(): boolean { - // Embed mode is signaled by the embed block specifically: the - // daemon also serves a config carrying only daemon-side UI state - // (ui.activeWorktreeKey), which must not flip the standalone SPA - // into embedded behavior. - return readConfig()?.embed !== undefined; -} - -export function isHeaderHidden(): boolean { - return readConfig()?.embed?.hideHeader === true; -} - -export function isStatusBarHidden(): boolean { - return readConfig()?.embed?.hideStatusBar === true; -} - -export function getThemeMode(): "light" | "dark" | "system" | undefined { - return readConfig()?.theme?.mode; -} - -export function getThemeColors(): NonNullable["colors"]> | undefined { - return readConfig()?.theme?.colors; -} - -export function getThemeFonts(): NonNullable["fonts"]> | undefined { - return readConfig()?.theme?.fonts; -} - -export function getThemeRadii(): NonNullable["radii"]> | undefined { - return readConfig()?.theme?.radii; -} - -export function getUIConfig(): UIDefaults { - const ui = readConfig()?.ui; - if (!ui) return UI_DEFAULTS; - return { - hideSync: ui.hideSync ?? false, - hideRepoSelector: ui.hideRepoSelector ?? false, - hideStar: ui.hideStar ?? false, - sidebarCollapsed: ui.sidebarCollapsed, - repo: ui.repo, - }; -} - -export function getActiveWorktreeKey(): string | undefined { - return readConfig()?.ui?.activeWorktreeKey; -} - -export function getHost(): string | undefined { - return readConfig()?.ui?.host; -} - -export function getPullRequestActions(): ActionHook[] { - return readConfig()?.actions?.pullRequest ?? []; -} - -export function getIssueActions(): ActionHook[] { - return readConfig()?.actions?.issue ?? []; -} - -export function getProjectActions(): ProjectActionHook[] { - return readConfig()?.actions?.project ?? []; -} - -export function getProjectAction(id: string): ProjectActionHook | undefined { - return getProjectActions().find((action) => action.id === id); -} - -export function getToolingStatus(): ToolingStatus | undefined { - return readConfig()?.embed?.tooling; -} - -export function getOnNavigate(): ((event: ForgeNavigateEvent) => void) | undefined { - return readConfig()?.onNavigate; -} - -export function getOnRouteChange(): ((event: ForgeNavigateEvent) => void) | undefined { - return readConfig()?.onRouteChange; -} - -export function invokeAction(runtime: AppRuntime, action: ActionHook, context: ActionContext): void { - runtime.runCommand( - Effect.tryPromise({ - try: () => Promise.resolve(action.handler(context)), - catch: (cause) => EmbeddingCallbackError.make({ operation: `run embed action ${action.id}`, cause }), - }).pipe( - Effect.catchTag("EmbeddingCallbackError", (failure) => - Effect.sync(() => console.error("Embedding action error:", failure.cause)), - ), - ), - { operation: "run embedding action", safeContext: { actionId: action.id }, onFailure: () => {} }, - ); -} - -// Project actions remain an embedding callback boundary. The Effect owns the -// acknowledgement lifetime and turns thrown/rejected handlers into the same -// visible command result as an explicit host rejection. -export const invokeProjectAction = Effect.fn("Embedding.invokeProjectAction")(function* ( - action: ProjectActionHook, - context: ProjectActionContext, -) { - const result = yield* Effect.tryPromise({ - try: () => Promise.resolve(action.handler(context)), - catch: (cause) => EmbeddingCallbackError.make({ operation: `run project action ${action.id}`, cause }), - }).pipe( - Effect.catchTag("EmbeddingCallbackError", (failure) => - Effect.sync(() => { - const message = failure.cause instanceof Error ? failure.cause.message : String(failure.cause); - console.error(`Embedding project action "${action.id}" failed:`, failure.cause); - return { ok: false, message }; - }), - ), - ); - return yield* decodeCommandResult(`project action ${action.id}`, result); -}); - -export function getInitialRoute(): string | undefined { - return readConfig()?.embed?.initialRoute; -} - -export function getSidebarWidth(): number | undefined { - return readConfig()?.embed?.sidebarWidth; -} - -export function getOnLayoutChanged(): ForgeConfig["onLayoutChanged"] | undefined { - return readConfig()?.onLayoutChanged; -} - -let layoutExecution: AppExecution | undefined; - -export function emitLayoutChanged( - runtime: AppRuntime, - layout: { - sidebar: { width: number }; - pinnedPanel: { width: number; visible: boolean }; - }, -): void { - layoutExecution?.interrupt(); - layoutExecution = runtime.runCommand( - Effect.sleep("150 millis").pipe( - Effect.andThen( - Effect.try({ - try: () => getOnLayoutChanged()?.(layout), - catch: (cause) => EmbeddingCallbackError.make({ operation: "publish embed layout", cause }), - }), - ), - Effect.catchTag("EmbeddingCallbackError", (failure) => - Effect.sync(() => console.error("[kenn-forge] onLayoutChanged error:", failure.cause)), - ), - Effect.asVoid, - ), - { operation: "publish embedding layout", safeContext: {}, onFailure: () => {} }, - ); -} - -export function getWorkspaceData(): WorkspaceData | undefined { - return readConfig()?.workspace; -} - -export function getOnWorkspaceCommand(): WorkspaceCommandHandler | undefined { - return readConfig()?.onWorkspaceCommand; -} - -export const emitWorkspaceCommand = Effect.fn("Embedding.emitWorkspaceCommand")(function* ( - command: string, - payload: Record, -) { - const handler = getOnWorkspaceCommand(); - if (!handler) { - return { ok: true }; - } - const result = yield* Effect.tryPromise({ - try: () => Promise.resolve(handler(command, payload)), - catch: (cause) => EmbeddingCallbackError.make({ operation: `run workspace command ${command}`, cause }), - }).pipe( - Effect.catchTag("EmbeddingCallbackError", (failure) => - Effect.sync(() => { - const message = failure.cause instanceof Error ? failure.cause.message : String(failure.cause); - console.error(`[kenn-forge] workspace command "${command}" failed:`, failure.cause); - return { ok: false, message }; - }), - ), - ); - return yield* decodeCommandResult(`workspace command ${command}`, result); -}); - -export function initWorkspaceBridge(): void { - window.__kenn_forge_update_workspace = (data: WorkspaceData) => { - const config = window.__kenn_forge_config; - if (config) { - config.workspace = data; - window.__kenn_forge_notify_config_changed?.(); - } - }; - window.__kenn_forge_update_selection = (selection: { hostKey?: string | null; worktreeKey?: string | null }) => { - const config = window.__kenn_forge_config; - if (!config?.workspace) return; - const changingHost = "hostKey" in selection && selection.hostKey !== config.workspace.selectedHostKey; - const updated = { ...config.workspace }; - if ("hostKey" in selection) { - updated.selectedHostKey = selection.hostKey ?? null; - } - if ("worktreeKey" in selection) { - updated.selectedWorktreeKey = selection.worktreeKey ?? null; - } else if (changingHost) { - updated.selectedWorktreeKey = null; - } - config.workspace = updated; - window.__kenn_forge_notify_config_changed?.(); - }; - window.__kenn_forge_update_host_state = ( - hostKey: string, - patch: { - connectionState?: WorkspaceHost["connectionState"]; - resources?: WorkspaceResources | null; - }, - ) => { - const config = window.__kenn_forge_config; - if (!config?.workspace) return; - const hostIdx = config.workspace.hosts.findIndex((h) => h.key === hostKey); - if (hostIdx < 0) return; - const host = config.workspace.hosts[hostIdx]!; - const updated = { ...host }; - if ("connectionState" in patch) { - updated.connectionState = patch.connectionState!; - } - if ("resources" in patch) { - updated.resources = patch.resources ?? null; - } - const hosts = [...config.workspace.hosts]; - hosts[hostIdx] = updated; - config.workspace = { ...config.workspace, hosts }; - window.__kenn_forge_notify_config_changed?.(); - }; - window.__kenn_forge_update_tooling = (tooling: ToolingStatus) => { - const config = window.__kenn_forge_config; - if (!config) return; - const embed = { ...(config.embed ?? {}), tooling }; - config.embed = embed; - window.__kenn_forge_notify_config_changed?.(); - }; -} diff --git a/frontend/src/lib/stores/filter.svelte.ts b/frontend/src/lib/stores/filter.svelte.ts index a7491a977b..d2c84a1402 100644 --- a/frontend/src/lib/stores/filter.svelte.ts +++ b/frontend/src/lib/stores/filter.svelte.ts @@ -74,29 +74,3 @@ export function clearGlobalRepoPresetAffinity(name?: string): void { if (name !== undefined && filterRepoPresetAffinity?.toLowerCase() !== name.trim().toLowerCase()) return; setPresetAffinity(undefined); } - -export function applyConfigRepo( - repo: - | { - provider?: string; - host?: string; - platform_host?: string; - repo_path?: string; - owner?: string; - name?: string; - } - | undefined, - hideSelector: boolean, -): void { - if (hideSelector) { - setPresetAffinity(undefined); - const provider = repo?.provider?.trim(); - const host = (repo?.platform_host ?? repo?.host)?.trim(); - const repoPath = (repo?.repo_path ?? (repo?.owner && repo.name ? `${repo.owner}/${repo.name}` : "")).trim(); - if (provider && host && repoPath) { - filterRepo = `${provider}|${host}/${repoPath}`; - } else { - filterRepo = undefined; - } - } -} diff --git a/frontend/src/lib/stores/keyboard/actions.test.ts b/frontend/src/lib/stores/keyboard/actions.test.ts index 42bcb283fd..5432793797 100644 --- a/frontend/src/lib/stores/keyboard/actions.test.ts +++ b/frontend/src/lib/stores/keyboard/actions.test.ts @@ -103,7 +103,6 @@ describe("defaultActions", () => { afterEach(() => { setSidebarCollapsed(false); resetNewWorkspaceDialogState(); - delete window.__kenn_forge_config; window.history.replaceState(null, "", "/"); }); @@ -421,181 +420,6 @@ describe("defaultActions", () => { action.handler(issueContext); expect(locationPath()).toBe("/repo/browser?provider=forgejo&platform_host=code.example.com&repo_path=team%2Fdocs"); }); - - it("opens the repo browser for a uniquely configured workspace repo", () => { - const action = command("repo.browser.open"); - window.__kenn_forge_config = { - ui: { - repo: { owner: "acme", name: "widgets" }, - }, - }; - setConfiguredRepos([ - configuredRepo({ - owner: "acme", - name: "widgets", - repoPath: "acme/widgets", - }), - ]); - const context = ctx("workspaces", { selectedPR: staleSelected }); - - expect(action.when(context)).toBe(true); - action.handler(context); - - expect(locationPath()).toBe("/repo/browser?provider=github&platform_host=github.com&repo_path=acme%2Fwidgets"); - }); - - it("opens the repo browser from fully qualified workspace repo config", () => { - const action = command("repo.browser.open"); - window.__kenn_forge_config = { - ui: { - repo: { - provider: "gitea", - platform_host: "code.example.com", - repo_path: "team/widgets", - owner: "acme", - name: "widgets", - }, - }, - }; - setConfiguredRepos([ - configuredRepo({ - provider: "github", - platformHost: "github.com", - owner: "acme", - name: "widgets", - repoPath: "acme/widgets", - }), - configuredRepo({ - provider: "gitlab", - platformHost: "gitlab.example.com", - owner: "acme", - name: "widgets", - repoPath: "acme/widgets", - }), - ]); - const context = ctx("workspaces", { selectedPR: staleSelected }); - - expect(action.when(context)).toBe(true); - action.handler(context); - - expect(locationPath()).toBe("/repo/browser?provider=gitea&platform_host=code.example.com&repo_path=team%2Fwidgets"); - }); - - it("opens the repo browser from canonical workspace repo identity", () => { - const action = command("repo.browser.open"); - window.__kenn_forge_config = { - ui: { - repo: { - provider: "gitlab", - platform_host: "gitlab.example.com", - repo_path: "group/subgroup/widgets", - }, - }, - }; - setConfiguredRepos([]); - const context = ctx("workspaces", { selectedPR: staleSelected }); - - expect(action.when(context)).toBe(true); - action.handler(context); - - expect(locationPath()).toBe( - "/repo/browser?provider=gitlab&platform_host=gitlab.example.com&repo_path=group%2Fsubgroup%2Fwidgets", - ); - }); - - it("uses workspace provider and host hints when matching configured repos", () => { - const action = command("repo.browser.open"); - window.__kenn_forge_config = { - ui: { - repo: { - provider: "gitlab", - platform_host: "gitlab.example.com", - owner: "acme", - name: "widgets", - }, - }, - }; - setConfiguredRepos([ - configuredRepo({ - provider: "github", - platformHost: "github.com", - owner: "acme", - name: "widgets", - repoPath: "acme/widgets", - }), - configuredRepo({ - provider: "gitlab", - platformHost: "gitlab.example.com", - owner: "acme", - name: "widgets", - repoPath: "group/widgets", - }), - ]); - const context = ctx("workspaces", { selectedPR: staleSelected }); - - expect(action.when(context)).toBe(true); - action.handler(context); - - expect(locationPath()).toBe( - "/repo/browser?provider=gitlab&platform_host=gitlab.example.com&repo_path=group%2Fwidgets", - ); - }); - - it("hides the repo browser command when workspace repo context is ambiguous", () => { - const action = command("repo.browser.open"); - window.__kenn_forge_config = { - ui: { - repo: { owner: "acme", name: "widgets" }, - }, - }; - setConfiguredRepos([ - configuredRepo({ - owner: "acme", - name: "widgets", - platformHost: "github.com", - }), - configuredRepo({ - owner: "acme", - name: "widgets", - platformHost: "ghe.example.com", - }), - ]); - - expect(action.when(ctx("workspaces"))).toBe(false); - }); - - it("offers New workspace everywhere and seeds the workspace-page repo", () => { - const action = command("workspace.new"); - expect(action.when(ctx("pulls"))).toBe(true); - - // From a PR/issue page there is no workspace repo context, so the dialog - // opens unseeded and the user picks a repository. - action.handler(ctx("pulls")); - expect(isNewWorkspaceDialogOpen()).toBe(true); - expect(getNewWorkspaceSeedRepo()).toBeNull(); - - resetNewWorkspaceDialogState(); - window.__kenn_forge_config = { - ui: { - repo: { - provider: "gitea", - platform_host: "code.example.com", - repo_path: "acme/widgets", - owner: "acme", - name: "widgets", - }, - }, - }; - - action.handler(ctx("workspaces")); - expect(isNewWorkspaceDialogOpen()).toBe(true); - expect(getNewWorkspaceSeedRepo()).toEqual({ - provider: "gitea", - platformHost: "code.example.com", - owner: "acme", - name: "widgets", - }); - }); }); describe("session pane commands", () => { diff --git a/frontend/src/lib/stores/keyboard/actions.ts b/frontend/src/lib/stores/keyboard/actions.ts index 52a110192b..5ac644561c 100644 --- a/frontend/src/lib/stores/keyboard/actions.ts +++ b/frontend/src/lib/stores/keyboard/actions.ts @@ -1,6 +1,5 @@ import { getDetailTab, getSelectedPRFromRoute, navigate, replaceUrl } from "../router.svelte.js"; -import { getUIConfig } from "../embed-config.svelte.js"; -import { isSidebarToggleEnabled, toggleSidebar } from "../sidebar.svelte.js"; +import { toggleSidebar } from "../sidebar.svelte.js"; import { toggleTheme } from "../theme.svelte.js"; import { toggleCheatsheet } from "./cheatsheet-state.svelte.js"; import { openNewWorkspaceDialog } from "../new-workspace.svelte.js"; @@ -22,7 +21,6 @@ import { import { isSessionPaneKey } from "../session-pane-key.js"; import { requestSessionFocus } from "../session-host.svelte.js"; import { activeHostedSession, hostedSessionRegistryKey, hostedWorkspaceLauncher } from "../workspace-host.svelte.js"; -import type { ConfigRepo } from "../../api/types.js"; import type { StoreInstances } from "../../types.js"; import type { Action, Context, PreviewBlock } from "./types.js"; import { parseActivitySelection } from "../../utils/activitySelection.js"; @@ -139,15 +137,6 @@ function cleanRepoPath(repoPath: string | undefined): string { return (repoPath ?? "").replace(/^\/+|\/+$/g, ""); } -function repoIdentityFromPath(repoPath: string): { owner: string; name: string } | null { - const separator = repoPath.lastIndexOf("/"); - if (separator <= 0 || separator === repoPath.length - 1) return null; - return { - owner: repoPath.slice(0, separator), - name: repoPath.slice(separator + 1), - }; -} - type RepoSelectionRef = { provider?: string | undefined; platformHost?: string | undefined; @@ -156,8 +145,6 @@ type RepoSelectionRef = { repoPath?: string | undefined; }; -type WorkspaceConfigRepo = NonNullable["repo"]>; - function itemRepoRef(ref: RepoSelectionRef | null): RepositoryRouteRef | null { if (!ref) return null; const repoPath = cleanRepoPath(ref.repoPath); @@ -171,65 +158,6 @@ function itemRepoRef(ref: RepoSelectionRef | null): RepositoryRouteRef | null { }; } -function workspaceConfigRepoRef(repo: WorkspaceConfigRepo): RepositoryRouteRef | null { - const provider = repo.provider?.trim(); - const platformHost = (repo.platform_host ?? repo.host)?.trim(); - const repoPath = cleanRepoPath(repo.repo_path); - if (!provider || !platformHost || !repoPath) return null; - const identity = repo.owner && repo.name ? { owner: repo.owner, name: repo.name } : repoIdentityFromPath(repoPath); - if (!identity) return null; - return { - provider, - platformHost, - owner: identity.owner, - name: identity.name, - repoPath, - }; -} - -function configuredRepoRef(repo: ConfigRepo): RepositoryRouteRef | null { - if (repo.is_glob) return null; - const repoPath = cleanRepoPath(repo.repo_path || `${repo.owner}/${repo.name}`); - if (!repo.provider || !repo.platform_host || !repo.owner || !repo.name || !repoPath) { - return null; - } - return { - provider: repo.provider, - platformHost: repo.platform_host, - owner: repo.owner, - name: repo.name, - repoPath, - }; -} - -function configuredRepoMatchesWorkspace(repo: ConfigRepo, selectedRepo: WorkspaceConfigRepo): boolean { - if (repo.owner !== selectedRepo.owner || repo.name !== selectedRepo.name) return false; - if (selectedRepo.provider && repo.provider !== selectedRepo.provider) return false; - const selectedHost = selectedRepo.platform_host ?? selectedRepo.host; - if (selectedHost && repo.platform_host !== selectedHost) return false; - const selectedRepoPath = cleanRepoPath(selectedRepo.repo_path); - if (selectedRepoPath && cleanRepoPath(repo.repo_path || `${repo.owner}/${repo.name}`) !== selectedRepoPath) { - return false; - } - return true; -} - -function workspaceRepoRef(): RepositoryRouteRef | null { - const selectedRepo = getUIConfig().repo; - if (!selectedRepo) return null; - const directRef = workspaceConfigRepoRef(selectedRepo); - if (directRef) return directRef; - if (!storesGetter) return null; - - const matches = stores() - .settings.getConfiguredRepos() - .filter((repo) => !repo.is_glob) - .filter((repo) => configuredRepoMatchesWorkspace(repo, selectedRepo)) - .map(configuredRepoRef) - .filter((repo): repo is RepositoryRouteRef => repo !== null); - return matches.length === 1 ? (matches[0] ?? null) : null; -} - function routeRepoRef(ctx: Context): RepositoryRouteRef | null { switch (ctx.route.page) { case "repo-browser": @@ -240,14 +168,6 @@ function routeRepoRef(ctx: Context): RepositoryRouteRef | null { name: ctx.route.name, repoPath: cleanRepoPath(ctx.route.repoPath), }; - case "embed-workspace-detail": - return { - provider: ctx.route.provider, - platformHost: ctx.route.platformHost, - owner: ctx.route.owner, - name: ctx.route.name, - repoPath: cleanRepoPath(ctx.route.repoPath), - }; case "focus": if (ctx.route.itemType !== "pr" && ctx.route.itemType !== "issue") return null; return { @@ -262,18 +182,6 @@ function routeRepoRef(ctx: Context): RepositoryRouteRef | null { } } -function workspacePageRepoRef(ctx: Context): RepositoryRouteRef | null { - switch (ctx.page) { - case "workspaces": - case "terminal": - case "embed-workspace-terminal": - case "embed-workspace-project": - return workspaceRepoRef(); - default: - return null; - } -} - function pageSelectedPRRef(ctx: Context): RepositoryRouteRef | null { if ( ctx.page === "pulls" || @@ -305,8 +213,6 @@ function repoBrowserCommandRef(ctx: Context): RepositoryRouteRef | null { if (ctx.page === "activity") { return itemRepoRef(parseActivitySelection(window.location.search)); } - const workspaceRef = workspacePageRepoRef(ctx); - if (workspaceRef) return workspaceRef; const selectedPRRef = pageSelectedPRRef(ctx); if (selectedPRRef) return selectedPRRef; return pageSelectedIssueRef(ctx); @@ -562,8 +468,8 @@ export const defaultActions: Action[] = [ scope: "global", binding: { key: "[", ctrlOrMeta: true }, priority: 0, - when: () => isSidebarToggleEnabled(), - visible: (ctx) => isSidebarToggleEnabled() && hasSidebarShortcutTarget(ctx), + when: always, + visible: hasSidebarShortcutTarget, handler: (ctx) => { if (hasSidebarShortcutTarget(ctx)) toggleSidebar(); }, @@ -786,19 +692,7 @@ export const defaultActions: Action[] = [ binding: null, priority: 0, when: always, - handler: (ctx) => { - const ref = workspacePageRepoRef(ctx); - openNewWorkspaceDialog( - ref - ? { - provider: ref.provider, - platformHost: ref.platformHost ?? "", - owner: ref.owner, - name: ref.name, - } - : undefined, - ); - }, + handler: () => openNewWorkspaceDialog(), preview: () => ({ title: "New workspace", subtitle: "Start work in a tracked repository on a new worktree branch", diff --git a/frontend/src/lib/stores/router.initialization.test.ts b/frontend/src/lib/stores/router.initialization.test.ts index b70d6dae99..71d5b2cbc6 100644 --- a/frontend/src/lib/stores/router.initialization.test.ts +++ b/frontend/src/lib/stores/router.initialization.test.ts @@ -14,7 +14,6 @@ describe("router initialization", () => { }); afterEach(() => { - delete window.__kenn_forge_config; delete window.__BASE_PATH__; vi.restoreAllMocks(); window.sessionStorage.clear(); @@ -49,28 +48,6 @@ describe("router initialization", () => { }, }); }); - it("uses embed initialRoute before the first app render", async () => { - window.__kenn_forge_config = { - embed: { - initialRoute: "/workspaces/embed/detail/gitlab/pr/git.example.com/42" + "?repo_path=group%2Fproject", - }, - }; - const { getRoute } = await importRouterAt("/"); - - expect(getRoute()).toEqual({ - page: "embed-workspace-detail", - provider: "gitlab", - itemType: "pr", - platformHost: "git.example.com", - repoPath: "group/project", - owner: "group", - name: "project", - number: 42, - }); - expect(window.location.pathname + window.location.search).toBe( - "/workspaces/embed/detail/gitlab/pr/git.example.com/42" + "?repo_path=group%2Fproject", - ); - }); it("defaults the last workspace route to /workspaces on initial load", async () => { const { getLastWorkspaceRoute } = await importRouterAt("/"); diff --git a/frontend/src/lib/stores/router.svelte.ts b/frontend/src/lib/stores/router.svelte.ts index ed8ac13c4e..b74b51fc49 100644 --- a/frontend/src/lib/stores/router.svelte.ts +++ b/frontend/src/lib/stores/router.svelte.ts @@ -12,10 +12,6 @@ export type NumberedItemRef = NumberedRouteItemRef; export type HostedItemRef = IssueRouteRef; export type RoutableItemRef = RoutedItemRef; -export type EmbedEmptyReason = "noSelection" | "noRepo" | "noWorkspace"; - -export type EmbedDetailTab = "pr" | "issue" | "reviews"; - export type Route = | { page: "activity" } | { page: "actions" } @@ -70,45 +66,10 @@ export type Route = | { page: "focus"; itemType: "issues"; repo?: string } | { page: "reviews"; jobId?: number } | { page: "project-intake"; hostKey?: string } - | { page: "terminal"; workspaceId: string; hostKey?: string } - // Embed-targetable workspace surfaces. Hosts mount these - // routes to render a single component of the workspaces UX - // (list, terminal, per-item detail, empty placeholder, the - // empty-registry First Run Panel, or a per-project card) - // without the surrounding app chrome. - | { page: "embed-workspace-list" } - | { page: "embed-workspace-terminal"; workspaceId: string } - | { - page: "embed-workspace-detail"; - provider: string; - itemType: "pr" | "issue"; - platformHost: string; - repoPath: string; - owner: string; - name: string; - number: number; - branch?: string; - tab?: EmbedDetailTab; - } - | { page: "embed-workspace-empty"; reason: EmbedEmptyReason } - | { page: "embed-workspace-first-run" } - | { - page: "embed-workspace-project"; - projectId: string; - hostKey?: string; - }; + | { page: "terminal"; workspaceId: string; hostKey?: string }; export type Page = Route["page"]; -import { - isEmbedded, - getOnNavigate, - getOnRouteChange, - getUIConfig as getEmbedUIConfig, - getHost, - getInitialRoute, -} from "./embed-config.svelte.js"; - // Runtime base path injected by the Go server (e.g., "/" or "/kenn-forge/"). const rawBase = window.__BASE_PATH__ ?? "/"; const basePrefix = rawBase === "/" ? "" : rawBase.replace(/\/$/, ""); @@ -207,10 +168,6 @@ function parseHostProviderNumberedPath( return undefined; } -function inferLegacyEmbedProvider(platformHost: string): string { - return platformHost.toLowerCase().includes("gitlab") ? "gitlab" : "github"; -} - function splitRepoPath(repoPath: string): { owner: string; name: string } | undefined { const pathParts = repoPath .replace(/^\/+|\/+$/g, "") @@ -360,7 +317,7 @@ function parseRoute(fullPath: string): Route { doc: emptyToNull(sp.get("doc")), }; } - if (path === "/settings" && !isEmbedded()) return { page: "settings" }; + if (path === "/settings") return { page: "settings" }; if (path.startsWith("/issues")) { if (path !== "/issues") { const selected = parseHostProviderNumberedPath(parts, "issues"); @@ -425,127 +382,13 @@ function parseRoute(fullPath: string): Route { workspaceId: decodeRouteSegment(terminalMatch[1]!) ?? terminalMatch[1]!, }; } - // Embed routes must be matched before the generic /workspaces - // catch-all so they don't fall back to the standalone page. - if (path === "/workspaces/embed/list") { - return { page: "embed-workspace-list" }; - } - const embedTerminalMatch = path.match(/^\/workspaces\/embed\/terminal(?:\/([^/]+))?$/); - if (embedTerminalMatch) { - return { - page: "embed-workspace-terminal", - workspaceId: embedTerminalMatch[1] ?? "", - }; - } - const embedDetailMatch = path.match(/^\/workspaces\/embed\/detail\/([^/]+)\/(pr|issue)\/([^/]+)\/(\d+)$/); - if (embedDetailMatch) { - const sp = new URLSearchParams(search); - const repoPath = sp.get("repo_path")?.trim(); - const repo = repoPath ? splitRepoPath(repoPath) : undefined; - if (!repoPath || !repo) { - return { page: "workspaces" }; - } - const branch = sp.get("branch") ?? undefined; - const tabParam = sp.get("tab"); - const tab: EmbedDetailTab | undefined = - tabParam === "pr" || tabParam === "issue" || tabParam === "reviews" ? tabParam : undefined; - const r: Route = { - page: "embed-workspace-detail", - provider: embedDetailMatch[1]!, - itemType: embedDetailMatch[2] as "pr" | "issue", - platformHost: embedDetailMatch[3]!, - repoPath, - owner: repo.owner, - name: repo.name, - number: parseInt(embedDetailMatch[4]!, 10), - }; - if (branch) r.branch = branch; - if (tab) r.tab = tab; - return r; - } - const legacyProviderEmbedDetailMatch = path.match( - /^\/workspaces\/embed\/detail\/([^/]+)\/(pr|issue)\/([^/]+)\/([^/]+)\/([^/]+)\/(\d+)$/, - ); - if (legacyProviderEmbedDetailMatch) { - const sp = new URLSearchParams(search); - const branch = sp.get("branch") ?? undefined; - const tabParam = sp.get("tab"); - const tab: EmbedDetailTab | undefined = - tabParam === "pr" || tabParam === "issue" || tabParam === "reviews" ? tabParam : undefined; - const owner = legacyProviderEmbedDetailMatch[4]!; - const name = legacyProviderEmbedDetailMatch[5]!; - const r: Route = { - page: "embed-workspace-detail", - provider: legacyProviderEmbedDetailMatch[1]!, - itemType: legacyProviderEmbedDetailMatch[2] as "pr" | "issue", - platformHost: legacyProviderEmbedDetailMatch[3]!, - repoPath: `${owner}/${name}`, - owner, - name, - number: parseInt(legacyProviderEmbedDetailMatch[6]!, 10), - }; - if (branch) r.branch = branch; - if (tab) r.tab = tab; - return r; - } - const legacyEmbedDetailMatch = path.match( - /^\/workspaces\/embed\/detail\/(pr|issue)\/([^/]+)\/([^/]+)\/([^/]+)\/(\d+)$/, - ); - if (legacyEmbedDetailMatch) { - const sp = new URLSearchParams(search); - const branch = sp.get("branch") ?? undefined; - const tabParam = sp.get("tab"); - const tab: EmbedDetailTab | undefined = - tabParam === "pr" || tabParam === "issue" || tabParam === "reviews" ? tabParam : undefined; - const platformHost = legacyEmbedDetailMatch[2]!; - const owner = legacyEmbedDetailMatch[3]!; - const name = legacyEmbedDetailMatch[4]!; - const r: Route = { - page: "embed-workspace-detail", - provider: inferLegacyEmbedProvider(platformHost), - itemType: legacyEmbedDetailMatch[1] as "pr" | "issue", - platformHost, - repoPath: `${owner}/${name}`, - owner, - name, - number: parseInt(legacyEmbedDetailMatch[5]!, 10), - }; - if (branch) r.branch = branch; - if (tab) r.tab = tab; - return r; - } - const embedEmptyMatch = path.match(/^\/workspaces\/embed\/empty\/(noSelection|noRepo|noWorkspace)$/); - if (embedEmptyMatch) { - return { - page: "embed-workspace-empty", - reason: embedEmptyMatch[1] as EmbedEmptyReason, - }; - } - if (path === "/workspaces/embed/first-run") { - return { page: "embed-workspace-first-run" }; - } - const embedProjectMatch = path.match(/^\/workspaces\/embed\/project\/([A-Za-z0-9_-]+)$/); - if (embedProjectMatch) { - const sp = new URLSearchParams(search); - const hostKey = emptyToNull(sp.get("host")); - return { - page: "embed-workspace-project", - projectId: embedProjectMatch[1]!, - ...(hostKey ? { hostKey } : {}), - }; - } if (path === "/workspaces" || path.startsWith("/workspaces/")) { return { page: "workspaces" }; } return { page: "activity" }; } -const configuredInitialRoute = getInitialRoute(); -if (configuredInitialRoute) { - history.replaceState(null, "", basePrefix + configuredInitialRoute); -} - -let route = $state(parseRoute(configuredInitialRoute ?? currentLocationPath())); +let route = $state(parseRoute(currentLocationPath())); // The Activity selection, detail tab, and feed filters all live in the URL // query string. Remember the full Activity path so the top-bar Activity tab @@ -730,138 +573,6 @@ export function navigate(path: string, state?: Record): void { // remembers the route it lands on — a terminal visit exited that way // would otherwise never enter route memory. rememberWorkspaceRoute(); - fireForgeNavigateEvent(route); - fireRouteChange(route); -} - -function buildRouteEvent(r: Route): ForgeNavigateEvent { - const focus = r.page === "focus"; - let navType: ForgeNavigateType; - if (r.page === "focus") { - if (r.itemType === "mrs") { - navType = "pull"; - } else if (r.itemType === "issues") { - navType = "issue"; - } else { - navType = r.itemType === "pr" ? "pull" : "issue"; - } - } else if (r.page === "mobile-pulls") { - navType = "pull"; - } else if (r.page === "mobile-issues") { - navType = "issue"; - } else if (r.page === "mobile-activity") { - navType = "activity"; - } else if (r.page === "pulls") { - navType = "pull"; - } else if (r.page === "issues") { - navType = "issue"; - } else if (r.page === "repos" || r.page === "repo-browser") { - navType = "repos"; - } else if (r.page === "docs") { - navType = "docs"; - } else if (r.page === "reviews") { - navType = "reviews"; - } else if (r.page === "project-intake" || isWorkspacePage(r.page)) { - navType = "workspaces"; - } else if (r.page === "design-system") { - navType = "activity"; - } else { - navType = "activity"; - } - - let page: ForgeNavigatePage; - if (navType === "pull") { - page = "pulls"; - } else if (navType === "issue") { - page = "issues"; - } else { - page = navType; - } - - const event: ForgeNavigateEvent = { - page, - type: navType, - focus, - view: stripBase(currentLocationPath()), - }; - - if (r.page === "focus" && "repoPath" in r) { - applyRouteRepoIdentity(event, r); - event.number = r.number; - } else if (r.page === "pulls" && "selected" in r && r.selected) { - applyRouteRepoIdentity(event, r.selected); - event.number = r.selected.number; - } else if (r.page === "issues" && "selected" in r && r.selected) { - applyRouteRepoIdentity(event, r.selected); - event.number = r.selected.number; - } else if ("repoPath" in r) { - applyRouteRepoIdentity(event, r); - } - - // Populate repo from focus list route or global config. - if (r.page === "focus" && "repo" in r && r.repo) { - const repoIdentity = parseFocusListRepoIdentity(r.repo); - if (repoIdentity) { - applyRouteRepoIdentity(event, repoIdentity); - } else { - event.repo = r.repo; - } - } else if (!event.repo_path) { - const cfgRepo = getEmbedUIConfig().repo; - if (cfgRepo) { - const repo = embedConfigRepoName(cfgRepo); - if (repo) event.repo = repo; - } - } - - const host = getHost(); - if (host) { - event.host = host; - } - - return event; -} - -function applyRouteRepoIdentity(event: ForgeNavigateEvent, ref: RepoRef): void { - event.provider = ref.provider; - if (ref.platformHost) event.platform_host = ref.platformHost; - event.repo_path = ref.repoPath; - event.repo = ref.repoPath; - event.owner = ref.owner; - event.name = ref.name; -} - -function parseFocusListRepoIdentity(repo: string): RepoRef | undefined { - const raw = repo.trim(); - if (!raw || raw.includes(",")) return undefined; - const pipeIndex = raw.indexOf("|"); - if (pipeIndex <= 0) return undefined; - const provider = raw.slice(0, pipeIndex).trim(); - const hostAndPath = raw.slice(pipeIndex + 1); - const slashIndex = hostAndPath.indexOf("/"); - if (!provider || slashIndex <= 0) return undefined; - const platformHost = hostAndPath.slice(0, slashIndex).trim(); - const repoPath = hostAndPath - .slice(slashIndex + 1) - .trim() - .replace(/^\/+|\/+$/g, ""); - const repoParts = repoPath ? splitRepoPath(repoPath) : undefined; - if (!platformHost || !repoPath || !repoParts) return undefined; - return { - provider, - platformHost, - repoPath, - owner: repoParts.owner, - name: repoParts.name, - }; -} - -function embedConfigRepoName(repo: NonNullable["repo"]>): string | undefined { - const repoPath = repo.repo_path?.trim().replace(/^\/+|\/+$/g, ""); - if (repoPath) return repoPath; - const owner = repo.owner?.trim().replace(/^\/+|\/+$/g, ""); - const name = repo.name?.trim().replace(/^\/+|\/+$/g, ""); - return owner && name ? `${owner}/${name}` : undefined; } function emptyToNull(value: string | null): string | null { @@ -882,25 +593,10 @@ export function isWorkspacePage(page: Page): boolean { page === "terminal" || page === "mobile-workspaces" || page === "mobile-workspace-terminal" || - page === "mobile-workspace-item" || - isWorkspaceEmbedPage(page) + page === "mobile-workspace-item" ); } -export function isWorkspaceEmbedPage(page: Page): boolean { - switch (page) { - case "embed-workspace-list": - case "embed-workspace-terminal": - case "embed-workspace-detail": - case "embed-workspace-empty": - case "embed-workspace-first-run": - case "embed-workspace-project": - return true; - default: - return false; - } -} - export function isMobilePage(page: Page): boolean { return ( page === "mobile-activity" || @@ -924,27 +620,12 @@ export function buildMobileWorkspaceItemRoute(workspaceId: string, hostKey?: str return `${base}/item${tab === "files" ? "/files" : ""}`; } -function fireForgeNavigateEvent(r: Route): void { - const cb = getOnNavigate(); - if (cb) cb(buildRouteEvent(r)); -} - -function fireRouteChange(r: Route): void { - const cb = getOnRouteChange(); - if (cb) cb(buildRouteEvent(r)); -} - -export function notifyInitialRouteChange(): void { - fireRouteChange(route); -} - export function replaceUrl(path: string, state?: Record): void { const fullPath = basePrefix + path; history.replaceState(state ?? null, "", fullPath); route = parseRoute(fullPath); rememberActivityRoute(); rememberWorkspaceRoute(); - fireRouteChange(route); } // Listen for browser back/forward. @@ -954,17 +635,9 @@ if (typeof window !== "undefined") { restoreMissingActivityFilters(); rememberActivityRoute(); rememberWorkspaceRoute(); - fireRouteChange(route); }); } -// Expose imperative navigation for the host embedder. -if (typeof window !== "undefined") { - window.__kenn_forge_navigate_to_route = (route: string) => { - navigate(route); - }; -} - // --- detail tab derived from route --- export type DetailTab = "conversation" | "files"; diff --git a/frontend/src/lib/stores/router.test.ts b/frontend/src/lib/stores/router.test.ts index 42ac901753..33f4417a57 100644 --- a/frontend/src/lib/stores/router.test.ts +++ b/frontend/src/lib/stores/router.test.ts @@ -375,158 +375,7 @@ describe("router basic routes", () => { }); }); -describe("router embed-workspace routes", () => { - it("parses /workspaces/embed/list", () => { - navigate("/workspaces/embed/list"); - expect(getRoute()).toEqual({ page: "embed-workspace-list" }); - expect(getPage()).toBe("embed-workspace-list"); - }); - - it("parses /workspaces/embed/terminal without an id", () => { - navigate("/workspaces/embed/terminal"); - expect(getRoute()).toEqual({ - page: "embed-workspace-terminal", - workspaceId: "", - }); - }); - - it("parses /workspaces/embed/terminal/:workspaceId", () => { - navigate("/workspaces/embed/terminal/abc-123"); - expect(getRoute()).toEqual({ - page: "embed-workspace-terminal", - workspaceId: "abc-123", - }); - }); - - it("parses /workspaces/embed/detail/:provider/pr/:host/:number with repo_path", () => { - navigate("/workspaces/embed/detail/github/pr/github.com/42?repo_path=acme%2Fwidgets"); - expect(getRoute()).toEqual({ - page: "embed-workspace-detail", - provider: "github", - itemType: "pr", - platformHost: "github.com", - repoPath: "acme/widgets", - owner: "acme", - name: "widgets", - number: 42, - }); - }); - - it("parses legacy /workspaces/embed/detail/:type/:host/:owner/:name/:number", () => { - navigate("/workspaces/embed/detail/issue/gitlab.example.com/acme/widgets/7?tab=issue"); - expect(getRoute()).toEqual({ - page: "embed-workspace-detail", - provider: "gitlab", - itemType: "issue", - platformHost: "gitlab.example.com", - repoPath: "acme/widgets", - owner: "acme", - name: "widgets", - number: 7, - tab: "issue", - }); - }); - - it("keeps legacy GitHub Enterprise embed detail URLs on GitHub", () => { - navigate("/workspaces/embed/detail/pr/ghe.example.com/acme/widgets/42"); - expect(getRoute()).toEqual({ - page: "embed-workspace-detail", - provider: "github", - itemType: "pr", - platformHost: "ghe.example.com", - repoPath: "acme/widgets", - owner: "acme", - name: "widgets", - number: 42, - }); - }); - - it("parses legacy provider-explicit detail path without repo_path", () => { - navigate("/workspaces/embed/detail/github/pr/github.com/acme/widgets/42?branch=main"); - expect(getRoute()).toEqual({ - page: "embed-workspace-detail", - provider: "github", - itemType: "pr", - platformHost: "github.com", - repoPath: "acme/widgets", - owner: "acme", - name: "widgets", - number: 42, - branch: "main", - }); - }); - - it("parses /workspaces/embed/detail with branch and tab query", () => { - navigate( - "/workspaces/embed/detail/gitlab/issue/git.example.com/7" + - "?repo_path=org%2Fteam%2Frepo&branch=feature%2Fx&tab=reviews", - ); - expect(getRoute()).toEqual({ - page: "embed-workspace-detail", - provider: "gitlab", - itemType: "issue", - platformHost: "git.example.com", - repoPath: "org/team/repo", - owner: "org/team", - name: "repo", - number: 7, - branch: "feature/x", - tab: "reviews", - }); - }); - - it("ignores unknown tab values on the detail route", () => { - navigate("/workspaces/embed/detail/github/pr/github.com/1?repo_path=o%2Fn&tab=garbage"); - const route = getRoute(); - expect(route).toEqual({ - page: "embed-workspace-detail", - provider: "github", - itemType: "pr", - platformHost: "github.com", - repoPath: "o/n", - owner: "o", - name: "n", - number: 1, - }); - }); - - it("parses /workspaces/embed/empty/:reason for each known reason", () => { - for (const reason of ["noSelection", "noRepo", "noWorkspace"] as const) { - navigate(`/workspaces/embed/empty/${reason}`); - expect(getRoute()).toEqual({ - page: "embed-workspace-empty", - reason, - }); - } - }); - - it("falls back to the standalone workspaces page for unknown empty reasons", () => { - navigate("/workspaces/embed/empty/garbage"); - expect(getRoute()).toEqual({ page: "workspaces" }); - }); - - it("parses /workspaces/embed/first-run", () => { - navigate("/workspaces/embed/first-run"); - expect(getRoute()).toEqual({ page: "embed-workspace-first-run" }); - expect(getPage()).toBe("embed-workspace-first-run"); - }); - - it("parses /workspaces/embed/project/:project_id", () => { - navigate("/workspaces/embed/project/prj_abc123"); - expect(getRoute()).toEqual({ - page: "embed-workspace-project", - projectId: "prj_abc123", - }); - expect(getPage()).toBe("embed-workspace-project"); - - navigate("/workspaces/embed/project/prj_abc123?host=epyc"); - expect(getRoute()).toEqual({ - page: "embed-workspace-project", - projectId: "prj_abc123", - hostKey: "epyc", - }); - }); - +describe("project intake routes", () => { it("parses /project-intake with an optional host", () => { navigate("/project-intake"); expect(getRoute()).toEqual({ page: "project-intake" }); @@ -537,309 +386,6 @@ describe("router embed-workspace routes", () => { hostKey: "epyc", }); }); - - it("falls back to the standalone workspaces page for unknown project_id shapes", () => { - navigate("/workspaces/embed/project/has slash/extra"); - expect(getRoute()).toEqual({ page: "workspaces" }); - }); -}); - -describe("router navigation events", () => { - beforeEach(() => { - navigate("/pulls"); - }); - - afterEach(() => { - delete (window as unknown as { __kenn_forge_config?: unknown }).__kenn_forge_config; - ( - window as unknown as { - __kenn_forge_notify_config_changed?: () => void; - } - ).__kenn_forge_notify_config_changed?.(); - }); - - function installOnNavigate(spy: ReturnType, config: Record = {}): void { - (window as unknown as { __kenn_forge_config?: unknown }).__kenn_forge_config = { - ...config, - onNavigate: spy, - }; - ( - window as unknown as { - __kenn_forge_notify_config_changed?: () => void; - } - ).__kenn_forge_notify_config_changed?.(); - } - - it("fires onNavigate with pull payload for files route", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - navigate(prFilesRoute); - - expect(spy).toHaveBeenCalledTimes(1); - const payload = spy.mock.calls[0]![0]; - expect(payload.page).toBe("pulls"); - expect(payload.type).toBe("pull"); - expect(payload.focus).toBe(false); - expect(payload.owner).toBe("acme"); - expect(payload.name).toBe("widgets"); - expect(payload.number).toBe(42); - expect(payload.provider).toBe("github"); - expect(payload.platform_host).toBe("github.com"); - expect(payload.repo_path).toBe("acme/widgets"); - expect(payload.repo).toBe("acme/widgets"); - }); - - it("fires onNavigate with pull payload for conversation route", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - navigate(prRoute); - - expect(spy).toHaveBeenCalledTimes(1); - const payload = spy.mock.calls[0]![0]; - expect(payload.page).toBe("pulls"); - expect(payload.type).toBe("pull"); - expect(payload.owner).toBe("acme"); - expect(payload.name).toBe("widgets"); - expect(payload.number).toBe(42); - }); - - it("fires onNavigate with pulls page for focus pull route", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - navigate(focusPrRoute); - - expect(spy).toHaveBeenCalledTimes(1); - const payload = spy.mock.calls[0]![0]; - expect(payload.page).toBe("pulls"); - expect(payload.type).toBe("pull"); - expect(payload.focus).toBe(true); - expect(payload.owner).toBe("acme"); - expect(payload.name).toBe("widgets"); - expect(payload.number).toBe(42); - }); - - it("fires provider-aware repo payloads for focus list repo filters", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - navigate("/focus/mrs?repo=gitlab%7Cgitlab.example.com%2Fgroup%2Fsubgroup%2Fproject"); - - const payload = spy.mock.calls[spy.mock.calls.length - 1]![0]; - expect(payload.page).toBe("pulls"); - expect(payload.type).toBe("pull"); - expect(payload.focus).toBe(true); - expect(payload.provider).toBe("gitlab"); - expect(payload.platform_host).toBe("gitlab.example.com"); - expect(payload.repo_path).toBe("group/subgroup/project"); - expect(payload.owner).toBe("group/subgroup"); - expect(payload.name).toBe("project"); - expect(payload.repo).toBe("group/subgroup/project"); - }); - - it("keeps legacy focus list repo filters opaque in navigation events", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - navigate("/focus/issues?repo=acme%2Fwidgets"); - - const payload = spy.mock.calls[spy.mock.calls.length - 1]![0]; - expect(payload.page).toBe("issues"); - expect(payload.type).toBe("issue"); - expect(payload.focus).toBe(true); - expect(payload.repo).toBe("acme/widgets"); - expect(payload.provider).toBeUndefined(); - expect(payload.platform_host).toBeUndefined(); - expect(payload.repo_path).toBeUndefined(); - }); - - it("fires onNavigate without owner/name/number for /pulls list", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - navigate("/pulls"); - - expect(spy).toHaveBeenCalledTimes(1); - const payload = spy.mock.calls[0]![0]; - expect(payload).toEqual({ - page: "pulls", - type: "pull", - focus: false, - view: "/pulls", - }); - }); - - it("reports /pulls/board as the pull list fallback", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - navigate("/pulls/board"); - - expect(spy).toHaveBeenCalledTimes(1); - const payload = spy.mock.calls[0]![0]; - expect(payload.page).toBe("pulls"); - expect(payload.type).toBe("pull"); - expect(payload.focus).toBe(false); - }); - - it("fires onNavigate with issues page for issue list route", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - navigate("/issues"); - - expect(spy).toHaveBeenCalledTimes(1); - const payload = spy.mock.calls[0]![0]; - expect(payload.page).toBe("issues"); - expect(payload.type).toBe("issue"); - expect(payload.focus).toBe(false); - }); - - it("maps /design-system to activity navigation events", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - navigate("/design-system"); - - expect(spy).toHaveBeenCalledTimes(1); - const payload = spy.mock.calls[0]![0]; - expect(payload.page).toBe("activity"); - expect(payload.type).toBe("activity"); - expect(payload.view).toBe("/design-system"); - }); - it("maps /docs to docs navigation events", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - navigate("/docs?folder=notes&doc=Daily%2Ftoday.md"); - - const payload = spy.mock.calls[spy.mock.calls.length - 1]![0]; - expect(payload.page).toBe("docs"); - expect(payload.type).toBe("docs"); - expect(payload.view).toBe("/docs?folder=notes&doc=Daily%2Ftoday.md"); - }); - - it("maps repo browser routes to provider-aware repos navigation events and preserves URL fragments", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - navigate( - "/repo/browser?provider=gitlab&platform_host=gitlab.example.com&repo_path=group%2Fsubgroup%2Fproject&path=README.md&mode=preview#install", - ); - - const payload = spy.mock.calls[spy.mock.calls.length - 1]![0]; - expect(payload.type).toBe("repos"); - expect(payload.provider).toBe("gitlab"); - expect(payload.platform_host).toBe("gitlab.example.com"); - expect(payload.repo_path).toBe("group/subgroup/project"); - expect(payload.owner).toBe("group/subgroup"); - expect(payload.name).toBe("project"); - expect(payload.repo).toBe("group/subgroup/project"); - expect(payload.view).toBe( - "/repo/browser?provider=gitlab&platform_host=gitlab.example.com&repo_path=group%2Fsubgroup%2Fproject&path=README.md&mode=preview#install", - ); - }); - - it("prefers route repo identity over embed repo config for repo browser navigation events", () => { - const spy = vi.fn(); - installOnNavigate(spy, { - ui: { - repo: { - provider: "gitlab", - platform_host: "gitlab.example.com", - repo_path: "other/group/project", - }, - }, - }); - - navigate("/repo/browser?provider=gitlab&platform_host=gitlab.example.com&repo_path=group%2Fsubgroup%2Fproject"); - - const payload = spy.mock.calls[spy.mock.calls.length - 1]![0]; - expect(payload.type).toBe("repos"); - expect(payload.repo).toBe("group/subgroup/project"); - expect(payload.provider).toBe("gitlab"); - expect(payload.platform_host).toBe("gitlab.example.com"); - expect(payload.repo_path).toBe("group/subgroup/project"); - }); - - it("falls back to embed owner and name for navigation event repo names", () => { - const spy = vi.fn(); - installOnNavigate(spy, { - ui: { - repo: { - provider: "github", - platform_host: "github.com", - owner: "acme", - name: "widgets", - }, - }, - }); - - navigate("/repos"); - - const payload = spy.mock.calls[spy.mock.calls.length - 1]![0]; - expect(payload.type).toBe("repos"); - expect(payload.repo).toBe("acme/widgets"); - }); - - it("maps every embed-workspace route to a workspaces navigation event", () => { - const spy = vi.fn(); - installOnNavigate(spy); - - const embedPaths = [ - "/workspaces/embed/list", - "/workspaces/embed/terminal/ws-1", - "/workspaces/embed/detail/github/pr/github.com/42?repo_path=acme%2Fwidget", - "/workspaces/embed/empty/noWorkspace", - "/workspaces/embed/first-run", - "/workspaces/embed/project/prj_abc123", - "/project-intake?host=epyc", - ]; - - for (const path of embedPaths) { - spy.mockClear(); - navigate(path); - const payload = spy.mock.calls[spy.mock.calls.length - 1]![0]; - expect(payload.page, `page for ${path}`).toBe("workspaces"); - expect(payload.type, `type for ${path}`).toBe("workspaces"); - expect(payload.focus, `focus for ${path}`).toBe(false); - } - }); -}); - -describe("router window bridges", () => { - beforeEach(() => { - navigate("/pulls"); - }); - - it("exposes __kenn_forge_navigate_to_route as a window global", () => { - const bridge = ( - window as unknown as { - __kenn_forge_navigate_to_route?: (route: string) => void; - } - ).__kenn_forge_navigate_to_route; - expect(typeof bridge).toBe("function"); - }); - - it("__kenn_forge_navigate_to_route updates the SPA route", () => { - const bridge = ( - window as unknown as { - __kenn_forge_navigate_to_route: (route: string) => void; - } - ).__kenn_forge_navigate_to_route; - - bridge("/workspaces/embed/first-run"); - expect(getRoute()).toEqual({ page: "embed-workspace-first-run" }); - expect(getPage()).toBe("embed-workspace-first-run"); - - bridge("/workspaces/embed/project/prj_xyz"); - expect(getRoute()).toEqual({ - page: "embed-workspace-project", - projectId: "prj_xyz", - }); - }); }); describe("router last activity route", () => { diff --git a/frontend/src/lib/stores/sidebar.svelte.test.ts b/frontend/src/lib/stores/sidebar.svelte.test.ts index d22ba7a444..382aaf6097 100644 --- a/frontend/src/lib/stores/sidebar.svelte.test.ts +++ b/frontend/src/lib/stores/sidebar.svelte.test.ts @@ -1,17 +1,7 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; -import { - isSidebarCollapsed, - getSidebarWidth, - setSidebarWidth, - toggleSidebar, - isSidebarToggleEnabled, - initSidebar, -} from "./sidebar.svelte.js"; - -const win = window as any; +import { isSidebarCollapsed, getSidebarWidth, setSidebarWidth, toggleSidebar, initSidebar } from "./sidebar.svelte.js"; afterEach(() => { - delete win.__kenn_forge_config; try { localStorage.removeItem("kenn-forge-sidebar"); } catch { @@ -30,11 +20,6 @@ describe("standalone mode", () => { expect(isSidebarCollapsed()).toBe(false); }); - it("toggle is enabled", () => { - initSidebar(); - expect(isSidebarToggleEnabled()).toBe(true); - }); - it("toggleSidebar flips state", () => { initSidebar(); toggleSidebar(); @@ -61,57 +46,3 @@ describe("standalone mode", () => { expect(localStorage.getItem("kenn-forge-sidebar-width")).toBe("420"); }); }); - -describe("embedded mode — embedder owns sidebar", () => { - it("uses config value when set to true", () => { - win.__kenn_forge_config = { - ui: { sidebarCollapsed: true }, - }; - win.__kenn_forge_notify_config_changed?.(); - initSidebar(); - expect(isSidebarCollapsed()).toBe(true); - }); - - it("uses config value when set to false", () => { - win.__kenn_forge_config = { - ui: { sidebarCollapsed: false }, - }; - win.__kenn_forge_notify_config_changed?.(); - initSidebar(); - expect(isSidebarCollapsed()).toBe(false); - }); - - it("toggle is disabled when embedder owns", () => { - win.__kenn_forge_config = { - ui: { sidebarCollapsed: false }, - }; - win.__kenn_forge_notify_config_changed?.(); - initSidebar(); - expect(isSidebarToggleEnabled()).toBe(false); - }); - - it("uses the embedded width when provided", () => { - win.__kenn_forge_config = { - embed: { sidebarWidth: 410 }, - }; - win.__kenn_forge_notify_config_changed?.(); - initSidebar(); - expect(getSidebarWidth()).toBe(410); - }); -}); - -describe("embedded mode — user owns sidebar", () => { - it("defaults to expanded when not set", () => { - win.__kenn_forge_config = { ui: {} }; - win.__kenn_forge_notify_config_changed?.(); - initSidebar(); - expect(isSidebarCollapsed()).toBe(false); - }); - - it("toggle is enabled when not set", () => { - win.__kenn_forge_config = { ui: {} }; - win.__kenn_forge_notify_config_changed?.(); - initSidebar(); - expect(isSidebarToggleEnabled()).toBe(true); - }); -}); diff --git a/frontend/src/lib/stores/sidebar.svelte.ts b/frontend/src/lib/stores/sidebar.svelte.ts index e715c58956..b83a0010f8 100644 --- a/frontend/src/lib/stores/sidebar.svelte.ts +++ b/frontend/src/lib/stores/sidebar.svelte.ts @@ -1,5 +1,3 @@ -import { getUIConfig, getSidebarWidth as getEmbeddedSidebarWidth } from "./embed-config.svelte.js"; - const STORAGE_KEY = "kenn-forge-sidebar"; const WIDTH_STORAGE_KEY = "kenn-forge-sidebar-width"; const DEFAULT_WIDTH = 340; @@ -63,19 +61,8 @@ function persistWidth(value: number): void { } export function initSidebar(): void { - const ui = getUIConfig(); - if (ui.sidebarCollapsed !== undefined) { - collapsed = ui.sidebarCollapsed; - } else { - collapsed = loadPersisted(); - } - - const embeddedWidth = getEmbeddedSidebarWidth(); - if (embeddedWidth !== undefined) { - width = clampWidth(embeddedWidth); - } else { - width = loadPersistedWidth(); - } + collapsed = loadPersisted(); + width = loadPersistedWidth(); } export function isSidebarCollapsed(): boolean { @@ -84,12 +71,7 @@ export function isSidebarCollapsed(): boolean { return collapsed; } -export function isSidebarToggleEnabled(): boolean { - return getUIConfig().sidebarCollapsed === undefined; -} - export function toggleSidebar(): void { - if (!isSidebarToggleEnabled()) return; if (narrowCollapsed || narrowOpened) { // Toggle the transient narrow state without touching the // persisted preference. Both flags reset on widen. @@ -112,9 +94,7 @@ export function getSidebarWidth(): number { export function setSidebarWidth(value: number): void { width = clampWidth(value); - if (getEmbeddedSidebarWidth() === undefined) { - persistWidth(width); - } + persistWidth(width); } export function setNarrowOverride(narrow: boolean): void { diff --git a/frontend/src/lib/stores/theme.svelte.test.ts b/frontend/src/lib/stores/theme.svelte.test.ts index 738e4f39a2..1b0f98eadf 100644 --- a/frontend/src/lib/stores/theme.svelte.test.ts +++ b/frontend/src/lib/stores/theme.svelte.test.ts @@ -1,13 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { - initTheme, - isDark, - toggleTheme, - isThemeToggleVisible, - applyThemeOverrides, - reapplyTheme, - cleanupTheme, -} from "./theme.svelte.js"; +import { initTheme, isDark, toggleTheme, cleanupTheme } from "./theme.svelte.js"; function mockMatchMedia(matches: boolean): void { Object.defineProperty(window, "matchMedia", { @@ -31,7 +23,6 @@ beforeEach(() => { }); afterEach(() => { - delete window.__kenn_forge_config; document.documentElement.classList.remove("dark"); document.documentElement.style.cssText = ""; try { @@ -43,11 +34,6 @@ afterEach(() => { }); describe("standalone mode (no config)", () => { - it("toggle is visible when no theme.mode set", () => { - initTheme(); - expect(isThemeToggleVisible()).toBe(true); - }); - it("toggleTheme flips dark state", () => { initTheme(); const initial = isDark(); @@ -75,116 +61,3 @@ describe("standalone mode (no config)", () => { expect(localStorage.getItem("kenn-forge-theme")).toBe("light"); }); }); - -describe("embedded mode with theme.mode", () => { - it("hides toggle when mode is set", () => { - window.__kenn_forge_config = { theme: { mode: "dark" } }; - window.__kenn_forge_notify_config_changed?.(); - initTheme(); - expect(isThemeToggleVisible()).toBe(false); - }); - - it("a keyboard toggle under a forced mode persists to the kenn-forge key", () => { - // Covers the adapter's kit lifecycle assumption: initTheme binds kit's - // storage key before cleanupTheme drops the OS listener, and cleanup - // must not unbind the key — a later setThemeMode from toggleTheme has - // to land in kenn-forge-theme, not kit's default key. - window.__kenn_forge_config = { theme: { mode: "light" } }; - window.__kenn_forge_notify_config_changed?.(); - initTheme(); - expect(isDark()).toBe(false); - - toggleTheme(); - expect(isDark()).toBe(true); - expect(localStorage.getItem("kenn-forge-theme")).toBe("dark"); - expect(localStorage.getItem("kit-ui-theme")).toBeNull(); - }); - - it("applies dark class when mode is dark", () => { - window.__kenn_forge_config = { theme: { mode: "dark" } }; - window.__kenn_forge_notify_config_changed?.(); - initTheme(); - expect(isDark()).toBe(true); - expect(document.documentElement.classList.contains("dark")).toBe(true); - }); - - it("applies light class when mode is light", () => { - window.__kenn_forge_config = { theme: { mode: "light" } }; - window.__kenn_forge_notify_config_changed?.(); - initTheme(); - expect(isDark()).toBe(false); - }); -}); - -describe("reapplyTheme after removing forced mode", () => { - it("restores localStorage preference when mode is removed", () => { - // Start with forced dark mode - window.__kenn_forge_config = { theme: { mode: "dark" } }; - window.__kenn_forge_notify_config_changed?.(); - initTheme(); - expect(isDark()).toBe(true); - - // Store a light preference, then remove forced mode - localStorage.setItem("kenn-forge-theme", "light"); - delete window.__kenn_forge_config!.theme; - window.__kenn_forge_notify_config_changed?.(); - reapplyTheme(); - expect(isDark()).toBe(false); - }); - - it("falls back to OS preference when no stored or manual choice", () => { - mockMatchMedia(true); // OS prefers dark - window.__kenn_forge_config = { theme: { mode: "light" } }; - window.__kenn_forge_notify_config_changed?.(); - initTheme(); - expect(isDark()).toBe(false); - - // Remove forced mode, no localStorage, no manual toggle - delete window.__kenn_forge_config!.theme; - window.__kenn_forge_notify_config_changed?.(); - reapplyTheme(); - expect(isDark()).toBe(true); // follows OS dark preference - }); - - it("preserves in-memory manual toggle when storage is blocked", () => { - window.__kenn_forge_config = { theme: { mode: "light" } }; - window.__kenn_forge_notify_config_changed?.(); - initTheme(); - expect(isDark()).toBe(false); - - // User manually toggles to dark (storage will fail silently) - const origSetItem = localStorage.setItem; - localStorage.setItem = () => { - throw new Error("blocked"); - }; - toggleTheme(); - expect(isDark()).toBe(true); - localStorage.setItem = origSetItem; - - // Remove forced mode — should use in-memory manual choice, not OS - mockMatchMedia(false); // OS prefers light - delete window.__kenn_forge_config!.theme; - window.__kenn_forge_notify_config_changed?.(); - reapplyTheme(); - expect(isDark()).toBe(true); // kept manual dark choice - }); -}); - -describe("applyThemeOverrides", () => { - it("sets CSS variables from color config", () => { - applyThemeOverrides({ bgPrimary: "#111", accentBlue: "#00f" }, undefined, undefined); - const style = document.documentElement.style; - expect(style.getPropertyValue("--bg-primary")).toBe("#111"); - expect(style.getPropertyValue("--accent-blue")).toBe("#00f"); - }); - - it("sets font CSS variables", () => { - applyThemeOverrides(undefined, { sans: "SF Pro" }, undefined); - expect(document.documentElement.style.getPropertyValue("--font-sans")).toBe("SF Pro"); - }); - - it("sets radius CSS variables", () => { - applyThemeOverrides(undefined, undefined, { sm: "2px" }); - expect(document.documentElement.style.getPropertyValue("--radius-sm")).toBe("2px"); - }); -}); diff --git a/frontend/src/lib/stores/theme.svelte.ts b/frontend/src/lib/stores/theme.svelte.ts index b64d2b36a5..2ddc2d455c 100644 --- a/frontend/src/lib/stores/theme.svelte.ts +++ b/frontend/src/lib/stores/theme.svelte.ts @@ -1,201 +1,11 @@ -import { - cleanupTheme as kitCleanupTheme, - initTheme as kitInitTheme, - isDark as kitIsDark, - setThemeMode, -} from "@kenn-io/kit-ui"; -import { getThemeMode, getThemeColors, getThemeFonts, getThemeRadii } from "./embed-config.svelte.js"; +import { cleanupTheme, initTheme as kitInitTheme, isDark, setThemeMode } from "@kenn-io/kit-ui"; -/* - * Adapter over kit-ui's theme store. Standalone dark/light resolution, - * persistence, and the `dark` root class are kit's; the storage key stays - * "kenn-forge-theme" and kit reads the same "dark"/"light" values the old - * store wrote, so existing preferences carry over (kit adds "system" as a - * persistable mode). - * - * What stays app-side, and why: - * - Embed hosts can FORCE a mode through embed-config. kit's setThemeMode - * always persists, which would let a host's config overwrite the user's - * standalone preference — so the forced path applies classes directly and - * never touches kit's storage. - * - applyThemeOverrides: embed-config color/font/radius CSS variable - * injection is a kenn-forge embed feature, not a kit concern. - */ - -const THEME_KEY = "kenn-forge-theme"; - -const COLOR_MAP: Record = { - bgPrimary: "--bg-primary", - bgSurface: "--bg-surface", - bgSurfaceHover: "--bg-surface-hover", - bgInset: "--bg-inset", - borderDefault: "--border-default", - borderMuted: "--border-muted", - textPrimary: "--text-primary", - textSecondary: "--text-secondary", - textMuted: "--text-muted", - accentBlue: "--accent-blue", - accentAmber: "--accent-amber", - accentPurple: "--accent-purple", - accentGreen: "--accent-green", - accentRed: "--accent-red", - accentTeal: "--accent-teal", - overlayBg: "--overlay-bg", - shadowSm: "--shadow-sm", - shadowMd: "--shadow-md", - shadowLg: "--shadow-lg", - kanbanNew: "--kanban-new", - kanbanReviewing: "--kanban-reviewing", - kanbanWaiting: "--kanban-waiting", - kanbanAwaitingMerge: "--kanban-awaiting-merge", -}; - -const FONT_MAP: Record = { - sans: "--font-sans", - mono: "--font-mono", -}; - -const RADII_MAP: Record = { - sm: "--radius-sm", - md: "--radius-md", - lg: "--radius-lg", -}; - -// Non-null while an embed-config mode is forced; kit owns the theme -// otherwise. Svelte state so isDark() stays reactive on the forced path. -let forcedDark = $state(null); -let forcedCleanup: (() => void) | null = null; -// The user's manual choice this session. Re-asserted through kit's -// setThemeMode on reapply so it survives blocked storage (the old store's -// manualDark fallback) and wins over a stale stored value kit would re-read. -let manualMode: "light" | "dark" | null = null; - -// Track which CSS variables we've set so we can clear them on reset. -const appliedVars = new Set(); - -function applyDarkClass(isDarkMode: boolean): void { - // This IS the adapter over kit's theme store: the embed-forced path - // applies classes directly by design (see context/ui-design-system.md). - // kit-ui-check-ignore: sanctioned forced-mode class wiring in the adapter - document.documentElement.classList.toggle("dark", isDarkMode); -} - -function applyForcedMode(configMode: string): void { - // resolveTheme() runs inside App's reapply $effect. Applying the dark class - // from a local rather than reading `forcedDark` back is deliberate: reading - // the same $state this function just wrote would make the effect depend on a - // signal it mutates, self-retriggering into effect_update_depth_exceeded. - let isDarkMode: boolean; - if (configMode === "system") { - // kit-ui-check-ignore: forced "system" mode tracks the OS directly, never persisting - const mq = window.matchMedia("(prefers-color-scheme: dark)"); - isDarkMode = mq.matches; - forcedDark = isDarkMode; - const handler = (e: MediaQueryListEvent) => { - forcedDark = e.matches; - applyDarkClass(e.matches); - }; - mq.addEventListener("change", handler); - forcedCleanup = () => mq.removeEventListener("change", handler); - } else { - isDarkMode = configMode === "dark"; - forcedDark = isDarkMode; - } - applyDarkClass(isDarkMode); -} - -function resolveTheme(): void { - forcedCleanup?.(); - forcedCleanup = null; - forcedDark = null; - kitCleanupTheme(); - - const configMode = getThemeMode(); - if (configMode) { - // Initialize kit even on the forced path so its storage key is bound - // (a later toggle persists to the right place), then immediately drop - // its OS listener and override the classes with the forced mode. - // Lifecycle contract this leans on (covered by the forced-toggle unit - // test): kit cleanupTheme tears down only the OS-preference listener; - // the bound storage key and in-memory mode survive, so setThemeMode - // still works after cleanup. - kitInitTheme({ storageKey: THEME_KEY }); - kitCleanupTheme(); - applyForcedMode(configMode); - } else if (manualMode !== null) { - // Re-assert the session's manual choice instead of re-reading storage: - // when storage is blocked, kit's re-init would fall back to "system" - // and lose the toggle. - setThemeMode(manualMode); - } else { - // kit re-reads storage and re-arms its own OS-preference listener. - kitInitTheme({ storageKey: THEME_KEY }); - } - - applyThemeOverrides(getThemeColors(), getThemeFonts(), getThemeRadii()); -} +export { cleanupTheme, isDark }; export function initTheme(): void { - resolveTheme(); -} - -export function reapplyTheme(): void { - resolveTheme(); -} - -export function cleanupTheme(): void { - forcedCleanup?.(); - forcedCleanup = null; - manualMode = null; - kitCleanupTheme(); -} - -export function isDark(): boolean { - return forcedDark ?? kitIsDark(); -} - -export function isThemeToggleVisible(): boolean { - return getThemeMode() === undefined; + kitInitTheme({ storageKey: "kenn-forge-theme" }); } export function toggleTheme(): void { - // Manual control beats a forced embed mode until the next reapply (the - // keyboard shortcut can fire even while the header toggle is hidden), and - // persisting an explicit mode stops OS-preference tracking — both matching - // the old store's manual-override behavior. - const next = isDark() ? "light" : "dark"; - forcedCleanup?.(); - forcedCleanup = null; - forcedDark = null; - manualMode = next; - setThemeMode(next); -} - -export function applyThemeOverrides( - colors: Record | undefined | null, - fonts: Record | undefined | null, - radii: Record | undefined | null, -): void { - const style = document.documentElement.style; - - // Clear any previously applied overrides so removed keys revert - // to the stylesheet defaults. - for (const cssVar of appliedVars) { - style.removeProperty(cssVar); - } - appliedVars.clear(); - - function apply(map: Record, values: Record): void { - for (const [key, value] of Object.entries(values)) { - const cssVar = map[key]; - if (cssVar) { - style.setProperty(cssVar, value); - appliedVars.add(cssVar); - } - } - } - - if (colors) apply(COLOR_MAP, colors); - if (fonts) apply(FONT_MAP, fonts); - if (radii) apply(RADII_MAP, radii); + setThemeMode(isDark() ? "light" : "dark"); } diff --git a/frontend/src/lib/stores/tooling-status.svelte.test.ts b/frontend/src/lib/stores/tooling-status.svelte.test.ts index 3e3c907d73..9da799b3f7 100644 --- a/frontend/src/lib/stores/tooling-status.svelte.test.ts +++ b/frontend/src/lib/stores/tooling-status.svelte.test.ts @@ -2,9 +2,7 @@ import { Effect } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { makeAppRuntime, type OwnedAppRuntime } from "../app/runtime.js"; import { resolveToolingStatus, resetToolingStatusForTest } from "./tooling-status.svelte.js"; -import type { ToolingStatusValue } from "./embed-config.svelte.js"; - -const win = window as any; +import type { ToolingStatusValue } from "./tooling-status.svelte.js"; const serverStatus: ToolingStatusValue = { git: { available: true, version: "2.44.0" }, @@ -26,38 +24,12 @@ beforeEach(() => { }); afterEach(async () => { - delete win.__kenn_forge_config; await Effect.runPromise(runtime.disposeEffect); resetToolingStatusForTest(); vi.unstubAllGlobals(); }); describe("resolveToolingStatus", () => { - it("returns the embedder's tooling without fetching when embedded", async () => { - const fetcher = vi.fn(async () => toolingResponse(serverStatus)); - vi.stubGlobal("fetch", fetcher); - const embedded: ToolingStatusValue = { - git: { available: true }, - }; - win.__kenn_forge_config = { embed: { tooling: embedded } }; - win.__kenn_forge_notify_config_changed(); - - expect(resolveToolingStatus(runtime)).toEqual(embedded); - await Promise.resolve(); - expect(fetcher).not.toHaveBeenCalled(); - }); - - it("returns undefined while embedded with no tooling pushed yet", async () => { - const fetcher = vi.fn(async () => toolingResponse(serverStatus)); - vi.stubGlobal("fetch", fetcher); - win.__kenn_forge_config = { embed: {} }; - win.__kenn_forge_notify_config_changed(); - - expect(resolveToolingStatus(runtime)).toBeUndefined(); - await Promise.resolve(); - expect(fetcher).not.toHaveBeenCalled(); - }); - it("fetches the server probe once in standalone mode", async () => { const fetcher = vi.fn(async () => toolingResponse(serverStatus)); vi.stubGlobal("fetch", fetcher); diff --git a/frontend/src/lib/stores/tooling-status.svelte.ts b/frontend/src/lib/stores/tooling-status.svelte.ts index 6960ecb891..15bdc63b11 100644 --- a/frontend/src/lib/stores/tooling-status.svelte.ts +++ b/frontend/src/lib/stores/tooling-status.svelte.ts @@ -1,26 +1,13 @@ -// Tooling status with a standalone fallback. Embedded, the host -// pushes its own probe into the embed config slot -// (__kenn_forge_update_tooling) and that value wins. Standalone, no -// embedder exists to fill the slot, so the first read lazily fetches -// the server's native probe from GET /api/v1/tooling-status. The -// fetch fires once per page load — the server caches probes -// internally, and tool/auth state changes rarely. - import { Effect } from "effect"; import type { AppRuntime } from "../app/runtime.js"; -import { getToolingStatus, isEmbedded, type ToolingStatusValue } from "./embed-config.svelte.ts"; +import type { ServerToolingStatus } from "./tooling-status-workflow.js"; + +export type ToolingStatusValue = ServerToolingStatus; import { ToolingStatusWorkflow } from "./tooling-status-workflow.js"; let fetched = $state(undefined); -// resolveToolingStatus returns the embedder's tooling status when one -// is configured, falling back to the server's native probe in -// standalone mode. Reading it from a $derived keeps consumers -// reactive to both the embed-config push and the fetch completing. export function resolveToolingStatus(runtime: AppRuntime): ToolingStatusValue | undefined { - if (isEmbedded()) { - return getToolingStatus(); - } runtime.runCommand( Effect.gen(function* () { const workflow = yield* ToolingStatusWorkflow; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b6cc55ccb1..dcc4131d4e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,26 +1,5 @@ import type { WorkspaceItemIdentity } from "./workspace-inline.js"; -export interface Action { - id: string; - label: string; - icon?: string; - handler: (context: ActionContext) => void | Promise; -} - -export interface ActionContext { - surface: string; - owner: string; - name: string; - number: number; - meta?: Record; -} - -export interface ActionRegistry { - pull?: Action[]; - issue?: Action[]; - activity?: Action[]; -} - export interface NavigateEvent { path: string; route: { @@ -53,37 +32,12 @@ export interface NavigateOptions { export type NavigateCallback = (event: string | NavigateEvent, options?: NavigateOptions) => void; -export interface WorkspaceCommandResult { - ok: boolean; - message?: string; -} - -export type WorkspaceCommandCallback = ( - command: string, - payload: Record, -) => WorkspaceCommandResult | Promise; - export type WorkspaceDeletedCallback = ( workspaceId: string, workspaceHostKey?: string, identity?: WorkspaceItemIdentity, ) => void; -export interface ForgeEvent { - type: "pr-selected" | "issue-selected" | "pr-state-changed" | "sync-completed" | "detail-loaded"; - owner?: string; - name?: string; - number?: number; - meta?: Record; -} - -export type EventCallback = (event: ForgeEvent) => void; - -export type PrepareRouteCallback = ( - repo: { host?: string; owner: string; name: string }, - target?: { kind: "pull" | "issue"; number: number }, -) => void | Promise; - export interface HostStateAccessors { getGlobalRepo?: () => string | undefined; getGroupByRepo?: () => boolean; @@ -91,8 +45,6 @@ export interface HostStateAccessors { } export interface UIConfig { - hideStar?: boolean; - hideSettings?: boolean; basePath?: string; } @@ -157,7 +109,5 @@ export interface StoreInstances { } export interface SidebarAccessors { - isEmbedded: () => boolean; - isSidebarToggleEnabled: () => boolean; toggleSidebar: () => void; } diff --git a/frontend/src/lib/utils/appShell.test.ts b/frontend/src/lib/utils/appShell.test.ts deleted file mode 100644 index 0e7466a8f4..0000000000 --- a/frontend/src/lib/utils/appShell.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { shouldUseFullAppShell } from "./appShell.js"; - -describe("app shell selection", () => { - it("does not use full app startup for workspace embed pages", () => { - expect(shouldUseFullAppShell("embed-workspace-list")).toBe(false); - expect(shouldUseFullAppShell("embed-workspace-terminal")).toBe(false); - expect(shouldUseFullAppShell("embed-workspace-detail")).toBe(false); - expect(shouldUseFullAppShell("embed-workspace-empty")).toBe(false); - expect(shouldUseFullAppShell("embed-workspace-first-run")).toBe(false); - expect(shouldUseFullAppShell("embed-workspace-project")).toBe(false); - }); - - it("uses the full app shell for standalone pages", () => { - expect(shouldUseFullAppShell("activity")).toBe(true); - expect(shouldUseFullAppShell("docs")).toBe(true); - expect(shouldUseFullAppShell("workspaces")).toBe(true); - expect(shouldUseFullAppShell("terminal")).toBe(true); - }); -}); diff --git a/frontend/src/lib/utils/appShell.ts b/frontend/src/lib/utils/appShell.ts deleted file mode 100644 index bddf7b9a16..0000000000 --- a/frontend/src/lib/utils/appShell.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { isWorkspaceEmbedPage, type Page } from "../stores/router.svelte.js"; - -export function shouldUseFullAppShell(page: Page): boolean { - return !isWorkspaceEmbedPage(page); -} diff --git a/frontend/src/lib/utils/repo-filter-values.test.ts b/frontend/src/lib/utils/repo-filter-values.test.ts index 44248602bf..21496c537d 100644 --- a/frontend/src/lib/utils/repo-filter-values.test.ts +++ b/frontend/src/lib/utils/repo-filter-values.test.ts @@ -4,7 +4,6 @@ import { canonicalRepoFilterValue, displayRepoFilterValue, interactiveRepoFilterIdentities, - normalizeGlobalRepoSelection, normalizeInteractiveRepoFilterSelection, normalizeRepoFilterSelection, normalizeRepoFilterValue, @@ -132,28 +131,6 @@ describe("repo filter values", () => { expect(normalizeInteractiveRepoFilterSelection("github|github.com/acme/archive", repos)).toBeUndefined(); }); - it("preserves a host-pinned scope even when the pinned repository is hidden", () => { - // With ui.hideRepoSelector there is no picker to rescope with; dropping - // the pinned selection would silently unscope every request. - const repos = [ - { - provider: "github", - platform_host: "github.com", - owner: "acme", - name: "archive", - repo_path: "acme/archive", - is_glob: false, - matched_repo_count: 1, - hidden_from_ui: true, - }, - ]; - - expect(normalizeGlobalRepoSelection("github|github.com/acme/archive", repos, true)).toBe( - "github|github.com/acme/archive", - ); - expect(normalizeGlobalRepoSelection("github|github.com/acme/archive", repos, false)).toBeUndefined(); - }); - it("keeps glob-resolved selections that have no configured entry", () => { const repos = [ { diff --git a/frontend/src/lib/utils/repo-filter-values.ts b/frontend/src/lib/utils/repo-filter-values.ts index 3b7c9182f4..52aec0ebe2 100644 --- a/frontend/src/lib/utils/repo-filter-values.ts +++ b/frontend/src/lib/utils/repo-filter-values.ts @@ -181,18 +181,3 @@ export function normalizeInteractiveRepoFilterSelection( const remaining = parseRepoFilterSelection(selected).filter((value) => !hidden.has(canonicalSelectionValue(value))); return normalizeRepoFilterSelection(serializeRepoFilterSelection(remaining), interactiveRepoFilterIdentities(repos)); } - -// normalizeGlobalRepoSelection applies interactive normalization only when -// the selection is user-editable. A host-pinned scope (ui.hideRepoSelector) -// offers no picker to rescope with, so dropping it — for example because the -// pinned repository is hidden from interactive catalogs — would silently -// unscope every pull, issue, and activity request. Pinned scopes pass -// through untouched. -export function normalizeGlobalRepoSelection( - selected: string | undefined, - repos: readonly ConfigRepo[], - pinnedScope: boolean, -): string | undefined { - if (pinnedScope) return selected; - return normalizeInteractiveRepoFilterSelection(selected, repos); -} diff --git a/frontend/src/lib/views/FocusListView.svelte b/frontend/src/lib/views/FocusListView.svelte index ea0b6a925f..ee55822a74 100644 --- a/frontend/src/lib/views/FocusListView.svelte +++ b/frontend/src/lib/views/FocusListView.svelte @@ -11,7 +11,7 @@ } from "@kenn-io/kit-ui"; import { getAppRuntime } from "../app/runtime-context.js"; import type { AppExecution } from "../app/runtime.js"; - import { getStores, getNavigate, getActions } from "../context.js"; + import { getStores, getNavigate } from "../context.js"; import { groupByWorkflow } from "../stores/workflow.svelte.js"; import PullItem from "../components/sidebar/PullItem.svelte"; import IssueItem from "../components/sidebar/IssueItem.svelte"; @@ -39,13 +39,7 @@ const { pulls, issues, sync, settings, grouping, activity } = getStores(); const runtime = getAppRuntime(); const navigate = getNavigate(); - const actions = getActions(); - const importAction = $derived( - (actions.pull ?? []).find( - (a) => a.id === "import-worktree", - ), - ); const groupingMode = $derived( grouping.getGroupingMode(), ); @@ -635,7 +629,6 @@ })} showRepo={!repo} selected={false} - {importAction} onclick={() => handlePRSelect(prRef)} /> {/each} @@ -655,7 +648,6 @@ })} showRepo={!repo} selected={false} - {importAction} onclick={() => handlePRSelect(prRef)} /> {/each} diff --git a/frontend/src/lib/views/IssueListView.svelte b/frontend/src/lib/views/IssueListView.svelte index 327f878763..58c01e5097 100644 --- a/frontend/src/lib/views/IssueListView.svelte +++ b/frontend/src/lib/views/IssueListView.svelte @@ -17,7 +17,7 @@ import type { InlineWorkspaceController, WorkspaceItemIdentity } from "../workspace-inline.js"; import { useItemWorkspaceClaim } from "../item-workspace-claim.svelte.js"; - const { isSidebarToggleEnabled, toggleSidebar } = getSidebar(); + const { toggleSidebar } = getSidebar(); const { issues } = getStores(); interface Props { @@ -134,7 +134,7 @@ {sidebarWidth} {onSidebarResize} overlay={sidebarOverlay} - showCollapsedStrip={isSidebarToggleEnabled()} + showCollapsedStrip={true} onExpand={toggleSidebar} mainEmpty={selectedIssue === null} > diff --git a/frontend/src/lib/views/IssueListView.test.ts b/frontend/src/lib/views/IssueListView.test.ts index 691bb1bf5d..e89508dad2 100644 --- a/frontend/src/lib/views/IssueListView.test.ts +++ b/frontend/src/lib/views/IssueListView.test.ts @@ -89,7 +89,6 @@ function renderIssueListView(options: RenderIssueListViewOptions = {}) { [ SIDEBAR_KEY, { - isSidebarToggleEnabled: () => false, toggleSidebar: vi.fn(), }, ], diff --git a/frontend/src/lib/views/PRListView.svelte b/frontend/src/lib/views/PRListView.svelte index c169e03337..2877f6ab13 100644 --- a/frontend/src/lib/views/PRListView.svelte +++ b/frontend/src/lib/views/PRListView.svelte @@ -28,7 +28,7 @@ type StackMemberNavigate = (ref: PullRequestRouteRef) => boolean | void; - const { isSidebarToggleEnabled, toggleSidebar } = getSidebar(); + const { toggleSidebar } = getSidebar(); const navigate = getNavigate(); const { detail: detailStore } = getStores(); interface Props { @@ -248,7 +248,7 @@ {sidebarWidth} {onSidebarResize} overlay={sidebarOverlay} - showCollapsedStrip={isSidebarToggleEnabled()} + showCollapsedStrip={true} onExpand={toggleSidebar} mainEmpty={selectedPR === null} > diff --git a/frontend/src/lib/views/PRListView.test.ts b/frontend/src/lib/views/PRListView.test.ts index 7e652b7c86..ca05767f15 100644 --- a/frontend/src/lib/views/PRListView.test.ts +++ b/frontend/src/lib/views/PRListView.test.ts @@ -119,7 +119,6 @@ function renderPRListView(options: RenderPRListViewOptions = {}) { [ SIDEBAR_KEY, { - isSidebarToggleEnabled: () => false, toggleSidebar: vi.fn(), }, ], diff --git a/frontend/src/lib/views/PRListView.workspaceDraft.test.ts b/frontend/src/lib/views/PRListView.workspaceDraft.test.ts index 912c82a722..fed2e39ef7 100644 --- a/frontend/src/lib/views/PRListView.workspaceDraft.test.ts +++ b/frontend/src/lib/views/PRListView.workspaceDraft.test.ts @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render } from "@testing-library/svelte"; import { tick } from "svelte"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { PullDetail } from "../api/types.js"; -import { ACTIONS_KEY, NAVIGATE_KEY, SIDEBAR_KEY, STORES_KEY, UI_CONFIG_KEY } from "../context.js"; +import { NAVIGATE_KEY, SIDEBAR_KEY, STORES_KEY } from "../context.js"; import type { PullRequestRouteRef } from "../routes.js"; import { createDetailActivityViewStore } from "../stores/detail-activity-view.svelte.js"; import { resetModalStack } from "../stores/keyboard/modal-stack.svelte.js"; @@ -179,13 +179,10 @@ function renderWithRealPullDetail( }, }, ], - [ACTIONS_KEY, { pull: [] }], - [UI_CONFIG_KEY, { hideStar: true }], [NAVIGATE_KEY, vi.fn()], [ SIDEBAR_KEY, { - isSidebarToggleEnabled: () => false, toggleSidebar: vi.fn(), }, ], diff --git a/frontend/src/lib/workspace-types.d.ts b/frontend/src/lib/workspace-types.d.ts index 5a71440915..0b66461e15 100644 --- a/frontend/src/lib/workspace-types.d.ts +++ b/frontend/src/lib/workspace-types.d.ts @@ -1,9 +1,5 @@ /** - * Global workspace type declarations for the frontend. - * - * These mirror the types in frontend/src/vite-env.d.ts so that - * Svelte components can reference them without - * explicit imports. + * Build environment declarations shared by frontend modules. */ // import.meta.env shape for source modules that do not import Vite directly, @@ -20,89 +16,3 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } - -interface WorkspaceHost { - key: string; - label: string; - connectionState: "connected" | "connecting" | "disconnected" | "error"; - transport?: "http" | "local"; - platform?: string; - projects: WorkspaceProject[]; - sessions: WorkspaceSession[]; - resources: WorkspaceResources | null; -} - -interface WorkspaceProject { - key: string; - name: string; - kind: "repository" | "scratch"; - repoKind: string; - defaultBranch: string; - platformRepo: string | null; - platformURL?: string; - worktrees: WorkspaceWorktree[]; -} - -interface WorkspaceWorktree { - key: string; - name: string; - branch: string; - isPrimary: boolean; - isHidden: boolean; - isStale: boolean; - sessionBackend: string | null; - linkedPR: WorkspaceLinkedPR | null; - activity: WorkspaceActivity; - diff: WorkspaceDiff | null; -} - -interface WorkspaceLinkedPR { - number: number; - title: string; - state: "open" | "closed" | "merged"; - checksStatus: string | null; - updatedAt: string | null; -} - -interface WorkspaceActivity { - state: "idle" | "active" | "running" | "needsAttention"; - lastOutputAt: string | null; -} - -interface WorkspaceDiff { - added: number; - removed: number; -} - -interface WorkspaceSession { - key: string; - name: string; - worktreeKey: string | null; - isHidden: boolean; -} - -interface WorkspaceResources { - cpuPercent: number; - residentMB: number; -} - -interface WorkspaceData { - hosts: WorkspaceHost[]; - selectedWorktreeKey: string | null; - selectedHostKey: string | null; -} - -interface CommandResult { - ok: boolean; - message?: string; -} - -interface WorkspaceCommandHandler { - (command: string, payload: Record): CommandResult | Promise; -} - -interface WorkspaceDetailContext { - worktree: WorkspaceWorktree | null; - project: WorkspaceProject | null; - host: WorkspaceHost | null; -} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 19b9df7b0e..d6e15d5a2c 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,5 +1,4 @@ import { Cause, Effect } from "effect"; -import { notifyInitialRouteChange } from "./lib/stores/router.svelte.js"; import "./app.css"; import { mountApplication } from "./lib/app/mount.js"; import { makeAppRuntime } from "./lib/app/runtime.js"; @@ -84,7 +83,3 @@ runtime.runCommand( mountApplication(target, runtime, (cause) => { console.error("Frontend application Effect failed", Cause.pretty(cause)); }); -runtime.runMicrotask(notifyInitialRouteChange, { - operation: "publish initial route", - safeContext: {}, -}); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index caa07689a6..83af3f909d 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -16,260 +16,15 @@ declare module "@xterm/addon-ligatures/lib/addon-ligatures.mjs" { export { LigaturesAddon } from "@xterm/addon-ligatures"; } -interface ForgeConfig { - theme?: { - mode?: "light" | "dark" | "system"; - colors?: Partial<{ - bgPrimary: string; - bgSurface: string; - bgSurfaceHover: string; - bgInset: string; - borderDefault: string; - borderMuted: string; - textPrimary: string; - textSecondary: string; - textMuted: string; - accentBlue: string; - accentAmber: string; - accentPurple: string; - accentGreen: string; - accentRed: string; - accentTeal: string; - overlayBg: string; - shadowSm: string; - shadowMd: string; - shadowLg: string; - kanbanNew: string; - kanbanReviewing: string; - kanbanWaiting: string; - kanbanAwaitingMerge: string; - }>; - fonts?: Partial<{ - sans: string; - mono: string; - }>; - radii?: Partial<{ - sm: string; - md: string; - lg: string; - }>; - }; - ui?: { - hideSync?: boolean; - hideRepoSelector?: boolean; - hideStar?: boolean; - sidebarCollapsed?: boolean; - repo?: { - provider?: string; - host?: string; - platform_host?: string; - repo_path?: string; - owner?: string; - name?: string; - }; - host?: string; - activeWorktreeKey?: string; - }; - actions?: { - pullRequest?: ActionHookDef[]; - issue?: ActionHookDef[]; - project?: ProjectActionDef[]; - }; - workspace?: WorkspaceData; - onWorkspaceCommand?: WorkspaceCommandHandler; - embed?: { - hideHeader?: boolean; - hideStatusBar?: boolean; - initialRoute?: string; - sidebarWidth?: number; - activePlatformHost?: string | null; - panelMode?: boolean; - hoverCardsEnabled?: boolean; - tooling?: ToolingStatus; - }; - onLayoutChanged?: (layout: { sidebar: { width: number }; pinnedPanel: { width: number; visible: boolean } }) => void; - onNavigate?: (event: ForgeNavigateEvent) => void; - onRouteChange?: (event: ForgeNavigateEvent) => void; -} - -interface ActionHookDef { - id: string; - label: string; - handler: (context: { - surface: string; - owner: string; - name: string; - number: number; - meta?: Record; - }) => void | Promise; -} - -// ProjectActionDef is the registry shape for project-scoped actions such -// as new-worktree. The handler MUST return a CommandResult so the firing -// surface can render success/failure instead of a fire-and-forget click. -// The action ID is the identifier the surface uses to look up the handler. -interface ProjectActionDef { - id: string; - label: string; - handler: (context: { - surface: string; - projectId?: string; - hostKey?: string; - meta?: Record; - }) => CommandResult | Promise; -} - -// ToolingStatus reports the embedding host's view of git/gh availability. -// The First Run Panel and the New Worktree sheet read this to gate the -// GitHub-dependent surfaces and surface specific recovery copy when a -// tool is missing. -interface ToolingStatus { - git?: { - available: boolean; - version?: string; - }; - gh?: { - available: boolean; - authenticated: boolean; - user?: string; - host?: string; - }; - glab?: { - available: boolean; - authenticated: boolean; - user?: string; - host?: string; - }; -} - -interface WorkspaceHost { - key: string; - label: string; - connectionState: "connected" | "connecting" | "disconnected" | "error"; - transport?: "http" | "local"; - platform?: string; - projects: WorkspaceProject[]; - sessions: WorkspaceSession[]; - resources: WorkspaceResources | null; -} - -interface WorkspaceProject { - key: string; - name: string; - kind: "repository" | "scratch"; - repoKind: string; - defaultBranch: string; - platformRepo: string | null; - platformURL?: string; - worktrees: WorkspaceWorktree[]; -} - -interface WorkspaceWorktree { - key: string; - name: string; - branch: string; - isPrimary: boolean; - isHidden: boolean; - isStale: boolean; - sessionBackend: string | null; - linkedPR: WorkspaceLinkedPR | null; - activity: WorkspaceActivity; - diff: WorkspaceDiff | null; -} - -interface WorkspaceLinkedPR { - number: number; - title: string; - state: "open" | "closed" | "merged"; - checksStatus: string | null; - updatedAt: string | null; -} - -interface WorkspaceActivity { - state: "idle" | "active" | "running" | "needsAttention"; - lastOutputAt: string | null; -} - -interface WorkspaceDiff { - added: number; - removed: number; -} - -interface WorkspaceSession { - key: string; - name: string; - worktreeKey: string | null; - isHidden: boolean; -} - -interface WorkspaceResources { - cpuPercent: number; - residentMB: number; -} - -interface WorkspaceData { - hosts: WorkspaceHost[]; - selectedWorktreeKey: string | null; - selectedHostKey: string | null; -} - -interface CommandResult { - ok: boolean; - message?: string; -} - -interface WorkspaceCommandHandler { - (command: string, payload: Record): CommandResult | Promise; -} - -interface WorkspaceDetailContext { - worktree: WorkspaceWorktree | null; - project: WorkspaceProject | null; - host: WorkspaceHost | null; -} - -type ForgeNavigatePage = "pulls" | "issues" | "activity" | "repos" | "kata" | "docs" | "reviews" | "workspaces"; - -type ForgeNavigateType = "pull" | "issue" | "activity" | "repos" | "kata" | "docs" | "reviews" | "workspaces"; - -interface ForgeNavigateEvent { - page: ForgeNavigatePage; - type: ForgeNavigateType; - provider?: string; - platform_host?: string; - repo_path?: string; - owner?: string; - name?: string; - number?: number; - focus: boolean; - view: string; - repo?: string; - host?: string; -} - interface Window { __BASE_PATH__?: string; __KENN_FORGE_DEV_API_URL__?: string; - __KENN_EMBEDDED_WEBSOCKET_BASE_URL__?: string; __KENN_FORGE_FORCE_MOBILE_ROUTES__?: boolean; - __kenn_forge_config?: ForgeConfig; + __kenn_forge_active_worktree_key?: string; __kenn_forge_event_source_counts?: () => { created: number; closed: number; }; - __kenn_forge_notify_config_changed?: () => void; - __kenn_forge_update_workspace?: (data: WorkspaceData) => void; - __kenn_forge_navigate_to_route?: (route: string) => void; - __kenn_forge_set_repo_filter?: (repo: { owner: string; name: string } | null) => void; - __kenn_forge_update_selection?: (selection: { hostKey?: string | null; worktreeKey?: string | null }) => void; - __kenn_forge_update_host_state?: ( - hostKey: string, - patch: { - connectionState?: WorkspaceHost["connectionState"]; - resources?: WorkspaceResources | null; - }, - ) => void; - __kenn_forge_update_tooling?: (tooling: ToolingStatus) => void; __kenn_forge_kata_graph_debug?: { snapshot: () => { events: Array<{ diff --git a/frontend/tests/e2e-full/00-inline-workspace-continuity.spec.ts b/frontend/tests/e2e-full/00-inline-workspace-continuity.spec.ts index 86fd86d92e..d515d0d5ad 100644 --- a/frontend/tests/e2e-full/00-inline-workspace-continuity.spec.ts +++ b/frontend/tests/e2e-full/00-inline-workspace-continuity.spec.ts @@ -1602,42 +1602,6 @@ test.describe("inline workspace pane continuity", () => { } }); - test("an embedded terminal route renders a live pooled session", async ({ page }) => { - // The embed routes replace the whole app shell, so they never mount - // WorkspaceHost — and therefore never got the pool that now owns every - // session terminal. Every session pane on this route rendered an empty - // portal slot until the embed shell mounted its own. - test.skip( - !hasCommand("git") || !hasCommand("tmux", ["-V"]), - "git and tmux are required for the real workspace flow", - ); - - let isolatedServer: IsolatedE2EServer | null = null; - let api: APIRequestContext | null = null; - try { - isolatedServer = await startIsolatedWorkspaceE2EServer(); - api = await playwrightRequest.newContext({ baseURL: isolatedServer.info.base_url }); - const workspace = await createIssueWorkspace(api, 10); - - await page.goto(`${isolatedServer.info.base_url}/workspaces/embed/terminal/${workspace.id}`); - await openTerminalPanel(page); - await page.getByRole("button", { name: "New terminal" }).click(); - const moveSession = page.getByRole("button", { name: /^Move (?!terminal panel).+ to workflow$/ }).first(); - await expect(moveSession).toBeVisible(); - await moveSession.click(); - await page.getByRole("button", { name: "Close terminal panel", exact: true }).nth(1).click(); - - const workflowContainer = page.locator(".session-terminal-slot .terminal-container"); - await expect(workflowContainer).toBeVisible(); - // A slot with no pool behind it is an empty div: this cannot pass without - // a terminal attached to the real tmux session. - await typeMarkerCommand(page, workflowContainer, workspace.worktree_path, "embed-pooled-marker"); - } finally { - await api?.dispose(); - await isolatedServer?.stop(); - } - }); - test("a promoted terminal is part of its workspace's dock", async ({ page }) => { // The dock is a view of every pane of the workspace once a session is promoted. // Only the real app proves it: the store tests drive controllers directly, so diff --git a/frontend/tests/e2e-full/00-settings-terminal-font.spec.ts b/frontend/tests/e2e-full/00-settings-terminal-font.spec.ts index 04563e7bd8..1d9a0f8aed 100644 --- a/frontend/tests/e2e-full/00-settings-terminal-font.spec.ts +++ b/frontend/tests/e2e-full/00-settings-terminal-font.spec.ts @@ -206,92 +206,6 @@ test("settings saves visible modes and hides disabled nav entries", async ({ pag test.describe("terminal options popover", () => { test.describe.configure({ timeout: lockedWorkspaceTestTimeoutMs }); - test("hydrates embedded terminal settings before zoom can persist them", async ({ page }) => { - test.skip( - !hasCommand("git") || !hasCommand("tmux", ["-V"]), - "git and tmux are required for the real workspace flow", - ); - - let workspaceServer: IsolatedE2EServer | null = null; - let workspaceApi: APIRequestContext | null = null; - let settingsDelayed = false; - let releaseSettings: (() => void) | undefined; - try { - workspaceServer = await startIsolatedWorkspaceE2EServer(); - workspaceApi = await playwrightRequest.newContext({ - baseURL: workspaceServer.info.base_url, - }); - const configuredTerminal = { - font_family: '"Iosevka Term", monospace', - font_size: 17, - scrollback: 4200, - line_height: 1.2, - letter_spacing: 1, - retained_sessions: 10, - cursor_blink: false, - font_ligatures: true, - hide_tmux_status: true, - graphics: true, - tmux_mouse: true, - }; - const settingsResponse = await workspaceApi.put("/api/v1/settings", { - data: { terminal: configuredTerminal }, - }); - expect(settingsResponse.ok()).toBe(true); - - const createResponse = await workspaceApi.post("/api/v1/issues/github/acme/widgets/10/workspace", { - data: {}, - }); - expect(createResponse.status()).toBe(202); - const workspace = (await createResponse.json()) as WorkspaceStatusResponse; - await waitForWorkspaceReady(workspaceApi, workspace.id); - - let settingsRequestStarted: (() => void) | undefined; - const settingsRequest = new Promise((resolve) => { - settingsRequestStarted = resolve; - }); - await page.route("**/api/v1/settings", async (route) => { - if (route.request().method() === "GET" && !settingsDelayed) { - settingsDelayed = true; - settingsRequestStarted?.(); - await new Promise((resolve) => { - releaseSettings = resolve; - }); - } - await route.continue(); - }); - - await page.goto(`${workspaceServer.info.base_url}/workspaces/embed/terminal/${workspace.id}`); - await settingsRequest; - await expect(page.locator(".terminal-view")).toHaveCount(0); - - const retrySettings = page.getByRole("button", { - name: "Retry terminal settings", - }); - await expect(retrySettings).toBeVisible({ timeout: 10_000 }); - await retrySettings.click(); - const resetZoom = page.getByRole("button", { name: "Reset terminal font size" }); - await expect(resetZoom).toHaveText("17px"); - await page.getByRole("button", { name: "Open terminal panel" }).click(); - await expect(page.locator(".terminal-container").locator("canvas, .xterm-screen").first()).toBeVisible(); - await page.getByRole("button", { name: "Increase terminal font size" }).click(); - - await expect - .poll(async () => { - const response = await workspaceApi!.get("/api/v1/settings"); - const settings = (await response.json()) as { - terminal: typeof configuredTerminal; - }; - return settings.terminal; - }) - .toEqual({ ...configuredTerminal, font_size: 18 }); - } finally { - releaseSettings?.(); - await workspaceApi?.dispose(); - await workspaceServer?.stop(); - } - }); - test("live previews, reverts unsaved changes, and saves from the toolbar", async ({ page }) => { test.skip( !hasCommand("git") || !hasCommand("tmux", ["-V"]), diff --git a/frontend/tests/e2e-full/activity-live-refresh.spec.ts b/frontend/tests/e2e-full/activity-live-refresh.spec.ts index c1c36ea0f1..c1c69abbfb 100644 --- a/frontend/tests/e2e-full/activity-live-refresh.spec.ts +++ b/frontend/tests/e2e-full/activity-live-refresh.spec.ts @@ -606,76 +606,6 @@ test("viewed-hot PR fast sync refreshes Activity without a notification", async } }); -test("replays missed Activity changes after provider-store handoff", async ({ page }) => { - const server = await startIsolatedE2EServer(); - try { - const eventURLs: string[] = []; - let detailGetCount = 0; - page.on("request", (request) => { - const url = new URL(request.url()); - if (url.pathname.endsWith("/api/v1/events")) eventURLs.push(url.toString()); - if (request.method() === "GET" && url.pathname === detailPath) detailGetCount++; - }); - - await page.goto(`${server.info.base_url}${selectedActivityRoute}`); - const detail = page.locator(".activity-detail"); - await expect(detail.locator(".pull-detail")).toBeVisible(); - await expect.poll(() => eventURLs.length).toBeGreaterThanOrEqual(1); - await expect(detail.locator(".sync-indicator")).toHaveCount(0, { timeout: 15_000 }); - - const acceptedComment = "Accepted before provider handoff"; - const acceptedEventID = await persistActivityComment(page, server.info.base_url, acceptedComment); - await expect(detail.getByText(acceptedComment, { exact: true })).toBeVisible(); - const preHandoffDetailResponse = await page.request.get(`${server.info.base_url}${detailPath}`); - expect(preHandoffDetailResponse.ok()).toBe(true); - const preHandoffDetail = await preHandoffDetailResponse.json(); - - await page.evaluate(() => window.dispatchEvent(new Event("beforeunload"))); - await page.evaluate(() => { - history.pushState(null, "", "/workspaces/embed/empty/noSelection"); - window.dispatchEvent(new PopStateEvent("popstate")); - }); - await expect(page.getByText("Select a workspace from the sidebar", { exact: true })).toBeVisible(); - - const missedComment = "Persisted while provider stores were replaced"; - const missedEventID = await persistActivityComment(page, server.info.base_url, missedComment, false); - expect(missedEventID).toBeGreaterThan(acceptedEventID); - const detailReadsBeforeReturn = detailGetCount; - let returnDetailReads = 0; - await page.route(`**${detailPath}`, async (route) => { - if (route.request().method() !== "GET" || returnDetailReads > 0) { - await route.continue(); - return; - } - returnDetailReads++; - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(preHandoffDetail), - }); - }); - await page.route("**/sync/async", async (route) => { - await route.fulfill({ status: 202 }); - }); - - await page.evaluate((path) => { - history.pushState(null, "", path); - window.dispatchEvent(new PopStateEvent("popstate")); - }, selectedActivityRoute); - - await expect.poll(() => eventURLs.length).toBeGreaterThanOrEqual(2); - const resumedEventURL = eventURLs.at(-1); - expect(resumedEventURL).toBeDefined(); - if (resumedEventURL === undefined) throw new Error("provider event stream did not reconnect"); - const resumedURL = new URL(resumedEventURL); - expect(resumedURL.searchParams.get("since")).toBe(String(acceptedEventID)); - await expect.poll(() => detailGetCount).toBeGreaterThan(detailReadsBeforeReturn + 1); - await expect(page.locator(".activity-detail").getByText(missedComment, { exact: true })).toBeVisible(); - } finally { - await server.stop(); - } -}); - test("notification-warm PR fast sync refreshes the selected Activity detail", async ({ page }) => { const server = await startIsolatedE2EServer(); try { diff --git a/frontend/tests/e2e-full/browser-security.spec.ts b/frontend/tests/e2e-full/browser-security.spec.ts index fba757d147..12e760a4fa 100644 --- a/frontend/tests/e2e-full/browser-security.spec.ts +++ b/frontend/tests/e2e-full/browser-security.spec.ts @@ -177,44 +177,6 @@ test("SPA shell cannot be framed by another origin", async ({ page, baseURL }) = } }); -test("workspace embed routes remain frameable", async ({ page, baseURL }) => { - expect(baseURL).toBeTruthy(); - const target = apiURL(baseURL!, "/workspaces/embed/list"); - const response = await page.request.get(target); - const headers = response.headers(); - expect(headers["content-security-policy"]).toBeUndefined(); - expect(headers["x-frame-options"]).toBeUndefined(); - - const consoleErrors: string[] = []; - page.on("console", (message) => { - if (message.type() === "error") { - consoleErrors.push(message.text()); - } - }); - - const escapedTarget = target.replaceAll('"', """); - const externalOrigin = await startExternalOrigin( - `embed host`, - ); - - try { - await page.goto(externalOrigin.url); - const frameHandle = await page.locator("#kenn-forge-frame").elementHandle(); - expect(frameHandle).not.toBeNull(); - const frame = await frameHandle!.contentFrame(); - expect(frame).not.toBeNull(); - await expect(frame!.locator("body")).toHaveCount(1); - expect( - consoleErrors.some( - (text) => - text.includes("frame-ancestors") || text.includes("X-Frame-Options") || text.includes("Refused to frame"), - ), - ).toBe(false); - } finally { - await externalOrigin.close(); - } -}); - test("same-origin browser bodyless mutations do not require a content type", async ({ page, baseURL }) => { expect(baseURL).toBeTruthy(); await page.goto(baseURL!); diff --git a/frontend/tests/e2e-full/embedded-config.spec.ts b/frontend/tests/e2e-full/embedded-config.spec.ts deleted file mode 100644 index 53beafd919..0000000000 --- a/frontend/tests/e2e-full/embedded-config.spec.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { mkdtempSync, realpathSync, rmSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { expect, test, type Page } from "@playwright/test"; -import type { - ListProjectsOutputBody, - ListWorktreesOutputBody, - ProjectResponse as GeneratedProjectResponse, - RegisterProjectInputBody, - RegisterWorktreeInputBody, -} from "../../src/lib/api/generated/models/index.js"; - -import { startIsolatedE2EServerWithOptions } from "./support/e2eServer"; - -type ProjectResponse = GeneratedProjectResponse; -type ProjectListResponse = ListProjectsOutputBody; -type WorktreeListResponse = ListWorktreesOutputBody; -type RegisterProjectInput = RegisterProjectInputBody; -type RegisterWorktreeInput = RegisterWorktreeInputBody; - -type SnapshotResponse = { - hosts: Array<{ - configKey: string; - kind: string; - name: string; - }>; -}; - -async function waitForPRList(page: Page): Promise { - await page.locator(".pull-item").first().waitFor({ state: "visible", timeout: 10_000 }); -} - -async function sidebarWidth(page: Page): Promise { - return Math.round( - await page - .locator(".kit-sidebar-layout__sidebar") - .first() - .evaluate((node) => node.getBoundingClientRect().width), - ); -} - -test.describe("embedded config", () => { - test("hides sync button when hideSync is true", async ({ page }) => { - await page.addInitScript(() => { - window.__kenn_forge_config = { ui: { hideSync: true } }; - }); - await page.goto("/pulls"); - await waitForPRList(page); - - await expect(page.locator(".action-btn", { hasText: "Sync" })).not.toBeVisible(); - }); - - test("hides repo selector when hideRepoSelector is true", async ({ page }) => { - await page.addInitScript(() => { - window.__kenn_forge_config = { ui: { hideRepoSelector: true } }; - }); - await page.goto("/pulls"); - await waitForPRList(page); - - await expect(page.locator(".typeahead")).not.toBeAttached(); - }); - - test("hides star button when hideStar is true", async ({ page }) => { - await page.addInitScript(() => { - window.__kenn_forge_config = { ui: { hideStar: true } }; - }); - await page.goto("/pulls"); - await waitForPRList(page); - - // Open a PR detail. - await page.locator(".pull-item").first().click(); - await page.locator(".pull-detail").waitFor({ state: "visible", timeout: 10_000 }); - - await expect(page.locator(".pull-detail .star-btn")).not.toBeAttached(); - }); - - test("hides theme toggle when theme.mode is set", async ({ page }) => { - await page.addInitScript(() => { - window.__kenn_forge_config = { theme: { mode: "dark" } }; - }); - await page.goto("/pulls"); - await waitForPRList(page); - - await expect(page.locator("button[title='Toggle theme']")).not.toBeAttached(); - }); - - test("host sidebarWidth overrides persisted width on pulls", async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem("kenn-forge-sidebar-width", "520"); - window.__kenn_forge_config = { embed: { sidebarWidth: 410 } }; - }); - await page.goto("/pulls"); - await waitForPRList(page); - - await expect.poll(async () => sidebarWidth(page)).toBe(410); - - await page.reload(); - await waitForPRList(page); - - await expect.poll(async () => sidebarWidth(page)).toBe(410); - }); - - test("settings page is blocked in embedded mode", async ({ page }) => { - await page.addInitScript(() => { - window.__kenn_forge_config = { embed: {} }; - }); - await page.goto("/settings"); - - // When embedded, /settings is not a valid route and falls - // through to the activity page. The URL may still say /settings - // but the activity feed should render instead. - await page.locator(".activity-feed").waitFor({ state: "visible", timeout: 10_000 }); - await expect(page.locator(".settings-page")).not.toBeAttached(); - }); - - test("daemon ui-only config does not block standalone settings", async ({ page }) => { - // The daemon serves window.__kenn_forge_config carrying only its - // UI focus state (ui.activeWorktreeKey, set via the API). That - // must not flip the SPA into embedded mode and hide the settings - // page, which a standalone client needs. - await page.addInitScript(() => { - window.__kenn_forge_config = { ui: { activeWorktreeKey: "wt-1" } }; - }); - await page.goto("/settings"); - - await page.locator(".settings-page").waitFor({ state: "visible", timeout: 10_000 }); - }); - - test("project intake uses snapshot host metadata and host-scoped registration", async ({ page }) => { - const server = await startIsolatedE2EServerWithOptions(); - const hostKey = server.info.node_id; - const localRepo = realpathSync(mkdtempSync(path.join(os.tmpdir(), "kenn-forge-hosted-intake-"))); - try { - execFileSync("git", ["init", "-b", "main"], { cwd: localRepo, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "e2e@example.com"], { - cwd: localRepo, - stdio: "ignore", - }); - execFileSync("git", ["config", "user.name", "E2E Fixture"], { - cwd: localRepo, - stdio: "ignore", - }); - execFileSync("git", ["commit", "--allow-empty", "-m", "fixture: seed project"], { - cwd: localRepo, - stdio: "ignore", - }); - - const snapshotResponse = await page.request.get(`${server.info.base_url}/api/v1/snapshot?include_peers=true`); - expect(snapshotResponse.status(), await snapshotResponse.text()).toBe(200); - const snapshot = (await snapshotResponse.json()) as SnapshotResponse; - const hubHost = snapshot.hosts.find((host) => host.configKey === hostKey); - expect(hubHost).toBeDefined(); - expect(hubHost?.kind).toBe("self"); - - const snapshotLoaded = page.waitForResponse((response) => { - const url = new URL(response.url()); - return url.pathname === "/api/v1/snapshot" && url.searchParams.get("include_peers") === "true"; - }); - await page.goto(`${server.info.base_url}/project-intake?host=${encodeURIComponent(hostKey)}`); - await snapshotLoaded; - await expect(page.getByText(`Host: ${hubHost?.name ?? "hub"}`)).toBeVisible(); - - await page.getByRole("button", { name: /Add an existing repository/ }).click(); - await page.getByLabel("Repository path").fill(localRepo); - - const registerFinished = page.waitForResponse((response) => { - const url = new URL(response.url()); - return response.request().method() === "POST" && url.pathname === `/api/v1/fleet/hosts/${hostKey}/projects`; - }); - await page.getByRole("button", { name: "Add repository" }).click(); - const registerResponse = await registerFinished; - expect(registerResponse.status(), await registerResponse.text()).toBe(201); - const created = (await registerResponse.json()) as ProjectResponse; - expect(created.id).not.toBe(""); - - await expect(page).toHaveURL(/\/workspaces$/); - const listResponse = await page.request.get(`${server.info.base_url}/api/v1/projects`); - expect(listResponse.status(), await listResponse.text()).toBe(200); - const list = (await listResponse.json()) as ProjectListResponse; - expect(list.projects).toContainEqual( - expect.objectContaining({ - id: created.id, - local_path: localRepo, - }), - ); - } finally { - rmSync(localRepo, { recursive: true, force: true }); - await server.stop(); - } - }); - - test("accepted project registration survives host replacement without a duplicate", async ({ page }) => { - const server = await startIsolatedE2EServerWithOptions(); - const hostKey = server.info.node_id; - const localRepo = realpathSync(mkdtempSync(path.join(os.tmpdir(), "kenn-forge-retained-intake-"))); - try { - execFileSync("git", ["init", "-b", "main"], { cwd: localRepo, stdio: "ignore" }); - execFileSync("git", ["config", "user.email", "e2e@example.com"], { - cwd: localRepo, - stdio: "ignore", - }); - execFileSync("git", ["config", "user.name", "E2E Fixture"], { - cwd: localRepo, - stdio: "ignore", - }); - execFileSync("git", ["commit", "--allow-empty", "-m", "fixture: seed project"], { - cwd: localRepo, - stdio: "ignore", - }); - - let registrationRequests = 0; - let noteCommitted: () => void = () => undefined; - let releaseResponse: () => void = () => undefined; - const committed = new Promise((resolve) => { - noteCommitted = resolve; - }); - const responseGate = new Promise((resolve) => { - releaseResponse = resolve; - }); - await page.route(`**/api/v1/fleet/hosts/${hostKey}/projects`, async (route) => { - if (route.request().method() !== "POST") { - await route.fallback(); - return; - } - registrationRequests += 1; - const response = await route.fetch(); - noteCommitted(); - await responseGate; - await route.fulfill({ response }); - }); - - await page.goto(`${server.info.base_url}/project-intake?host=${encodeURIComponent(hostKey)}`); - await page.getByRole("button", { name: /Add an existing repository/ }).click(); - await page.getByLabel("Repository path").fill(localRepo); - await page.getByRole("button", { name: "Add repository" }).click(); - await committed; - - await page.evaluate(() => { - history.pushState({}, "", "/project-intake"); - window.dispatchEvent(new PopStateEvent("popstate")); - }); - await expect(page.getByText("Add an existing local repository")).toBeVisible(); - releaseResponse(); - await expect(page).toHaveURL(/\/project-intake$/); - - await page.evaluate((key) => { - history.pushState({}, "", `/project-intake?host=${encodeURIComponent(key)}`); - window.dispatchEvent(new PopStateEvent("popstate")); - }, hostKey); - await page.getByRole("button", { name: /Add an existing repository/ }).click(); - await page.getByLabel("Repository path").fill(localRepo); - await page.getByRole("button", { name: "Add repository" }).click(); - - await expect(page).toHaveURL(/\/workspaces$/); - expect(registrationRequests).toBe(1); - const listResponse = await page.request.get(`${server.info.base_url}/api/v1/projects`); - expect(listResponse.status(), await listResponse.text()).toBe(200); - const list = (await listResponse.json()) as ProjectListResponse; - expect((list.projects ?? []).filter((project) => project.local_path === localRepo)).toHaveLength(1); - } finally { - rmSync(localRepo, { recursive: true, force: true }); - await server.stop(); - } - }); - - test("embed project card preserves host key in project actions", async ({ page }) => { - const server = await startIsolatedE2EServerWithOptions(); - const hostKey = server.info.node_id; - const localRepo = realpathSync(mkdtempSync(path.join(os.tmpdir(), "kenn-forge-hosted-card-"))); - try { - execFileSync("git", ["init"], { cwd: localRepo, stdio: "ignore" }); - const registerResponse = await page.request.post(`${server.info.base_url}/api/v1/projects`, { - data: { - local_path: localRepo, - display_name: "Fleet Project", - default_branch: "main", - }, - }); - expect(registerResponse.status(), await registerResponse.text()).toBe(201); - const project = (await registerResponse.json()) as ProjectResponse; - expect(project.id).not.toBe(""); - - await page.addInitScript(() => { - const win = window as unknown as { - __kenn_forge_config?: ForgeConfig; - __kenn_forge_project_action_context?: unknown; - }; - win.__kenn_forge_config = { - actions: { - project: [ - { - id: "new-worktree", - label: "New Worktree", - handler: (context) => { - win.__kenn_forge_project_action_context = context; - return { ok: true }; - }, - }, - ], - }, - }; - }); - - await page.goto( - `${server.info.base_url}/workspaces/embed/project/${encodeURIComponent(project.id)}?host=${encodeURIComponent(hostKey)}`, - ); - await expect(page.locator("header.app-top-bar")).toHaveCount(0); - await expect(page.getByText("Fleet Project")).toBeVisible(); - - await page - .getByRole("button", { - name: /Create (your first|another) worktree/i, - }) - .click(); - - await expect - .poll(() => - page.evaluate(() => { - const win = window as unknown as { - __kenn_forge_project_action_context?: unknown; - }; - return win.__kenn_forge_project_action_context; - }), - ) - .toEqual({ - surface: "project-card", - projectId: project.id, - hostKey, - }); - } finally { - rmSync(localRepo, { recursive: true, force: true }); - await server.stop(); - } - }); - - test("committed worktree creation reconciles after navigation before accepting another intent", async ({ page }) => { - const server = await startIsolatedE2EServerWithOptions(); - const firstRepo = realpathSync(mkdtempSync(path.join(os.tmpdir(), "kenn-forge-retained-worktree-first-"))); - const secondRepo = realpathSync(mkdtempSync(path.join(os.tmpdir(), "kenn-forge-retained-worktree-second-"))); - const worktreePaths = [ - path.join(os.tmpdir(), `kenn-forge-retained-worktree-${process.pid}-one`), - path.join(os.tmpdir(), `kenn-forge-retained-worktree-${process.pid}-two`), - ]; - try { - for (const repo of [firstRepo, secondRepo]) { - execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "ignore" }); - } - - const firstProjectInput = { - local_path: firstRepo, - display_name: "Retained Worktree Project", - default_branch: "main", - } satisfies RegisterProjectInput; - const firstProjectResponse = await page.request.post(`${server.info.base_url}/api/v1/projects`, { - data: firstProjectInput, - }); - expect(firstProjectResponse.status(), await firstProjectResponse.text()).toBe(201); - const firstProject: ProjectResponse = await firstProjectResponse.json(); - - const secondProjectInput = { - local_path: secondRepo, - display_name: "Navigation Target Project", - default_branch: "main", - } satisfies RegisterProjectInput; - const secondProjectResponse = await page.request.post(`${server.info.base_url}/api/v1/projects`, { - data: secondProjectInput, - }); - expect(secondProjectResponse.status(), await secondProjectResponse.text()).toBe(201); - const secondProject: ProjectResponse = await secondProjectResponse.json(); - - await page.addInitScript( - ({ paths }) => { - window.__kenn_forge_config = { - actions: { - project: [ - { - id: "new-worktree", - label: "New Worktree", - handler: async (context) => { - const callNumber = Number.parseInt(localStorage.getItem("worktree-action-calls") ?? "0", 10) + 1; - localStorage.setItem("worktree-action-calls", String(callNumber)); - const projectId = context.projectId; - const worktreePath = paths[callNumber - 1]; - if (!projectId || !worktreePath) return { ok: false, message: "Missing worktree input." }; - const branch = callNumber === 1 ? "feature-retained" : "feature-second-intent"; - const body = { branch, path: worktreePath } satisfies RegisterWorktreeInput; - const response = await fetch(`/api/v1/projects/${encodeURIComponent(projectId)}/worktrees`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) return { ok: false, message: await response.text() }; - localStorage.setItem("worktree-action-committed", String(callNumber)); - await new Promise((resolve) => { - const release = (event: MessageEvent) => { - if (event.data !== `release-retained-worktree-${callNumber}`) return; - window.removeEventListener("message", release); - resolve(); - }; - window.addEventListener("message", release); - }); - localStorage.setItem("worktree-action-acknowledged", String(callNumber)); - return { ok: true }; - }, - }, - ], - }, - }; - }, - { paths: worktreePaths }, - ); - - const firstRoute = `/workspaces/embed/project/${encodeURIComponent(firstProject.id)}`; - const secondRoute = `/workspaces/embed/project/${encodeURIComponent(secondProject.id)}`; - await page.goto(`${server.info.base_url}${firstRoute}`); - await expect(page.getByText("Retained Worktree Project")).toBeVisible(); - await page.getByRole("button", { name: /Create (your first|another) worktree/i }).click(); - await expect.poll(() => page.evaluate(() => localStorage.getItem("worktree-action-committed"))).toBe("1"); - - await page.evaluate((route) => { - history.pushState({}, "", route); - window.dispatchEvent(new PopStateEvent("popstate")); - }, secondRoute); - await expect(page.getByText("Navigation Target Project")).toBeVisible(); - - let holdProjectRefreshes = true; - let firstProjectReadCount = 0; - let deliveredRefreshes = 0; - const releaseRefreshes: Array<() => void> = []; - await page.route(`**/api/v1/projects/${encodeURIComponent(firstProject.id)}`, async (route) => { - firstProjectReadCount += 1; - if (!holdProjectRefreshes || firstProjectReadCount === 1) { - await route.continue(); - return; - } - const response = await route.fetch(); - await new Promise((resolve) => { - releaseRefreshes.push(resolve); - }); - await route.fulfill({ response }); - deliveredRefreshes += 1; - }); - - await page.evaluate((route) => { - history.pushState({}, "", route); - window.dispatchEvent(new PopStateEvent("popstate")); - }, firstRoute); - await expect(page.getByText("Retained Worktree Project")).toBeVisible(); - await expect(page.getByRole("button", { name: /Create (your first|another) worktree/i })).toBeDisabled(); - - await page.evaluate(() => window.postMessage("release-retained-worktree-1", "*")); - await expect.poll(() => releaseRefreshes.length).toBe(2); - releaseRefreshes[1]?.(); - await expect.poll(() => deliveredRefreshes).toBe(1); - await expect(page.getByRole("button", { name: "Create another worktree" })).toBeEnabled(); - expect(await page.evaluate(() => localStorage.getItem("worktree-action-calls"))).toBe("1"); - - const retainedListResponse = await page.request.get( - `${server.info.base_url}/api/v1/projects/${encodeURIComponent(firstProject.id)}/worktrees`, - ); - expect(retainedListResponse.status(), await retainedListResponse.text()).toBe(200); - const retainedList: WorktreeListResponse = await retainedListResponse.json(); - expect((retainedList.worktrees ?? []).filter((worktree) => !worktree.is_primary)).toEqual([ - expect.objectContaining({ branch: "feature-retained" }), - ]); - - await page.getByRole("button", { name: "Create another worktree" }).click(); - await expect.poll(() => page.evaluate(() => localStorage.getItem("worktree-action-committed"))).toBe("2"); - - releaseRefreshes[0]?.(); - await expect.poll(() => deliveredRefreshes).toBe(2); - holdProjectRefreshes = false; - await page.evaluate((route) => { - history.pushState({}, "", route); - window.dispatchEvent(new PopStateEvent("popstate")); - }, secondRoute); - await expect(page.getByText("Navigation Target Project")).toBeVisible(); - await page.evaluate((route) => { - history.pushState({}, "", route); - window.dispatchEvent(new PopStateEvent("popstate")); - }, firstRoute); - await expect(page.getByRole("button", { name: "Create another worktree" })).toBeDisabled(); - expect(await page.evaluate(() => localStorage.getItem("worktree-action-calls"))).toBe("2"); - - await page.evaluate(() => window.postMessage("release-retained-worktree-2", "*")); - await expect.poll(() => page.evaluate(() => localStorage.getItem("worktree-action-acknowledged"))).toBe("2"); - await expect(page.getByText("feature-second-intent")).toBeVisible(); - - const finalListResponse = await page.request.get( - `${server.info.base_url}/api/v1/projects/${encodeURIComponent(firstProject.id)}/worktrees`, - ); - expect(finalListResponse.status(), await finalListResponse.text()).toBe(200); - const finalList: WorktreeListResponse = await finalListResponse.json(); - expect( - (finalList.worktrees ?? []) - .filter((worktree) => !worktree.is_primary) - .map((worktree) => worktree.branch) - .sort(), - ).toEqual(["feature-retained", "feature-second-intent"]); - } finally { - rmSync(firstRepo, { recursive: true, force: true }); - rmSync(secondRepo, { recursive: true, force: true }); - await server.stop(); - } - }); -}); diff --git a/frontend/tests/e2e-full/project-intake.spec.ts b/frontend/tests/e2e-full/project-intake.spec.ts new file mode 100644 index 0000000000..83a265fea9 --- /dev/null +++ b/frontend/tests/e2e-full/project-intake.spec.ts @@ -0,0 +1,149 @@ +import { realpathSync } from "node:fs"; +import { createGitTestRepository } from "../../../scripts/test-git-fixture.mjs"; +import { expect, test } from "@playwright/test"; +import type { + ListProjectsOutputBody, + ProjectResponse as GeneratedProjectResponse, +} from "../../src/lib/api/generated/models/index.js"; + +import { startIsolatedE2EServerWithOptions } from "./support/e2eServer"; + +type ProjectResponse = GeneratedProjectResponse; +type ProjectListResponse = ListProjectsOutputBody; + +type SnapshotResponse = { + hosts: Array<{ + configKey: string; + kind: string; + name: string; + }>; +}; + +test.describe("project intake", () => { + test("project intake uses snapshot host metadata and host-scoped registration", async ({ page }) => { + const server = await startIsolatedE2EServerWithOptions(); + const hostKey = server.info.node_id; + let cleanupRepository = () => {}; + const repository = createGitTestRepository({ + after: (cleanup: () => void) => { + cleanupRepository = cleanup; + }, + }); + const localRepo = realpathSync(repository.root); + try { + repository.git("commit", "--allow-empty", "-m", "fixture: seed project"); + + const snapshotResponse = await page.request.get(`${server.info.base_url}/api/v1/snapshot?include_peers=true`); + expect(snapshotResponse.status(), await snapshotResponse.text()).toBe(200); + const snapshot = (await snapshotResponse.json()) as SnapshotResponse; + const hubHost = snapshot.hosts.find((host) => host.configKey === hostKey); + expect(hubHost).toBeDefined(); + expect(hubHost?.kind).toBe("self"); + + const snapshotLoaded = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === "/api/v1/snapshot" && url.searchParams.get("include_peers") === "true"; + }); + await page.goto(`${server.info.base_url}/project-intake?host=${encodeURIComponent(hostKey)}`); + await snapshotLoaded; + await expect(page.getByText(`Host: ${hubHost?.name ?? "hub"}`)).toBeVisible(); + + await page.getByRole("button", { name: /Add an existing repository/ }).click(); + await page.getByLabel("Repository path").fill(localRepo); + + const registerFinished = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === "POST" && url.pathname === `/api/v1/fleet/hosts/${hostKey}/projects`; + }); + await page.getByRole("button", { name: "Add repository" }).click(); + const registerResponse = await registerFinished; + expect(registerResponse.status(), await registerResponse.text()).toBe(201); + const created = (await registerResponse.json()) as ProjectResponse; + expect(created.id).not.toBe(""); + + await expect(page).toHaveURL(`${server.info.base_url}/`); + await expect(page.locator(".activity-shell")).toBeVisible(); + const listResponse = await page.request.get(`${server.info.base_url}/api/v1/projects`); + expect(listResponse.status(), await listResponse.text()).toBe(200); + const list = (await listResponse.json()) as ProjectListResponse; + expect(list.projects).toContainEqual( + expect.objectContaining({ + id: created.id, + local_path: localRepo, + }), + ); + } finally { + await server.stop(); + cleanupRepository(); + } + }); + + test("accepted project registration survives host replacement without a duplicate", async ({ page }) => { + const server = await startIsolatedE2EServerWithOptions(); + const hostKey = server.info.node_id; + let cleanupRepository = () => {}; + const repository = createGitTestRepository({ + after: (cleanup: () => void) => { + cleanupRepository = cleanup; + }, + }); + const localRepo = realpathSync(repository.root); + try { + repository.git("commit", "--allow-empty", "-m", "fixture: seed project"); + + let registrationRequests = 0; + let noteCommitted: () => void = () => undefined; + let releaseResponse: () => void = () => undefined; + const committed = new Promise((resolve) => { + noteCommitted = resolve; + }); + const responseGate = new Promise((resolve) => { + releaseResponse = resolve; + }); + await page.route(`**/api/v1/fleet/hosts/${hostKey}/projects`, async (route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + registrationRequests += 1; + const response = await route.fetch(); + noteCommitted(); + await responseGate; + await route.fulfill({ response }); + }); + + await page.goto(`${server.info.base_url}/project-intake?host=${encodeURIComponent(hostKey)}`); + await page.getByRole("button", { name: /Add an existing repository/ }).click(); + await page.getByLabel("Repository path").fill(localRepo); + await page.getByRole("button", { name: "Add repository" }).click(); + await committed; + + await page.evaluate(() => { + history.pushState({}, "", "/project-intake"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + await expect(page.getByText("Add an existing local repository")).toBeVisible(); + releaseResponse(); + await expect(page).toHaveURL(/\/project-intake$/); + + await page.evaluate((key) => { + history.pushState({}, "", `/project-intake?host=${encodeURIComponent(key)}`); + window.dispatchEvent(new PopStateEvent("popstate")); + }, hostKey); + await page.getByRole("button", { name: /Add an existing repository/ }).click(); + await page.getByLabel("Repository path").fill(localRepo); + await page.getByRole("button", { name: "Add repository" }).click(); + + await expect(page).toHaveURL(`${server.info.base_url}/`); + await expect(page.locator(".activity-shell")).toBeVisible(); + expect(registrationRequests).toBe(1); + const listResponse = await page.request.get(`${server.info.base_url}/api/v1/projects`); + expect(listResponse.status(), await listResponse.text()).toBe(200); + const list = (await listResponse.json()) as ProjectListResponse; + expect((list.projects ?? []).filter((project) => project.local_path === localRepo)).toHaveLength(1); + } finally { + await server.stop(); + cleanupRepository(); + } + }); +}); diff --git a/frontend/tests/e2e-full/repo-browser.spec.ts b/frontend/tests/e2e-full/repo-browser.spec.ts index b7758cd166..1891a117be 100644 --- a/frontend/tests/e2e-full/repo-browser.spec.ts +++ b/frontend/tests/e2e-full/repo-browser.spec.ts @@ -437,7 +437,8 @@ test.describe("repository source browser", () => { const movedTreeLoaded = treeResponse(page, "main"); const movedBlobLoaded = blobResponse(page, "README.md"); await page.evaluate((route) => { - window.__kenn_forge_navigate_to_route?.(route); + window.history.pushState({}, "", route); + window.dispatchEvent(new PopStateEvent("popstate")); }, `/repo/browser?provider=github&repo_path=acme%2Fwidgets&ref_type=branch&ref_name=main&ref_sha=${staleSHA}&path=README.md`); const movedTree = await movedTreeLoaded; const movedTreeBody = (await movedTree.json()) as { @@ -463,7 +464,8 @@ test.describe("repository source browser", () => { await page.locator(".pull-detail").waitFor({ state: "visible", timeout: 10_000 }); await page.evaluate((route) => { - window.__kenn_forge_navigate_to_route?.(route); + window.history.pushState({}, "", route); + window.dispatchEvent(new PopStateEvent("popstate")); }, "/focus/pulls/github/acme/widgets/1"); await page.locator(".focus-layout .pull-detail").waitFor({ state: "visible", timeout: 10_000 }); @@ -545,9 +547,12 @@ test.describe("repository source browser", () => { await expectHeadingScrolledIntoView(viewer.locator("#api-reference")); await page.evaluate(() => { - window.__kenn_forge_navigate_to_route?.( + window.history.pushState( + {}, + "", "/repo/browser?provider=github&repo_path=acme%2Fwidgets&path=docs%2Fguide.md&mode=preview", ); + window.dispatchEvent(new PopStateEvent("popstate")); }); await expect(page).toHaveURL(/path=docs%2Fguide\.md&mode=preview$/); await viewer.locator(".repo-browser__markdown").evaluate((node) => { diff --git a/frontend/tests/e2e/active-worktree.spec.ts b/frontend/tests/e2e/active-worktree.spec.ts new file mode 100644 index 0000000000..02c89bab23 --- /dev/null +++ b/frontend/tests/e2e/active-worktree.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; +import { mockApi } from "./support/mockApi"; + +for (const key of ["projects/theme-rework", "projects/another-worktree", undefined]) { + test(`active-worktree bootstrap ${key ?? "unset"} reaches the pull sidebar`, async ({ page }) => { + await mockApi(page); + await page.addInitScript((activeKey) => { + if (activeKey !== undefined) window.__kenn_forge_active_worktree_key = activeKey; + }, key); + + await page.goto("/pulls/github/acme/widgets/55"); + const row = page.locator(".pull-item").filter({ hasText: "Refactor theme system" }); + await expect(row).toBeVisible(); + if (key === "projects/theme-rework") { + await expect(row).toHaveClass(/active-worktree/); + await expect(page.locator(".pull-item.active-worktree")).toHaveCount(1); + } else { + await expect(page.locator(".pull-item.active-worktree")).toHaveCount(0); + } + }); +} diff --git a/frontend/tests/e2e/workspaces.spec.ts b/frontend/tests/e2e/workspaces.spec.ts index c975285bb8..b3a0a644fa 100644 --- a/frontend/tests/e2e/workspaces.spec.ts +++ b/frontend/tests/e2e/workspaces.spec.ts @@ -348,44 +348,7 @@ test("repo selector renders icon and still filters repos", async ({ page }) => { await expect(page.getByText("Add browser regression coverage")).toBeVisible(); }); -test("hideHeader suppresses AppHeader on the workspaces page", async ({ page }) => { - await page.addInitScript(() => { - window.__kenn_forge_config = { - embed: { hideHeader: true }, - }; - }); - - await page.goto("/workspaces"); - await expect(page.locator("header.app-top-bar")).toHaveCount(0); -}); - -test("navigateToRoute bridge method works", async ({ page }) => { - await page.goto("/pulls"); - await page.evaluate(() => { - window.__kenn_forge_navigate_to_route?.("/workspaces"); - }); - await expect(page).toHaveURL(/\/workspaces/); -}); - -test("workspace bridge methods are registered on startup", async ({ page }) => { - await page.goto("/workspaces"); - - await expect( - page.evaluate(() => ({ - navigateToRoute: typeof window.__kenn_forge_navigate_to_route, - updateWorkspace: typeof window.__kenn_forge_update_workspace, - updateSelection: typeof window.__kenn_forge_update_selection, - updateHostState: typeof window.__kenn_forge_update_host_state, - })), - ).resolves.toEqual({ - navigateToRoute: "function", - updateWorkspace: "function", - updateSelection: "function", - updateHostState: "function", - }); -}); - -test("provider-explicit embed detail route uses provider in detail request", async ({ page }) => { +test("provider-explicit issue detail route uses provider in detail request", async ({ page }) => { const detailRequest = page.waitForRequest( (request) => request.method() === "GET" && @@ -403,7 +366,7 @@ test("provider-explicit embed detail route uses provider in detail request", asy Number: 7, URL: "https://git.example.com/group/project/-/issues/7", Title: "Provider-explicit GitLab issue", - Author: "marius", + Author: "user-a", State: "open", Body: "", CommentCount: 0, @@ -431,13 +394,13 @@ test("provider-explicit embed detail route uses provider in detail request", asy }); }); - await page.goto("/workspaces/embed/detail/gitlab/issue/git.example.com/group/project/7"); + await page.goto("/host/git.example.com/issues/gitlab/group/project/7"); await detailRequest; await expect(page.getByText("Provider-explicit GitLab issue")).toBeVisible(); }); -test("nested repo_path embed detail route loads matching detail content", async ({ page }) => { +test("nested repository issue detail route loads matching detail content", async ({ page }) => { const detailRequest = page.waitForRequest( (request) => request.method() === "GET" && @@ -455,7 +418,7 @@ test("nested repo_path embed detail route loads matching detail content", async Number: 7, URL: "https://git.example.com/group/subgroup/project/-/issues/7", Title: "Nested GitLab issue", - Author: "marius", + Author: "user-a", State: "open", Body: "", CommentCount: 0, @@ -483,209 +446,8 @@ test("nested repo_path embed detail route loads matching detail content", async }); }); - await page.goto("/workspaces/embed/detail/gitlab/issue/git.example.com/7" + "?repo_path=group%2Fsubgroup%2Fproject"); + await page.goto("/host/git.example.com/issues/gitlab/group%2Fsubgroup/project/7"); await detailRequest; await expect(page.getByText("Nested GitLab issue")).toBeVisible(); }); - -test("embed initialRoute opens detail surface without full app chrome", async ({ page }) => { - await page.addInitScript(() => { - window.__kenn_forge_config = { - embed: { - initialRoute: "/workspaces/embed/detail/gitlab/issue/git.example.com/7" + "?repo_path=group%2Fproject", - }, - }; - }); - - const detailRequest = page.waitForRequest( - (request) => - request.method() === "GET" && - new URL(request.url()).pathname === "/api/v1/host/git.example.com/issues/gitlab/group/project/7", - ); - await page.route("**/api/v1/host/git.example.com/issues/gitlab/group/project/7", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - issue: { - ID: 7, - RepoID: 7, - GitHubID: 7007, - Number: 7, - URL: "https://git.example.com/group/project/-/issues/7", - Title: "Initial route GitLab issue", - Author: "marius", - State: "open", - Body: "", - CommentCount: 0, - LabelsJSON: "[]", - CreatedAt: "2026-03-28T14:00:00Z", - UpdatedAt: "2026-03-30T14:00:00Z", - LastActivityAt: "2026-03-30T14:00:00Z", - ClosedAt: null, - Starred: false, - }, - repo: { - provider: "gitlab", - platform_host: "git.example.com", - owner: "group", - name: "project", - repo_path: "group/project", - }, - events: [], - platform_host: "git.example.com", - repo_owner: "group", - repo_name: "project", - detail_loaded: true, - detail_fetched_at: "2026-03-30T14:00:00Z", - }), - }); - }); - - await page.goto("/"); - - await detailRequest; - await expect(page.locator("header.app-top-bar")).toHaveCount(0); - await expect(page).toHaveURL( - /\/workspaces\/embed\/detail\/gitlab\/issue\/git\.example\.com\/7\?repo_path=group%2Fproject$/, - ); - await expect(page.getByText("Initial route GitLab issue")).toBeVisible(); -}); - -test("full app initializes after navigating away from an initial embed route", async ({ page }) => { - await page.route("**/api/v1/settings", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - ...defaultSettings, - repos: [ - { - provider: "github", - platform_host: "github.com", - owner: "acme", - name: "widgets", - repo_path: "acme/widgets", - is_glob: false, - matched_repo_count: 1, - }, - ], - activity: { - ...defaultSettings.activity, - view_mode: "threaded", - }, - terminal: { - ...defaultSettings.terminal, - font_family: '"Fira Code", monospace', - font_size: 14, - }, - }), - }); - }); - - await page.addInitScript(() => { - window.__kenn_forge_config = { - embed: { - initialRoute: "/workspaces/embed/list", - }, - }; - }); - - await page.goto("/"); - await expect(page.locator("header.app-top-bar")).toHaveCount(0); - - const pullsResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/v1/pulls"); - await page.evaluate(() => { - window.__kenn_forge_navigate_to_route?.("/pulls"); - }); - - await expect(page).toHaveURL(/\/pulls$/); - await pullsResponse; - await expect(page.locator("header.app-top-bar")).toBeVisible(); -}); - -test("full app reinitializes after navigating through an embed route without refetching cached settings", async ({ - page, -}) => { - let settingsRequests = 0; - await page.addInitScript(() => { - const OriginalEventSource = window.EventSource; - const created: EventSource[] = []; - const closed: EventSource[] = []; - class TrackingEventSource extends OriginalEventSource { - constructor(url: string | URL, eventSourceInitDict?: EventSourceInit) { - super(url, eventSourceInitDict); - created.push(this); - } - - close(): void { - closed.push(this); - super.close(); - } - } - window.EventSource = TrackingEventSource; - Object.defineProperty(window, "__kenn_forge_event_source_counts", { - value: () => ({ created: created.length, closed: closed.length }), - }); - }); - await page.route("**/api/v1/settings", async (route) => { - settingsRequests += 1; - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - ...defaultSettings, - repos: [ - { - provider: "github", - platform_host: "github.com", - owner: "acme", - name: "widgets", - repo_path: "acme/widgets", - is_glob: false, - matched_repo_count: 1, - }, - ], - activity: { - ...defaultSettings.activity, - view_mode: "threaded", - }, - terminal: { - ...defaultSettings.terminal, - font_family: '"Fira Code", monospace', - font_size: 14, - }, - }), - }); - }); - - await page.goto("/pulls"); - await expect(page.locator("header.app-top-bar")).toBeVisible(); - await expect.poll(() => settingsRequests).toBe(1); - const initialEventSources = await page.evaluate(() => window.__kenn_forge_event_source_counts?.().created ?? 0); - expect(initialEventSources).toBeGreaterThan(0); - - await page.evaluate(() => { - window.__kenn_forge_navigate_to_route?.("/workspaces/embed/list"); - }); - await expect(page).toHaveURL(/\/workspaces\/embed\/list$/); - await expect(page.locator("header.app-top-bar")).toHaveCount(0); - await expect.poll(() => settingsRequests).toBe(1); - await expect - .poll(async () => page.evaluate(() => window.__kenn_forge_event_source_counts?.().closed ?? 0)) - .toBeGreaterThanOrEqual(initialEventSources); - - await page.evaluate(() => { - window.__kenn_forge_navigate_to_route?.("/pulls"); - }); - await expect(page).toHaveURL(/\/pulls$/); - await expect(page.locator("header.app-top-bar")).toBeVisible(); - await expect.poll(() => settingsRequests).toBe(1); - await expect - .poll(async () => page.evaluate(() => window.__kenn_forge_event_source_counts?.().created ?? 0)) - .toBeGreaterThan(initialEventSources); - await expect - .poll(async () => page.evaluate(() => window.__kenn_forge_event_source_counts?.().closed ?? 0)) - .toBeGreaterThanOrEqual(initialEventSources); -}); diff --git a/internal/server/embedded_test.go b/internal/server/embedded_test.go deleted file mode 100644 index 812f4b6f6f..0000000000 --- a/internal/server/embedded_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package server - -import ( - "io/fs" - "net/http" - "net/http/httptest" - "testing" - "testing/fstest" - "time" - - "github.com/stretchr/testify/assert" - ghclient "go.kenn.io/forge/internal/github" - "go.kenn.io/forge/internal/testutil/dbtest" -) - -func setupEmbeddedServer( - t *testing.T, - basePath string, - frontend fs.FS, - options ServerOptions, -) *Server { - t.Helper() - database := dbtest.Open(t) - - mock := &mockGH{} - syncer := ghclient.NewSyncer(map[string]ghclient.Client{"github.com": mock}, database, nil, nil, time.Minute, nil, nil) - t.Cleanup(syncer.Stop) - return New( - database, - syncer, - frontend, - basePath, - nil, - options, - ) -} - -// TestBootstrapActiveWorktreeKey pins the served-config contract: -// the SPA bootstrap script carries the daemon-side UI focus state -// (set over PUT /api/v1/ui/active-worktree) and nothing else — -// presentation preferences are injected client-side by webview -// hosts. -func TestBootstrapActiveWorktreeKey(t *testing.T) { - frontend := fstest.MapFS{ - "index.html": &fstest.MapFile{ - Data: []byte(`app`), - }, - } - - t.Run("set key is served", func(t *testing.T) { - srv := setupEmbeddedServer(t, "/app/", frontend, ServerOptions{}) - srv.SetActiveWorktreeKey("wt-123") - - req := httptest.NewRequest(http.MethodGet, "/app/", nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - - body := rr.Body.String() - assert := assert.New(t) - assert.Contains(body, `"activeWorktreeKey":"wt-123"`) - assert.Contains(body, `window.__kenn_forge_config=`) - }) - - t.Run("no key means no served config", func(t *testing.T) { - srv := setupEmbeddedServer(t, "/app/", frontend, ServerOptions{}) - req := httptest.NewRequest(http.MethodGet, "/app/", nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - - body := rr.Body.String() - assert := assert.New(t) - assert.NotContains(body, `__kenn_forge_config`) - assert.Contains(body, `window.__BASE_PATH__="/app/"`) - }) -} - -func TestSPACacheHeaders(t *testing.T) { - frontend := fstest.MapFS{ - "index.html": &fstest.MapFile{ - Data: []byte(`app`), - }, - "assets/index-DEADBEEF.js": &fstest.MapFile{ - Data: []byte(`console.log("bundle");`), - }, - "favicon.ico": &fstest.MapFile{ - Data: []byte(`icon`), - }, - } - - srv := setupEmbeddedServer(t, "/", frontend, ServerOptions{}) - - cases := []struct { - name string - path string - wantStatus int - wantCacheHdr string - wantPragma string - wantExpires string - }{ - { - name: "index served at root must not be cached", - path: "/", - wantStatus: http.StatusOK, - wantCacheHdr: "no-store, no-cache, must-revalidate, max-age=0", - wantPragma: "no-cache", - wantExpires: "0", - }, - { - name: "spa fallback must not be cached", - path: "/some/spa/route", - wantStatus: http.StatusOK, - wantCacheHdr: "no-store, no-cache, must-revalidate, max-age=0", - wantPragma: "no-cache", - wantExpires: "0", - }, - { - name: "hashed assets are immutable", - path: "/assets/index-DEADBEEF.js", - wantStatus: http.StatusOK, - wantCacheHdr: "public, max-age=31536000, immutable", - }, - { - name: "missing hashed asset returns 404", - path: "/assets/index-MISSING.js", - wantStatus: http.StatusNotFound, - wantCacheHdr: "", - }, - { - name: "non-hashed top-level files are not given immutable headers", - path: "/favicon.ico", - wantStatus: http.StatusOK, - wantCacheHdr: "", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, tc.path, nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - assert := assert.New(t) - assert.Equal(tc.wantStatus, rr.Code) - assert.Equal(tc.wantCacheHdr, rr.Header().Get("Cache-Control")) - assert.Equal(tc.wantPragma, rr.Header().Get("Pragma")) - assert.Equal(tc.wantExpires, rr.Header().Get("Expires")) - }) - } -} - -func TestSPAFrameProtectionHeaders(t *testing.T) { - frontend := fstest.MapFS{ - "index.html": &fstest.MapFile{ - Data: []byte(`app`), - }, - "assets/index-DEADBEEF.js": &fstest.MapFile{ - Data: []byte(`console.log("bundle");`), - }, - } - - srv := setupEmbeddedServer(t, "/", frontend, ServerOptions{}) - - cases := []struct { - name string - path string - }{ - {name: "index", path: "/"}, - {name: "spa fallback", path: "/workspaces"}, - {name: "terminal route", path: "/terminal/ws-123"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, tc.path, nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - assert := assert.New(t) - assert.Equal(http.StatusOK, rr.Code) - assert.Equal(spaFrameAncestorsPolicy, rr.Header().Get("Content-Security-Policy")) - assert.Equal(spaXFrameOptions, rr.Header().Get("X-Frame-Options")) - }) - } - - t.Run("asset", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/assets/index-DEADBEEF.js", nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - assert := assert.New(t) - assert.Equal(http.StatusOK, rr.Code) - assert.Empty(rr.Header().Get("Content-Security-Policy")) - assert.Empty(rr.Header().Get("X-Frame-Options")) - }) - - t.Run("workspace embed route remains frameable", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/workspaces/embed/list", nil) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - assert := assert.New(t) - assert.Equal(http.StatusOK, rr.Code) - assert.Empty(rr.Header().Get("Content-Security-Policy")) - assert.Empty(rr.Header().Get("X-Frame-Options")) - }) -} diff --git a/internal/server/server.go b/internal/server/server.go index 3d6b5b0de0..41440bbf46 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1531,16 +1531,11 @@ func (s *Server) bootstrapScript() string { builder.WriteString(`window.__BASE_PATH__=`) builder.WriteString(scriptSafe(string(safeBase))) builder.WriteString(`;`) - // The served config carries the daemon-side UI state thin - // clients set over the API (PUT /api/v1/ui/active-worktree); - // presentation preferences (embed mode, theming) are injected - // client-side by whoever hosts the webview. + // Preserve daemon-side worktree focus set by thin clients through the API. if awKey, set := s.ActiveWorktreeKey(); set { - configJSON, _ := json.Marshal(map[string]any{ - "ui": map[string]any{"activeWorktreeKey": awKey}, - }) - builder.WriteString(`window.__kenn_forge_config=`) - builder.WriteString(scriptSafe(string(configJSON))) + keyJSON, _ := json.Marshal(awKey) + builder.WriteString(`window.__kenn_forge_active_worktree_key=`) + builder.WriteString(scriptSafe(string(keyJSON))) builder.WriteString(`;`) } return builder.String() diff --git a/internal/server/spa_handler.go b/internal/server/spa_handler.go index 4b07041f15..36f2911768 100644 --- a/internal/server/spa_handler.go +++ b/internal/server/spa_handler.go @@ -37,10 +37,8 @@ func newSPAAssetHandler( idx := strings.Replace(indexTemplate, "", ``, 1) w.Header().Set("Content-Type", "text/html; charset=utf-8") - if !isWorkspaceEmbedRoute(r.URL.Path) { - w.Header().Set("Content-Security-Policy", spaFrameAncestorsPolicy) - w.Header().Set("X-Frame-Options", spaXFrameOptions) - } + w.Header().Set("Content-Security-Policy", spaFrameAncestorsPolicy) + w.Header().Set("X-Frame-Options", spaXFrameOptions) // index.html references content-hashed bundles. Browsers // must always re-fetch it so a rebuild is picked up; the // hashed assets it references can still be cached forever. @@ -121,7 +119,3 @@ func serveCompressedAsset( _ = writeCompressedBody(w, encoding, body) return true } - -func isWorkspaceEmbedRoute(path string) bool { - return path == "/workspaces/embed" || strings.HasPrefix(path, "/workspaces/embed/") -} diff --git a/internal/server/spa_handler_test.go b/internal/server/spa_handler_test.go index 5542117c0f..64713987ef 100644 --- a/internal/server/spa_handler_test.go +++ b/internal/server/spa_handler_test.go @@ -8,12 +8,189 @@ import ( "strings" "testing" "testing/fstest" + "time" "github.com/andybalholm/brotli" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + ghclient "go.kenn.io/forge/internal/github" + "go.kenn.io/forge/internal/testutil/dbtest" ) +func setupSPAAssetServer( + t *testing.T, + basePath string, + frontend fs.FS, + options ServerOptions, +) *Server { + t.Helper() + database := dbtest.Open(t) + + mock := &mockGH{} + syncer := ghclient.NewSyncer(map[string]ghclient.Client{"github.com": mock}, database, nil, nil, time.Minute, nil, nil) + t.Cleanup(syncer.Stop) + return New( + database, + syncer, + frontend, + basePath, + nil, + options, + ) +} + +// TestBootstrapActiveWorktreeKey covers daemon-side focus state in the SPA bootstrap. +func TestBootstrapActiveWorktreeKey(t *testing.T) { + frontend := fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte(`app`), + }, + } + + t.Run("set key is served", func(t *testing.T) { + srv := setupSPAAssetServer(t, "/app/", frontend, ServerOptions{}) + srv.SetActiveWorktreeKey("wt-123") + + req := httptest.NewRequest(http.MethodGet, "/app/", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + body := rr.Body.String() + assert := assert.New(t) + assert.Contains(body, `window.__kenn_forge_active_worktree_key="wt-123";`) + }) + + t.Run("no key means no served config", func(t *testing.T) { + srv := setupSPAAssetServer(t, "/app/", frontend, ServerOptions{}) + req := httptest.NewRequest(http.MethodGet, "/app/", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + body := rr.Body.String() + assert := assert.New(t) + assert.NotContains(body, `__kenn_forge_active_worktree_key`) + assert.Contains(body, `window.__BASE_PATH__="/app/"`) + }) +} + +func TestSPACacheHeaders(t *testing.T) { + frontend := fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte(`app`), + }, + "assets/index-DEADBEEF.js": &fstest.MapFile{ + Data: []byte(`console.log("bundle");`), + }, + "favicon.ico": &fstest.MapFile{ + Data: []byte(`icon`), + }, + } + + srv := setupSPAAssetServer(t, "/", frontend, ServerOptions{}) + + cases := []struct { + name string + path string + wantStatus int + wantCacheHdr string + wantPragma string + wantExpires string + }{ + { + name: "index served at root must not be cached", + path: "/", + wantStatus: http.StatusOK, + wantCacheHdr: "no-store, no-cache, must-revalidate, max-age=0", + wantPragma: "no-cache", + wantExpires: "0", + }, + { + name: "spa fallback must not be cached", + path: "/some/spa/route", + wantStatus: http.StatusOK, + wantCacheHdr: "no-store, no-cache, must-revalidate, max-age=0", + wantPragma: "no-cache", + wantExpires: "0", + }, + { + name: "hashed assets are immutable", + path: "/assets/index-DEADBEEF.js", + wantStatus: http.StatusOK, + wantCacheHdr: "public, max-age=31536000, immutable", + }, + { + name: "missing hashed asset returns 404", + path: "/assets/index-MISSING.js", + wantStatus: http.StatusNotFound, + wantCacheHdr: "", + }, + { + name: "non-hashed top-level files are not given immutable headers", + path: "/favicon.ico", + wantStatus: http.StatusOK, + wantCacheHdr: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + assert := assert.New(t) + assert.Equal(tc.wantStatus, rr.Code) + assert.Equal(tc.wantCacheHdr, rr.Header().Get("Cache-Control")) + assert.Equal(tc.wantPragma, rr.Header().Get("Pragma")) + assert.Equal(tc.wantExpires, rr.Header().Get("Expires")) + }) + } +} + +func TestSPAFrameProtectionHeaders(t *testing.T) { + frontend := fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte(`app`), + }, + "assets/index-DEADBEEF.js": &fstest.MapFile{ + Data: []byte(`console.log("bundle");`), + }, + } + + srv := setupSPAAssetServer(t, "/", frontend, ServerOptions{}) + + cases := []struct { + name string + path string + }{ + {name: "index", path: "/"}, + {name: "spa fallback", path: "/workspaces"}, + {name: "terminal route", path: "/terminal/ws-123"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + assert := assert.New(t) + assert.Equal(http.StatusOK, rr.Code) + assert.Equal(spaFrameAncestorsPolicy, rr.Header().Get("Content-Security-Policy")) + assert.Equal(spaXFrameOptions, rr.Header().Get("X-Frame-Options")) + }) + } + + t.Run("asset", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/assets/index-DEADBEEF.js", nil) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + assert := assert.New(t) + assert.Equal(http.StatusOK, rr.Code) + assert.Empty(rr.Header().Get("Content-Security-Policy")) + assert.Empty(rr.Header().Get("X-Frame-Options")) + }) + +} + func TestSPAAssetsCompressFullResponsesAndPreserveRanges(t *testing.T) { assert := assert.New(t) require := require.New(t) diff --git a/scripts/test-git-fixture.d.mts b/scripts/test-git-fixture.d.mts new file mode 100644 index 0000000000..cbe67006b3 --- /dev/null +++ b/scripts/test-git-fixture.d.mts @@ -0,0 +1,13 @@ +import type { SpawnSyncReturns } from "node:child_process"; + +export function isolatedGitEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv; + +export function createGitTestRepository( + lifecycle: { after: (cleanup: () => void) => void }, + options?: { env?: NodeJS.ProcessEnv; initialBranch?: string; prefix?: string }, +): { + env: NodeJS.ProcessEnv; + git: (...args: string[]) => SpawnSyncReturns; + root: string; + scratch: string; +}; diff --git a/skills/context-sync/SKILL.md b/skills/context-sync/SKILL.md index 37d0fefa64..0c7bfbf051 100644 --- a/skills/context-sync/SKILL.md +++ b/skills/context-sync/SKILL.md @@ -65,7 +65,6 @@ knowledge that changes what future agents should do. | `notifications` | `context/notifications-in-activity.md` | notification-owned paths in `internal/github/`, `internal/db/`, `internal/server/`, `frontend/`, and `packages/ui/` | | `db` | `context/db-migrations.md` | `internal/db/`, `internal/db/migrations/` | | `deferred-merge` | `context/deferred-merge.md` | deferred merge paths in `internal/server/` | -| `embeds` | `context/embeds.md` | embed routes, shell, and host bridge paths | | `fleet` | `context/fleet-architecture.md`, `context/workspace-runtime-lifecycle.md` | `internal/federation/`, `internal/federationauth/`, `internal/fleet/`, `internal/server/fleetapi/`; federation-owned settings, frontend, and tests | | `server` | `context/server-runtime.md`, `context/workspace-apis.md`, `context/workspace-runtime-lifecycle.md` | `cmd/kenn-forge/`, `internal/daemonruntime/`, `internal/runtimelock/`; server/workspace-owned paths in `internal/config/`, `internal/workspace/`, `internal/agentactivity/`, `internal/server/`, `internal/apiclient/generated/`, `frontend/`, and `packages/ui/`, including shared app and configuration files | | `errors` | `context/error-handling.md` | error envelopes and frontend error branching |