diff --git a/docs/pr-screenshots/issue-251/dashboard-route.png b/docs/pr-screenshots/issue-251/dashboard-route.png new file mode 100644 index 00000000..f953567a Binary files /dev/null and b/docs/pr-screenshots/issue-251/dashboard-route.png differ diff --git a/docs/pr-screenshots/issue-251/network-proof.json b/docs/pr-screenshots/issue-251/network-proof.json new file mode 100644 index 00000000..8f6df3c5 --- /dev/null +++ b/docs/pr-screenshots/issue-251/network-proof.json @@ -0,0 +1,35 @@ +{ + "generatedAt": "2026-05-08T11:19:39.087Z", + "mockServer": "http://127.0.0.1:4517", + "terminalOnly": { + "url": "/ssh/session/session-1", + "requests": [ + "GET /ssh/session/session-1", + "GET /css2?family=IBM+Plex+Mono:wght@400;500;600&family=Space+Grotesk:wght@500;700&display=swap", + "GET /assets/index-CAnIq_BE.js", + "GET /assets/index-6EdQ78LN.css", + "GET /api/terminal-sessions/session-1?_=1778239175378", + "GET /s/ibmplexmono/v20/-F6qfjptAgt5VM-kVkqdyU8n3vAOwlBFgg.woff2", + "GET /s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n1i8q1w.woff2" + ], + "websockets": [ + "ws://127.0.0.1:4517/api/projects/by-key/octocat%2Fhello-world/terminal-sessions/session-1/ws?cols=145&rows=42" + ], + "requestedProjectsEndpoint": false, + "containsTerminalDomHeader": true, + "screenshotIncludesProofBanner": true + }, + "dashboard": { + "url": "/menu/select", + "requests": [ + "GET /menu/select", + "GET /assets/index-CAnIq_BE.js", + "GET /assets/index-6EdQ78LN.css", + "GET /api/health?_=1778239177547", + "GET /api/projects?_=1778239177553", + "GET /api/auth/github/status?_=1778239177566" + ], + "requestedProjectsEndpoint": true, + "containsDashboardText": true + } +} diff --git a/docs/pr-screenshots/issue-251/terminal-only-route.png b/docs/pr-screenshots/issue-251/terminal-only-route.png new file mode 100644 index 00000000..f7bda74d Binary files /dev/null and b/docs/pr-screenshots/issue-251/terminal-only-route.png differ diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index 3e843507..19ec7d60 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -579,8 +579,8 @@ const startCreateProjectJob = ( export const listProjects = () => listProjectItems.pipe( - Effect.map((projects) => projects.map((project) => dbProjectDetails(project))), - Effect.catchAll(() => Effect.succeed([] as ReadonlyArray)) + Effect.map((projects) => projects.map((project) => dbProjectSummary(project))), + Effect.catchAll(() => Effect.succeed([] as ReadonlyArray)) ) export const applyAllProjects = (activeOnly: boolean) => diff --git a/packages/api/tests/projects.test.ts b/packages/api/tests/projects.test.ts index 15436942..88555eee 100644 --- a/packages/api/tests/projects.test.ts +++ b/packages/api/tests/projects.test.ts @@ -258,7 +258,7 @@ describe("projects service", () => { }) ).pipe(Effect.provide(NodeContext.layer))) - it.effect("lists project inventory from .docker-git with conservative runtime defaults", () => + it.effect("lists lightweight project summaries while getProject returns project details", () => withTempDir((root) => Effect.gen(function*(_) { const path = yield* _(Path.Path) @@ -299,19 +299,26 @@ describe("projects service", () => { expect(projects).toHaveLength(1) expect(projects[0]).toMatchObject({ id: projectId, - projectDir: projectId, status: "unknown", statusLabel: "unknown", sshSessions: 0, startedAtIso: null, startedAtEpochMs: null }) + expect(projects[0]).not.toHaveProperty("sshCommand") + expect(projects[0]).not.toHaveProperty("authorizedKeysPath") + expect(projects[0]).not.toHaveProperty("envGlobalPath") + expect(projects[0]).not.toHaveProperty("codexHome") expect(details).toMatchObject({ id: projectId, projectDir: projectId, status: "unknown", statusLabel: "unknown" }) + expect(details).toHaveProperty("sshCommand") + expect(details).toHaveProperty("authorizedKeysPath") + expect(details).toHaveProperty("envGlobalPath") + expect(details).toHaveProperty("codexHome") }) ).pipe(Effect.provide(NodeContext.layer))) @@ -368,7 +375,6 @@ describe("projects service", () => { expect(projects).toHaveLength(1) expect(projects[0]).toMatchObject({ id: projectId, - projectDir: projectId, status: "running", statusLabel: "last known: running", sshSessions: 0, diff --git a/packages/app/src/web/app-terminal-session-core.ts b/packages/app/src/web/app-terminal-session-core.ts new file mode 100644 index 00000000..e31f8197 --- /dev/null +++ b/packages/app/src/web/app-terminal-session-core.ts @@ -0,0 +1,35 @@ +import type { ProjectTerminalSessionLookup } from "./api.js" +import { type ActiveTerminalSession, buildProjectActiveTerminalSession } from "./terminal.js" + +export type WebAppRoute = + | { readonly tag: "Dashboard" } + | { readonly tag: "TerminalSession"; readonly sessionId: string } + +const terminalSessionRoutePrefix = "/ssh/session/" + +export const readTerminalSessionRoute = (pathname: string): string | null => { + if (!pathname.startsWith(terminalSessionRoutePrefix)) { + return null + } + + const rawSessionId = pathname.slice(terminalSessionRoutePrefix.length).split("/")[0] ?? "" + const sessionId = decodeURIComponent(rawSessionId).trim() + return sessionId.length === 0 ? null : sessionId +} + +export const resolveWebAppRoute = (pathname: string): WebAppRoute => { + const sessionId = readTerminalSessionRoute(pathname) + return sessionId === null + ? { tag: "Dashboard" } + : { tag: "TerminalSession", sessionId } +} + +export const buildTerminalOnlyActiveSession = ( + lookup: ProjectTerminalSessionLookup +): ActiveTerminalSession => + buildProjectActiveTerminalSession({ + projectDisplayName: lookup.projectDisplayName, + projectId: lookup.session.projectId, + projectKey: lookup.projectKey, + session: lookup.session + }) diff --git a/packages/app/src/web/app-terminal-session.tsx b/packages/app/src/web/app-terminal-session.tsx new file mode 100644 index 00000000..0d62c2c9 --- /dev/null +++ b/packages/app/src/web/app-terminal-session.tsx @@ -0,0 +1,212 @@ +import { Effect, Match } from "effect" +import { type CSSProperties, type Dispatch, type JSX, type SetStateAction, useEffect, useState } from "react" + +import { deleteTerminalSessionByPath, loadTerminalSessionById, resolveApiBaseUrl } from "./api.js" +import { buildTerminalOnlyActiveSession } from "./app-terminal-session-core.js" +import { Box, Text } from "./elements.js" +import { TerminalPanel } from "./panel-terminal.js" +import type { ActiveTerminalSession } from "./terminal.js" +import type { ViewportLayout } from "./viewport-layout.js" + +type AppTerminalSessionProps = { + readonly sessionId: string + readonly viewportLayout: ViewportLayout +} + +type TerminalOnlyState = + | { readonly _tag: "Loading"; readonly sessionId: string } + | { readonly _tag: "Ready"; readonly message: string | null; readonly session: ActiveTerminalSession } + | { readonly _tag: "Closed"; readonly message: string } + | { readonly _tag: "Error"; readonly apiBaseUrl: string; readonly message: string } + +type TerminalOnlyStateSetter = Dispatch> + +const terminalOnlyContainerStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + height: "100%", + minHeight: 0, + overflow: "hidden", + padding: "8px", + width: "100%" +} + +const terminalOnlyMessageStyle: CSSProperties = { + background: "#101419", + border: "1px solid #3a4652", + borderRadius: "8px", + color: "#f6d27b", + flexShrink: 0, + marginBottom: "8px", + overflow: "hidden", + padding: "8px", + textOverflow: "ellipsis", + whiteSpace: "nowrap" +} + +const terminalOnlyLoadingState = (sessionId: string): TerminalOnlyState => ({ + _tag: "Loading", + sessionId +}) + +const terminalOnlyErrorState = (message: string): TerminalOnlyState => ({ + _tag: "Error", + apiBaseUrl: resolveApiBaseUrl(), + message +}) + +const terminalOnlyClosedState = (message: string): TerminalOnlyState => ({ + _tag: "Closed", + message +}) + +const loadTerminalOnlyState = ( + sessionId: string +): Effect.Effect => + loadTerminalSessionById(sessionId).pipe( + Effect.match({ + onFailure: (message) => terminalOnlyErrorState(message), + onSuccess: (lookup) => ({ + _tag: "Ready", + message: null, + session: buildTerminalOnlyActiveSession(lookup) + }) + }) + ) + +const closeTerminalSession = (session: ActiveTerminalSession): void => { + void Effect.runPromise(deleteTerminalSessionByPath(session.closePath).pipe(Effect.either, Effect.asVoid)) +} + +const updateReadyMessage = ( + setState: TerminalOnlyStateSetter, + message: string | null +): void => { + setState((current) => + current._tag === "Ready" + ? { + ...current, + message + } + : current + ) +} + +const TerminalOnlyMessage = ({ message }: { readonly message: string | null }): JSX.Element | null => + message === null ? null :
{message}
+ +const TerminalOnlyReady = ( + { + session, + setState, + state, + viewportLayout + }: { + readonly session: ActiveTerminalSession + readonly setState: TerminalOnlyStateSetter + readonly state: Extract + readonly viewportLayout: ViewportLayout + } +): JSX.Element => ( +
+ + { + setState(terminalOnlyErrorState(`Terminal websocket closed before attach: ${session.session.id}.`)) + }} + onDetach={() => { + setState(terminalOnlyClosedState(`Detached SSH terminal: ${session.session.id}.`)) + }} + onKill={() => { + closeTerminalSession(session) + setState(terminalOnlyClosedState(`Killed SSH terminal: ${session.session.id}.`)) + }} + onMessage={(message) => { + updateReadyMessage(setState, message) + }} + session={session} + /> +
+) + +const TerminalOnlyClosed = ({ message }: { readonly message: string }): JSX.Element => ( + + + SSH terminal + {message} + + +) + +const TerminalOnlyLoading = ({ sessionId }: { readonly sessionId: string }): JSX.Element => ( + + + SSH terminal + session: {sessionId} + Attaching terminal... + + +) + +const TerminalOnlyError = ( + { apiBaseUrl, message }: { readonly apiBaseUrl: string; readonly message: string } +): JSX.Element => ( + + + SSH terminal unavailable + target: {apiBaseUrl} + {message} + + +) + +const renderTerminalOnlyState = ( + state: TerminalOnlyState, + setState: TerminalOnlyStateSetter, + viewportLayout: ViewportLayout +): JSX.Element => + Match.value(state).pipe( + Match.when({ _tag: "Loading" }, ({ sessionId }) => ), + Match.when( + { _tag: "Error" }, + ({ apiBaseUrl, message }) => + ), + Match.when({ _tag: "Closed" }, ({ message }) => ), + Match.when({ _tag: "Ready" }, (readyState) => ( + + )), + Match.exhaustive + ) + +export const AppTerminalSession = ({ sessionId, viewportLayout }: AppTerminalSessionProps): JSX.Element => { + const [state, setState] = useState(() => terminalOnlyLoadingState(sessionId)) + + useEffect(() => { + let cancelled = false + setState(terminalOnlyLoadingState(sessionId)) + void Effect.runPromise( + loadTerminalOnlyState(sessionId).pipe( + Effect.tap((nextState) => + Effect.sync(() => { + if (!cancelled) { + setState(nextState) + } + }) + ), + Effect.asVoid + ) + ) + return () => { + cancelled = true + } + }, [sessionId]) + + return renderTerminalOnlyState(state, setState, viewportLayout) +} diff --git a/packages/app/src/web/app.tsx b/packages/app/src/web/app.tsx index 8c7ba119..4659905b 100644 --- a/packages/app/src/web/app.tsx +++ b/packages/app/src/web/app.tsx @@ -6,6 +6,8 @@ import { UiProvider } from "../ui/primitives.js" import { loadDashboard, resolveApiBaseUrl } from "./api.js" import { createDashboardRefreshReducer, type DashboardState } from "./app-dashboard-state.js" import { AppReady } from "./app-ready.js" +import { resolveWebAppRoute } from "./app-terminal-session-core.js" +import { AppTerminalSession } from "./app-terminal-session.js" import { ErrorScreen, LoadingScreen } from "./panels.js" import { resolveViewportLayout, type ViewportLayout, type ViewportSize } from "./viewport-layout.js" @@ -170,26 +172,47 @@ const renderDashboardState = ( Match.exhaustive ) -export const App = (): JSX.Element => { +const AppFrame = ( + { children, viewport }: { readonly children: JSX.Element; readonly viewport: ViewportLayout } +): JSX.Element => ( +
+ + {children} + +
+) + +const AppDashboard = ({ viewport }: { readonly viewport: ViewportLayout }): JSX.Element => { const { refresh, state } = useDashboardController() + + return renderDashboardState(state, refresh, viewport) +} + +export const App = (): JSX.Element => { const viewport = useViewportMode() + const [route] = useState(() => resolveWebAppRoute(globalThis.location.pathname)) return ( -
- - {renderDashboardState(state, refresh, viewport)} - -
+ + {Match.value(route).pipe( + Match.when({ tag: "Dashboard" }, () => ), + Match.when( + { tag: "TerminalSession" }, + ({ sessionId }) => + ), + Match.exhaustive + )} + ) } diff --git a/packages/app/tests/docker-git/actions-projects.test.ts b/packages/app/tests/docker-git/actions-projects.test.ts index 5511c5e4..89e3e05e 100644 --- a/packages/app/tests/docker-git/actions-projects.test.ts +++ b/packages/app/tests/docker-git/actions-projects.test.ts @@ -3,7 +3,11 @@ import { Effect } from "effect" import { afterEach, beforeEach, vi } from "vitest" import { applyProjectById, connectProjectById, runApplyAllProjects } from "../../src/web/actions-projects.js" +import type { BrowserActionContext } from "../../src/web/actions-shared.js" import type { + applyAllProjects, + applyProject, + loadProjectTerminalSession, ProjectDetails, startProjectTerminalSession, StartProjectTerminalSessionAccepted, @@ -13,15 +17,12 @@ import type { openProjectEventStream } from "../../src/web/project-events.js" import type { ActiveTerminalSession } from "../../src/web/terminal.js" import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" -type OpenProjectEventStream = typeof openProjectEventStream -type StartProjectTerminalSession = typeof startProjectTerminalSession - -const applyAllProjectsMock = vi.hoisted(() => vi.fn()) -const applyProjectMock = vi.hoisted(() => vi.fn()) -const eventStreamCloseMock = vi.hoisted(() => vi.fn()) -const loadProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) -const openProjectEventStreamMock = vi.hoisted(() => vi.fn()) -const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) +const applyAllProjectsMock = vi.hoisted(() => vi.fn()) +const applyProjectMock = vi.hoisted(() => vi.fn()) +const eventStreamCloseMock = vi.hoisted(() => vi.fn<() => void>()) +const loadProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) +const openProjectEventStreamMock = vi.hoisted(() => vi.fn()) +const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) vi.mock("../../src/web/api.js", () => ({ applyAllProjects: applyAllProjectsMock, @@ -100,15 +101,30 @@ const startTerminalAccepted = (requestId: string): StartProjectTerminalSessionAc requestId }) -const makeSelectedProjectActionContext = ( - overrides: Parameters[0] = {} -) => +const makeSelectedProjectContext = (overrides: Partial) => makeBrowserActionContext({ + ...overrides, selectedProjectId: "project-1", - selectedProjectKey: "octocat/hello-world", - ...overrides + selectedProjectKey: "octocat/hello-world" + }) + +const connectProjectAndWaitForStream = (context: BrowserActionContext) => + Effect.gen(function*(_) { + connectProjectById("project-1", context, "octocat/hello-world") + + yield* _(waitForAssertion(() => { + expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) + })) }) +const readFirstProjectEventHandler = () => { + const handlers = openProjectEventStreamMock.mock.calls[0]?.[1] + if (handlers?.onEvent === undefined) { + throw new Error("missing event handlers") + } + return handlers.onEvent +} + describe("web project actions", () => { beforeEach(() => { vi.restoreAllMocks() @@ -136,23 +152,13 @@ describe("web project actions", () => { openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() const closeTerminalSession = vi.fn<(sessionId: string) => void>() - const { context, reloadDashboard, setMessage } = makeSelectedProjectActionContext({ + const { context, reloadDashboard, setMessage } = makeSelectedProjectContext({ addTerminalSession, closeTerminalSession }) - connectProjectById("project-1", context, "octocat/hello-world") - - yield* _(waitForAssertion(() => { - expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) - })) - - const handlers = openProjectEventStreamMock.mock.calls[0]?.[1] - if (handlers === undefined || typeof handlers.onEvent !== "function") { - throw new Error("missing event handlers") - } - - handlers.onEvent({ + yield* _(connectProjectAndWaitForStream(context)) + readFirstProjectEventHandler()({ at: "2026-04-21T10:00:01.000Z", payload: { phase: "created", @@ -211,10 +217,9 @@ describe("web project actions", () => { it.effect("starts SSH terminal creation when randomUUID is unavailable", () => Effect.gen(function*(_) { const dateNowMock = vi.spyOn(Date, "now").mockReturnValue(0x12_34) - const deterministicBytes = Uint8Array.from([0x80, 0, 0, 0, 0x80, 0, 0, 0]) vi.stubGlobal("crypto", { - getRandomValues: (values: Uint8Array) => { - values.set(deterministicBytes.subarray(0, values.length)) + getRandomValues: (values: Uint8Array): Uint8Array => { + values.set([0x80, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00]) return values } }) @@ -223,14 +228,12 @@ describe("web project actions", () => { ) openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() - const { context } = makeSelectedProjectActionContext({ addTerminalSession }) - - connectProjectById("project-1", context, "octocat/hello-world") - - yield* _(waitForAssertion(() => { - expect(startProjectTerminalSessionMock).toHaveBeenCalledTimes(1) - })) + const { context } = makeSelectedProjectContext({ + addTerminalSession + }) + yield* _(connectProjectAndWaitForStream(context)) + expect(startProjectTerminalSessionMock).toHaveBeenCalledTimes(1) const requestId = startProjectTerminalSessionMock.mock.calls[0]?.[1] expect(requestId).toBe("pending-1234-8000000080000000") expect(addTerminalSession).toHaveBeenCalledTimes(1) diff --git a/packages/app/tests/docker-git/app-terminal-session-core.test.ts b/packages/app/tests/docker-git/app-terminal-session-core.test.ts new file mode 100644 index 00000000..84d2a92e --- /dev/null +++ b/packages/app/tests/docker-git/app-terminal-session-core.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest" + +import type { ProjectTerminalSessionLookup } from "../../src/web/api.js" +import { + buildTerminalOnlyActiveSession, + readTerminalSessionRoute, + resolveWebAppRoute +} from "../../src/web/app-terminal-session-core.js" + +const lookup: ProjectTerminalSessionLookup = { + projectDisplayName: "octocat/hello-world", + projectKey: "octocat/hello-world", + session: { + createdAt: "2026-04-21T10:00:00.000Z", + id: "session-1", + projectId: "project-1", + sshCommand: "ssh -p 22 dev@172.18.0.7", + status: "ready" + } +} + +describe("terminal-only SSH route core", () => { + it("routes direct SSH session URLs outside the dashboard", () => { + expect(resolveWebAppRoute("/ssh/session/session-1")).toEqual({ + tag: "TerminalSession", + sessionId: "session-1" + }) + expect(resolveWebAppRoute("/ssh/session/session%2Fencoded")).toEqual({ + tag: "TerminalSession", + sessionId: "session/encoded" + }) + }) + + it("keeps dashboard and project SSH links on the dashboard route", () => { + expect(resolveWebAppRoute("/")).toEqual({ tag: "Dashboard" }) + expect(resolveWebAppRoute("/menu/select")).toEqual({ tag: "Dashboard" }) + expect(resolveWebAppRoute("/ssh/octocat/hello-world")).toEqual({ tag: "Dashboard" }) + }) + + it("ignores empty SSH session routes", () => { + expect(readTerminalSessionRoute("/ssh/session/")).toBeNull() + }) + + it("builds terminal-only sessions without dashboard refresh callbacks", () => { + const session = buildTerminalOnlyActiveSession(lookup) + + expect(session).toMatchObject({ + browserProjectId: "project-1", + browserProjectKey: "octocat/hello-world", + browserProjectName: "octocat/hello-world", + closePath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/session-1", + sessionPath: "/ssh/session/session-1", + websocketPath: "/projects/by-key/octocat%2Fhello-world/terminal-sessions/session-1/ws" + }) + expect("onReady" in session).toBe(false) + expect("onExit" in session).toBe(false) + }) +})