diff --git a/.changeset/restore-ssh-session-toolbar.md b/.changeset/restore-ssh-session-toolbar.md new file mode 100644 index 00000000..b6a7b093 --- /dev/null +++ b/.changeset/restore-ssh-session-toolbar.md @@ -0,0 +1,5 @@ +--- +"@prover-coder-ai/docker-git": patch +--- + +Restore the SSH session toolbar after a page reload on `/ssh/session/:id`. The standalone terminal view now wires Open browser, Apply, Task manager, and New terminal handlers in addition to Detach and Kill, matching the dashboard-launched terminal toolbar. diff --git a/docs/screenshots/issue-269-after-reload.png b/docs/screenshots/issue-269-after-reload.png new file mode 100644 index 00000000..33ccb19b Binary files /dev/null and b/docs/screenshots/issue-269-after-reload.png differ diff --git a/docs/screenshots/issue-269-expected.png b/docs/screenshots/issue-269-expected.png new file mode 100644 index 00000000..27579bd8 Binary files /dev/null and b/docs/screenshots/issue-269-expected.png differ diff --git a/packages/app/src/web/app-terminal-session-handlers.ts b/packages/app/src/web/app-terminal-session-handlers.ts new file mode 100644 index 00000000..33a7245f --- /dev/null +++ b/packages/app/src/web/app-terminal-session-handlers.ts @@ -0,0 +1,252 @@ +import { Effect } from "effect" +import { type Dispatch, type SetStateAction, useCallback, useState } from "react" + +import { + applyProject, + type ContainerTaskSnapshot, + createProjectTerminalSession, + loadProjectBrowser, + loadProjectTaskLogs, + loadProjectTasks, + projectBrowserCdpUrl, + projectBrowserNoVncUrl, + type ProjectBrowserSession, + stopProjectTask +} from "./api.js" +import { openUrl } from "./open-url.js" +import { terminalSessionRoutePath } from "./terminal.js" + +export type StateMessageUpdater = (message: string | null) => void + +export type ProjectHandlers = { + readonly onApplyProject: (() => void) | undefined + readonly onOpenBrowser: (() => void) | undefined + readonly onOpenTaskManager: (() => void) | undefined + readonly onOpenTerminal: (() => void) | undefined +} + +export type TaskHandlers = { + readonly logs: string + readonly onIncludeDefaultChange: (include: boolean) => void + readonly onLoadLogs: (pid: number) => void + readonly onRefresh: () => void + readonly onStopTask: (pid: number) => void + readonly refreshTasks: (include: boolean) => void + readonly snapshot: ContainerTaskSnapshot | null + readonly taskIncludeDefault: boolean +} + +const confirmApplyProject = (label: string): boolean => { + const dialog = globalThis.confirm + return typeof dialog === "function" + && dialog( + `Apply docker-git config to ${label}? This restarts the container and ends active SSH sessions and in-container browsers.` + ) +} + +const browserStatusMessage = (browser: ProjectBrowserSession): string => { + if (browser.status !== "running") { + return `Browser sidecar is ${browser.status}. Enable Playwright MCP and start the project first.` + } + const noVncUrl = projectBrowserNoVncUrl(browser) + return openUrl(noVncUrl) + ? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.` + : `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.` +} + +const runOpenBrowser = (projectId: string, setMessage: StateMessageUpdater): void => { + void Effect.runPromise( + loadProjectBrowser(projectId).pipe( + Effect.match({ + onFailure: (error) => { + setMessage(`Failed to open browser: ${error}`) + }, + onSuccess: (browser) => { + setMessage(browserStatusMessage(browser)) + } + }) + ) + ) +} + +const runApplyProject = ( + projectId: string, + projectLabel: string, + setMessage: StateMessageUpdater +): void => { + if (!confirmApplyProject(projectLabel)) { + return + } + void Effect.runPromise( + applyProject(projectId).pipe( + Effect.match({ + onFailure: (error) => { + setMessage(`Apply failed: ${error}`) + }, + onSuccess: (applied) => { + setMessage(`Applied ${applied.displayName}.`) + } + }) + ) + ) +} + +const handleTerminalCreated = (sessionId: string, setMessage: StateMessageUpdater): void => { + const targetUrl = `${globalThis.location.origin}${terminalSessionRoutePath(sessionId)}` + if (!openUrl(targetUrl)) { + setMessage(`New terminal popup was blocked. Open ${targetUrl} manually.`) + } +} + +const runOpenTerminal = (projectKey: string, setMessage: StateMessageUpdater): void => { + void Effect.runPromise( + createProjectTerminalSession(projectKey).pipe( + Effect.match({ + onFailure: (error) => { + setMessage(`Failed to open new terminal: ${error}`) + }, + onSuccess: (created) => { + handleTerminalCreated(created.session.id, setMessage) + } + }) + ) + ) +} + +export type ProjectActionHandlersArgs = { + readonly onOpenTaskManagerRequest: () => void + readonly projectId: string | undefined + readonly projectKey: string | undefined + readonly projectLabel: string + readonly setMessage: StateMessageUpdater +} + +export const useProjectActionHandlers = ( + { onOpenTaskManagerRequest, projectId, projectKey, projectLabel, setMessage }: ProjectActionHandlersArgs +): ProjectHandlers => ({ + onApplyProject: projectId === undefined ? undefined : () => { + runApplyProject(projectId, projectLabel, setMessage) + }, + onOpenBrowser: projectId === undefined ? undefined : () => { + runOpenBrowser(projectId, setMessage) + }, + onOpenTaskManager: projectId === undefined ? undefined : onOpenTaskManagerRequest, + onOpenTerminal: projectId === undefined || projectKey === undefined + ? undefined + : () => { + runOpenTerminal(projectKey, setMessage) + } +}) + +const runRefreshTasks = ( + projectId: string, + include: boolean, + setSnapshot: Dispatch>, + setMessage: StateMessageUpdater +): void => { + void Effect.runPromise( + loadProjectTasks(projectId, include).pipe( + Effect.match({ + onFailure: (error) => { + setMessage(`Failed to load tasks: ${error}`) + }, + onSuccess: (next) => { + setSnapshot(next) + } + }) + ) + ) +} + +const runStopTask = ( + projectId: string, + pid: number, + setMessage: StateMessageUpdater, + onAfterStop: () => void +): void => { + void Effect.runPromise( + stopProjectTask(projectId, pid).pipe( + Effect.match({ + onFailure: (error) => { + setMessage(`Failed to stop task ${pid}: ${error}`) + }, + onSuccess: () => { + onAfterStop() + } + }) + ) + ) +} + +const runLoadLogs = ( + projectId: string, + pid: number, + setLogs: Dispatch>, + setMessage: StateMessageUpdater +): void => { + void Effect.runPromise( + loadProjectTaskLogs(projectId, pid).pipe( + Effect.match({ + onFailure: (error) => { + setMessage(`Failed to load logs for ${pid}: ${error}`) + }, + onSuccess: (output) => { + setLogs(output) + } + }) + ) + ) +} + +export type TaskManagerHandlersArgs = { + readonly projectId: string | undefined + readonly setMessage: StateMessageUpdater +} + +export const useTaskManagerHandlers = ( + { projectId, setMessage }: TaskManagerHandlersArgs +): TaskHandlers => { + const [snapshot, setSnapshot] = useState(null) + const [logs, setLogs] = useState("") + const [taskIncludeDefault, setTaskIncludeDefault] = useState(false) + + const refreshTasks = useCallback((include: boolean) => { + if (projectId !== undefined) { + runRefreshTasks(projectId, include, setSnapshot, setMessage) + } + }, [projectId, setMessage]) + + const onStopTask = useCallback((pid: number) => { + if (projectId !== undefined) { + runStopTask(projectId, pid, setMessage, () => { + refreshTasks(taskIncludeDefault) + }) + } + }, [projectId, refreshTasks, setMessage, taskIncludeDefault]) + + const onLoadLogs = useCallback((pid: number) => { + if (projectId !== undefined) { + runLoadLogs(projectId, pid, setLogs, setMessage) + } + }, [projectId, setMessage]) + + const onIncludeDefaultChange = useCallback((include: boolean) => { + setTaskIncludeDefault(include) + refreshTasks(include) + }, [refreshTasks]) + + const onRefresh = useCallback(() => { + refreshTasks(taskIncludeDefault) + }, [refreshTasks, taskIncludeDefault]) + + return { + logs, + onIncludeDefaultChange, + onLoadLogs, + onRefresh, + onStopTask, + refreshTasks, + snapshot, + taskIncludeDefault + } +} diff --git a/packages/app/src/web/app-terminal-session-ui.tsx b/packages/app/src/web/app-terminal-session-ui.tsx new file mode 100644 index 00000000..9758ff95 --- /dev/null +++ b/packages/app/src/web/app-terminal-session-ui.tsx @@ -0,0 +1,171 @@ +import type { CSSProperties, JSX } from "react" + +import type { ProjectDetails } from "./api.js" +import type { ProjectHandlers, TaskHandlers } from "./app-terminal-session-handlers.js" +import { Box, Text } from "./elements.js" +import { TaskPanel } from "./panel-tasks.js" +import { TerminalPanel } from "./panel-terminal.js" +import type { ActiveTerminalSession } from "./terminal.js" +import type { ViewportLayout } from "./viewport-layout.js" + +export 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 taskManagerBodyStyle: CSSProperties = { + background: "#080a0d", + boxSizing: "border-box", + color: "#d6e5f7", + height: "100%", + overflow: "auto", + padding: "10px" +} + +const taskManagerToolbarStyle: CSSProperties = { + alignItems: "center", + display: "flex", + justifyContent: "flex-end", + marginBottom: "10px" +} + +const taskManagerReturnButtonStyle: CSSProperties = { + background: "#171d24", + border: "1px solid #3a4652", + borderRadius: "8px", + color: "#d6e5f7", + cursor: "pointer", + font: "inherit", + padding: "6px 10px" +} + +export const TerminalOnlyMessage = ( + { message }: { readonly message: string | null } +): JSX.Element | null => message === null ? null :
{message}
+ +type TaskManagerBodyProps = { + readonly onClose: () => void + readonly project: ProjectDetails | null + readonly tasks: TaskHandlers +} + +const TerminalTaskManagerBody = ( + { onClose, project, tasks }: TaskManagerBodyProps +): JSX.Element => ( +
+
+ +
+ +
+) + +export type RenderTaskManagerBodyArgs = { + readonly onCloseTaskManager: () => void + readonly project: ProjectDetails | null + readonly projectId: string | undefined + readonly taskManagerOpen: boolean + readonly tasks: TaskHandlers +} + +export const renderTaskManagerBody = ( + { onCloseTaskManager, project, projectId, taskManagerOpen, tasks }: RenderTaskManagerBodyArgs +): JSX.Element | undefined => + taskManagerOpen && projectId !== undefined + ? + : undefined + +export type TerminalOnlyCallbacks = { + readonly onAttachFailure: () => void + readonly onDetach: () => void + readonly onKill: () => void + readonly onMessage: (message: string) => void +} + +export type TerminalOnlyTerminalPanelArgs = { + readonly bodyContent: JSX.Element | undefined + readonly callbacks: TerminalOnlyCallbacks + readonly handlers: ProjectHandlers + readonly session: ActiveTerminalSession + readonly viewportLayout: ViewportLayout +} + +export const TerminalOnlyTerminalPanel = ( + { bodyContent, callbacks, handlers, session, viewportLayout }: TerminalOnlyTerminalPanelArgs +): JSX.Element => ( + +) + +export const TerminalOnlyClosed = ({ message }: { readonly message: string }): JSX.Element => ( + + + SSH terminal + {message} + + +) + +export const TerminalOnlyLoading = ({ sessionId }: { readonly sessionId: string }): JSX.Element => ( + + + SSH terminal + session: {sessionId} + Attaching terminal... + + +) + +export const TerminalOnlyError = ( + { apiBaseUrl, message }: { readonly apiBaseUrl: string; readonly message: string } +): JSX.Element => ( + + + SSH terminal unavailable + target: {apiBaseUrl} + {message} + + +) diff --git a/packages/app/src/web/app-terminal-session.tsx b/packages/app/src/web/app-terminal-session.tsx index 0d62c2c9..1957926b 100644 --- a/packages/app/src/web/app-terminal-session.tsx +++ b/packages/app/src/web/app-terminal-session.tsx @@ -1,10 +1,29 @@ 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 { type Dispatch, type JSX, type SetStateAction, useCallback, useEffect, useState } from "react" + +import { + deleteTerminalSessionByPath, + loadProjectDetails, + loadTerminalSessionById, + type ProjectDetails, + 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 ProjectHandlers, + type TaskHandlers, + useProjectActionHandlers, + useTaskManagerHandlers +} from "./app-terminal-session-handlers.js" +import { + renderTaskManagerBody, + TerminalOnlyClosed, + terminalOnlyContainerStyle, + TerminalOnlyError, + TerminalOnlyLoading, + TerminalOnlyMessage, + TerminalOnlyTerminalPanel +} from "./app-terminal-session-ui.js" import type { ActiveTerminalSession } from "./terminal.js" import type { ViewportLayout } from "./viewport-layout.js" @@ -21,29 +40,6 @@ type TerminalOnlyState = 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 @@ -60,9 +56,7 @@ const terminalOnlyClosedState = (message: string): TerminalOnlyState => ({ message }) -const loadTerminalOnlyState = ( - sessionId: string -): Effect.Effect => +const loadTerminalOnlyState = (sessionId: string): Effect.Effect => loadTerminalSessionById(sessionId).pipe( Effect.match({ onFailure: (message) => terminalOnlyErrorState(message), @@ -78,89 +72,121 @@ 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 updateReadyMessage = (setState: TerminalOnlyStateSetter, message: string | null): void => { + setState((current) => current._tag === "Ready" ? { ...current, message } : current) +} + +const useLoadedProjectDetails = (projectId: string | undefined): ProjectDetails | null => { + const [project, setProject] = useState(null) + useEffect(() => { + if (projectId === undefined) { + return + } + let cancelled = false + void Effect.runPromise( + loadProjectDetails(projectId).pipe( + Effect.tap((details) => + Effect.sync(() => { + if (!cancelled) { + setProject(details) + } + }) + ), + Effect.catchAll(() => Effect.sync(() => {})), + Effect.asVoid + ) + ) + return () => { + cancelled = true + } + }, [projectId]) + return project } -const TerminalOnlyMessage = ({ message }: { readonly message: string | null }): JSX.Element | null => - message === null ? null :
{message}
+type TerminalOnlyReadyArgs = { + readonly session: ActiveTerminalSession + readonly setState: TerminalOnlyStateSetter + readonly state: Extract + readonly viewportLayout: ViewportLayout +} + +const useTerminalOnlyReadyState = ( + session: ActiveTerminalSession, + setState: TerminalOnlyStateSetter +): { + readonly handlers: ProjectHandlers + readonly project: ProjectDetails | null + readonly setMessage: (message: string | null) => void + readonly tasks: TaskHandlers + readonly taskManagerOpen: boolean + readonly setTaskManagerOpen: Dispatch> +} => { + const projectId = session.browserProjectId + const projectKey = session.browserProjectKey + const projectLabel = session.browserProjectName ?? projectId ?? "this project" + const project = useLoadedProjectDetails(projectId) + const [taskManagerOpen, setTaskManagerOpen] = useState(false) + const setMessage = useCallback( + (message: string | null) => { + updateReadyMessage(setState, message) + }, + [setState] + ) + const tasks = useTaskManagerHandlers({ projectId, setMessage }) + const handlers = useProjectActionHandlers({ + onOpenTaskManagerRequest: () => { + setTaskManagerOpen(true) + tasks.refreshTasks(tasks.taskIncludeDefault) + }, + projectId, + projectKey, + projectLabel, + setMessage + }) + return { handlers, project, setMessage, setTaskManagerOpen, taskManagerOpen, tasks } +} const TerminalOnlyReady = ( - { + { session, setState, state, viewportLayout }: TerminalOnlyReadyArgs +): JSX.Element => { + const { handlers, project, setMessage, setTaskManagerOpen, taskManagerOpen, tasks } = useTerminalOnlyReadyState( 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} - - -) + setState + ) + const bodyContent = renderTaskManagerBody({ + onCloseTaskManager: () => { + setTaskManagerOpen(false) + }, + project, + projectId: session.browserProjectId, + taskManagerOpen, + tasks + }) + return ( +
+ + { + 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: setMessage + }} + handlers={handlers} + session={session} + viewportLayout={viewportLayout} + /> +
+ ) +} const renderTerminalOnlyState = ( state: TerminalOnlyState, diff --git a/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts b/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts new file mode 100644 index 00000000..657d9a5e --- /dev/null +++ b/packages/app/tests/docker-git/app-terminal-session-handlers.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest" + +import { type ProjectHandlers, useProjectActionHandlers } from "../../src/web/app-terminal-session-handlers.js" + +const noopMessage = (_message: string | null): void => {} +const noopOpenTaskManager = (): void => {} + +const buildHandlers = ( + overrides: Partial[0]> = {} +): ProjectHandlers => + useProjectActionHandlers({ + onOpenTaskManagerRequest: noopOpenTaskManager, + projectId: "project-1", + projectKey: "octocat/hello-world", + projectLabel: "octocat/hello-world", + setMessage: noopMessage, + ...overrides + }) + +describe("useProjectActionHandlers", () => { + it("returns all four project action handlers when project context is present", () => { + const handlers = buildHandlers() + expect(typeof handlers.onApplyProject).toBe("function") + expect(typeof handlers.onOpenBrowser).toBe("function") + expect(typeof handlers.onOpenTaskManager).toBe("function") + expect(typeof handlers.onOpenTerminal).toBe("function") + }) + + it("hides all handlers when projectId is missing", () => { + const handlers = buildHandlers({ projectId: undefined }) + expect(handlers.onApplyProject).toBeUndefined() + expect(handlers.onOpenBrowser).toBeUndefined() + expect(handlers.onOpenTaskManager).toBeUndefined() + expect(handlers.onOpenTerminal).toBeUndefined() + }) + + it("hides only onOpenTerminal when projectKey is missing", () => { + const handlers = buildHandlers({ projectKey: undefined }) + expect(typeof handlers.onApplyProject).toBe("function") + expect(typeof handlers.onOpenBrowser).toBe("function") + expect(typeof handlers.onOpenTaskManager).toBe("function") + expect(handlers.onOpenTerminal).toBeUndefined() + }) + + it("wires onOpenTaskManager to the supplied request callback", () => { + let opened = 0 + const handlers = buildHandlers({ + onOpenTaskManagerRequest: () => { + opened += 1 + } + }) + handlers.onOpenTaskManager?.() + expect(opened).toBe(1) + }) +})