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
5 changes: 5 additions & 0 deletions .changeset/restore-ssh-session-toolbar.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added docs/screenshots/issue-269-after-reload.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/issue-269-expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
252 changes: 252 additions & 0 deletions packages/app/src/web/app-terminal-session-handlers.ts
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<ContainerTaskSnapshot | null>>,
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<SetStateAction<string>>,
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<ContainerTaskSnapshot | null>(null)
const [logs, setLogs] = useState<string>("")
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
}
}
Loading