Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/pr-screenshots/issue-251/dashboard-route.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions docs/pr-screenshots/issue-251/network-proof.json
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions packages/api/src/services/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectDetails>))
Effect.map((projects) => projects.map((project) => dbProjectSummary(project))),
Effect.catchAll(() => Effect.succeed([] as ReadonlyArray<ProjectSummary>))
)

export const applyAllProjects = (activeOnly: boolean) =>
Expand Down
12 changes: 9 additions & 3 deletions packages/api/tests/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)))

Expand Down Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions packages/app/src/web/app-terminal-session-core.ts
Original file line number Diff line number Diff line change
@@ -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
})
212 changes: 212 additions & 0 deletions packages/app/src/web/app-terminal-session.tsx
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<TerminalOnlyState>>

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<TerminalOnlyState> =>
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 : <div style={terminalOnlyMessageStyle}>{message}</div>

const TerminalOnlyReady = (
{
session,
setState,
state,
viewportLayout
}: {
readonly session: ActiveTerminalSession
readonly setState: TerminalOnlyStateSetter
readonly state: Extract<TerminalOnlyState, { readonly _tag: "Ready" }>
readonly viewportLayout: ViewportLayout
}
): JSX.Element => (
<div style={terminalOnlyContainerStyle}>
<TerminalOnlyMessage message={state.message} />
<TerminalPanel
keyboardOpen={viewportLayout.keyboardOpen}
mobileMode={viewportLayout.mode === "mobile"}
onAttachFailure={() => {
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}
/>
</div>
)

const TerminalOnlyClosed = ({ message }: { readonly message: string }): JSX.Element => (
<Box alignItems="center" height="100%" justifyContent="center" padding={2} width="100%">
<Box border={true} borderColor="#3a4652" borderStyle="rounded" flexDirection="column" padding={2}>
<Text bold={true} fg="#f5fbff">SSH terminal</Text>
<Text fg="#f6d27b">{message}</Text>
</Box>
</Box>
)

const TerminalOnlyLoading = ({ sessionId }: { readonly sessionId: string }): JSX.Element => (
<Box alignItems="center" height="100%" justifyContent="center" padding={2} width="100%">
<Box border={true} borderColor="#3a4652" borderStyle="rounded" flexDirection="column" padding={2}>
<Text bold={true} fg="#f5fbff">SSH terminal</Text>
<Text fg="#7fdfff">session: {sessionId}</Text>
<Text fg="#a8c0dc">Attaching terminal...</Text>
</Box>
</Box>
)

const TerminalOnlyError = (
{ apiBaseUrl, message }: { readonly apiBaseUrl: string; readonly message: string }
): JSX.Element => (
<Box height="100%" justifyContent="center" padding={2} width="100%">
<Box border={true} borderColor="#ff6b7d" borderStyle="rounded" flexDirection="column" padding={2}>
<Text bold={true} fg="#ffd8de">SSH terminal unavailable</Text>
<Text fg="#ffd166">target: {apiBaseUrl}</Text>
<Text fg="#f2b7bf">{message}</Text>
</Box>
</Box>
)

const renderTerminalOnlyState = (
state: TerminalOnlyState,
setState: TerminalOnlyStateSetter,
viewportLayout: ViewportLayout
): JSX.Element =>
Match.value(state).pipe(
Match.when({ _tag: "Loading" }, ({ sessionId }) => <TerminalOnlyLoading sessionId={sessionId} />),
Match.when(
{ _tag: "Error" },
({ apiBaseUrl, message }) => <TerminalOnlyError apiBaseUrl={apiBaseUrl} message={message} />
),
Match.when({ _tag: "Closed" }, ({ message }) => <TerminalOnlyClosed message={message} />),
Match.when({ _tag: "Ready" }, (readyState) => (
<TerminalOnlyReady
session={readyState.session}
setState={setState}
state={readyState}
viewportLayout={viewportLayout}
/>
)),
Match.exhaustive
)

export const AppTerminalSession = ({ sessionId, viewportLayout }: AppTerminalSessionProps): JSX.Element => {
const [state, setState] = useState<TerminalOnlyState>(() => 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)
}
Loading