Skip to content

Commit 011e2e2

Browse files
committed
fix(web): restore SSH session toolbar after page reload
Wire Open browser, Apply, Task manager, and New terminal handlers in the standalone /ssh/session/:id view so the full toolbar appears after reload, matching the dashboard-launched terminal. Closes #269
1 parent 028e1d4 commit 011e2e2

7 files changed

Lines changed: 632 additions & 109 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@prover-coder-ai/docker-git": patch
3+
---
4+
5+
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.
17.1 KB
Loading
9.1 KB
Loading
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { Effect } from "effect"
2+
import { type Dispatch, type SetStateAction, useCallback, useState } from "react"
3+
4+
import {
5+
applyProject,
6+
type ContainerTaskSnapshot,
7+
createProjectTerminalSession,
8+
loadProjectBrowser,
9+
loadProjectTaskLogs,
10+
loadProjectTasks,
11+
projectBrowserCdpUrl,
12+
projectBrowserNoVncUrl,
13+
type ProjectBrowserSession,
14+
stopProjectTask
15+
} from "./api.js"
16+
import { openUrl } from "./open-url.js"
17+
import { terminalSessionRoutePath } from "./terminal.js"
18+
19+
export type StateMessageUpdater = (message: string | null) => void
20+
21+
export type ProjectHandlers = {
22+
readonly onApplyProject: (() => void) | undefined
23+
readonly onOpenBrowser: (() => void) | undefined
24+
readonly onOpenTaskManager: (() => void) | undefined
25+
readonly onOpenTerminal: (() => void) | undefined
26+
}
27+
28+
export type TaskHandlers = {
29+
readonly logs: string
30+
readonly onIncludeDefaultChange: (include: boolean) => void
31+
readonly onLoadLogs: (pid: number) => void
32+
readonly onRefresh: () => void
33+
readonly onStopTask: (pid: number) => void
34+
readonly refreshTasks: (include: boolean) => void
35+
readonly snapshot: ContainerTaskSnapshot | null
36+
readonly taskIncludeDefault: boolean
37+
}
38+
39+
const confirmApplyProject = (label: string): boolean => {
40+
const dialog = globalThis.confirm
41+
return typeof dialog === "function"
42+
&& dialog(
43+
`Apply docker-git config to ${label}? This restarts the container and ends active SSH sessions and in-container browsers.`
44+
)
45+
}
46+
47+
const browserStatusMessage = (browser: ProjectBrowserSession): string => {
48+
if (browser.status !== "running") {
49+
return `Browser sidecar is ${browser.status}. Enable Playwright MCP and start the project first.`
50+
}
51+
const noVncUrl = projectBrowserNoVncUrl(browser)
52+
return openUrl(noVncUrl)
53+
? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
54+
: `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
55+
}
56+
57+
const runOpenBrowser = (projectId: string, setMessage: StateMessageUpdater): void => {
58+
void Effect.runPromise(
59+
loadProjectBrowser(projectId).pipe(
60+
Effect.tap((browser) =>
61+
Effect.sync(() => {
62+
setMessage(browserStatusMessage(browser))
63+
})
64+
),
65+
Effect.catchAll((error) =>
66+
Effect.sync(() => {
67+
setMessage(`Failed to open browser: ${error}`)
68+
})
69+
),
70+
Effect.asVoid
71+
)
72+
)
73+
}
74+
75+
const runApplyProject = (
76+
projectId: string,
77+
projectLabel: string,
78+
setMessage: StateMessageUpdater
79+
): void => {
80+
if (!confirmApplyProject(projectLabel)) {
81+
return
82+
}
83+
void Effect.runPromise(
84+
applyProject(projectId).pipe(
85+
Effect.tap((applied) =>
86+
Effect.sync(() => {
87+
setMessage(`Applied ${applied.displayName}.`)
88+
})
89+
),
90+
Effect.catchAll((error) =>
91+
Effect.sync(() => {
92+
setMessage(`Apply failed: ${error}`)
93+
})
94+
),
95+
Effect.asVoid
96+
)
97+
)
98+
}
99+
100+
const handleTerminalCreated = (sessionId: string, setMessage: StateMessageUpdater): void => {
101+
const targetUrl = `${globalThis.location.origin}${terminalSessionRoutePath(sessionId)}`
102+
if (!openUrl(targetUrl)) {
103+
setMessage(`New terminal popup was blocked. Open ${targetUrl} manually.`)
104+
}
105+
}
106+
107+
const runOpenTerminal = (projectKey: string, setMessage: StateMessageUpdater): void => {
108+
void Effect.runPromise(
109+
createProjectTerminalSession(projectKey).pipe(
110+
Effect.tap((created) =>
111+
Effect.sync(() => {
112+
handleTerminalCreated(created.session.id, setMessage)
113+
})
114+
),
115+
Effect.catchAll((error) =>
116+
Effect.sync(() => {
117+
setMessage(`Failed to open new terminal: ${error}`)
118+
})
119+
),
120+
Effect.asVoid
121+
)
122+
)
123+
}
124+
125+
export type ProjectActionHandlersArgs = {
126+
readonly onOpenTaskManagerRequest: () => void
127+
readonly projectId: string | undefined
128+
readonly projectKey: string | undefined
129+
readonly projectLabel: string
130+
readonly setMessage: StateMessageUpdater
131+
}
132+
133+
export const useProjectActionHandlers = (
134+
{ onOpenTaskManagerRequest, projectId, projectKey, projectLabel, setMessage }: ProjectActionHandlersArgs
135+
): ProjectHandlers => ({
136+
onApplyProject: projectId === undefined ? undefined : () => {
137+
runApplyProject(projectId, projectLabel, setMessage)
138+
},
139+
onOpenBrowser: projectId === undefined ? undefined : () => {
140+
runOpenBrowser(projectId, setMessage)
141+
},
142+
onOpenTaskManager: projectId === undefined ? undefined : onOpenTaskManagerRequest,
143+
onOpenTerminal: projectId === undefined || projectKey === undefined
144+
? undefined
145+
: () => {
146+
runOpenTerminal(projectKey, setMessage)
147+
}
148+
})
149+
150+
const runRefreshTasks = (
151+
projectId: string,
152+
include: boolean,
153+
setSnapshot: Dispatch<SetStateAction<ContainerTaskSnapshot | null>>,
154+
setMessage: StateMessageUpdater
155+
): void => {
156+
void Effect.runPromise(
157+
loadProjectTasks(projectId, include).pipe(
158+
Effect.tap((next) =>
159+
Effect.sync(() => {
160+
setSnapshot(next)
161+
})
162+
),
163+
Effect.catchAll((error) =>
164+
Effect.sync(() => {
165+
setMessage(`Failed to load tasks: ${error}`)
166+
})
167+
),
168+
Effect.asVoid
169+
)
170+
)
171+
}
172+
173+
const runStopTask = (
174+
projectId: string,
175+
pid: number,
176+
setMessage: StateMessageUpdater,
177+
onAfterStop: () => void
178+
): void => {
179+
void Effect.runPromise(
180+
stopProjectTask(projectId, pid).pipe(
181+
Effect.tap(() => Effect.sync(onAfterStop)),
182+
Effect.catchAll((error) =>
183+
Effect.sync(() => {
184+
setMessage(`Failed to stop task ${pid}: ${error}`)
185+
})
186+
),
187+
Effect.asVoid
188+
)
189+
)
190+
}
191+
192+
const runLoadLogs = (
193+
projectId: string,
194+
pid: number,
195+
setLogs: Dispatch<SetStateAction<string>>,
196+
setMessage: StateMessageUpdater
197+
): void => {
198+
void Effect.runPromise(
199+
loadProjectTaskLogs(projectId, pid).pipe(
200+
Effect.tap((output) =>
201+
Effect.sync(() => {
202+
setLogs(output)
203+
})
204+
),
205+
Effect.catchAll((error) =>
206+
Effect.sync(() => {
207+
setMessage(`Failed to load logs for ${pid}: ${error}`)
208+
})
209+
),
210+
Effect.asVoid
211+
)
212+
)
213+
}
214+
215+
export type TaskManagerHandlersArgs = {
216+
readonly projectId: string | undefined
217+
readonly setMessage: StateMessageUpdater
218+
}
219+
220+
export const useTaskManagerHandlers = (
221+
{ projectId, setMessage }: TaskManagerHandlersArgs
222+
): TaskHandlers => {
223+
const [snapshot, setSnapshot] = useState<ContainerTaskSnapshot | null>(null)
224+
const [logs, setLogs] = useState<string>("")
225+
const [taskIncludeDefault, setTaskIncludeDefault] = useState(false)
226+
227+
const refreshTasks = useCallback((include: boolean) => {
228+
if (projectId !== undefined) {
229+
runRefreshTasks(projectId, include, setSnapshot, setMessage)
230+
}
231+
}, [projectId, setMessage])
232+
233+
const onStopTask = useCallback((pid: number) => {
234+
if (projectId !== undefined) {
235+
runStopTask(projectId, pid, setMessage, () => {
236+
refreshTasks(taskIncludeDefault)
237+
})
238+
}
239+
}, [projectId, refreshTasks, setMessage, taskIncludeDefault])
240+
241+
const onLoadLogs = useCallback((pid: number) => {
242+
if (projectId !== undefined) {
243+
runLoadLogs(projectId, pid, setLogs, setMessage)
244+
}
245+
}, [projectId, setMessage])
246+
247+
const onIncludeDefaultChange = useCallback((include: boolean) => {
248+
setTaskIncludeDefault(include)
249+
refreshTasks(include)
250+
}, [refreshTasks])
251+
252+
const onRefresh = useCallback(() => {
253+
refreshTasks(taskIncludeDefault)
254+
}, [refreshTasks, taskIncludeDefault])
255+
256+
return {
257+
logs,
258+
onIncludeDefaultChange,
259+
onLoadLogs,
260+
onRefresh,
261+
onStopTask,
262+
refreshTasks,
263+
snapshot,
264+
taskIncludeDefault
265+
}
266+
}

0 commit comments

Comments
 (0)