Skip to content

Commit 6641d31

Browse files
committed
fix(app): address coderabbit follow-up review
1 parent f7c943a commit 6641d31

7 files changed

Lines changed: 303 additions & 110 deletions

packages/app/src/docker-git/menu-create-advance.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,9 @@ const resolveNextCreateFlowStep = (
266266
: currentStepIndex + 1
267267

268268
const shouldCompleteCreateFlow = (
269-
nextSteps: ReadonlyArray<CreateStep>
270-
): boolean => nextSteps.length <= firstCreateSettingsStepIndex
269+
nextSteps: ReadonlyArray<CreateStep>,
270+
nextStep: number
271+
): boolean => nextSteps.length <= firstCreateSettingsStepIndex || nextStep >= nextSteps.length
271272

272273
/**
273274
* Advances create mode by applying the active prompt buffer.
@@ -281,10 +282,10 @@ const shouldCompleteCreateFlow = (
281282
// QUOTE(ТЗ): "after applying a non-repoUrl step it advances to currentStepIndex + 1"
282283
// REF: issue-339
283284
// SOURCE: n/a
284-
// FORMAT THEOREM: remaining = empty -> Complete, otherwise Continue(next valid setting)
285+
// FORMAT THEOREM: remaining = empty or nextStep past end -> Complete, otherwise Continue(next valid setting)
285286
// PURITY: CORE
286287
// EFFECT: n/a
287-
// INVARIANT: completion requires no unsatisfied settings after repoUrl
288+
// INVARIANT: applying the final settings index completes instead of clamping back to it
288289
// COMPLEXITY: O(k + s) where s = number of remaining create steps
289290
export const advanceCreateFlow = (
290291
contextOrCwd: string | CreateFlowContext,
@@ -307,12 +308,10 @@ export const advanceCreateFlow = (
307308

308309
const nextSteps = resolveCreateFlowSteps(nextValues)
309310
const nextStep = resolveNextCreateFlowStep(step, view.step)
310-
if (shouldCompleteCreateFlow(nextSteps)) {
311+
if (shouldCompleteCreateFlow(nextSteps, nextStep)) {
311312
return completeCreateFlow(context, nextValues)
312313
}
313-
return nextStep < nextSteps.length
314-
? continueCreateFlow(clampCreateSettingsStep(nextStep, nextSteps.length - 1), nextValues)
315-
: continueCreateFlow(nextSteps.length - 1, nextValues)
314+
return continueCreateFlow(clampCreateSettingsStep(nextStep, nextSteps.length - 1), nextValues)
316315
}
317316
)
318317
}

packages/app/src/web/actions-project-terminal.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,11 @@ const showPendingTerminalError = (
176176
error: string
177177
): void => {
178178
const wasFinalized = runtime.pendingSessionFinalized
179-
runtime.pendingSessionFinalized = true
180-
if (!wasFinalized) {
181-
runtime.lifecycle.onFailure?.(error)
179+
if (wasFinalized) {
180+
return
182181
}
182+
runtime.pendingSessionFinalized = true
183+
runtime.lifecycle.onFailure?.(error)
183184
appendOutputLine(context, `[error] ${error}`)
184185
context.addTerminalSession(renderPendingTerminalSession(context, runtime, error, "error"))
185186
}

packages/app/src/web/app-ready-ssh-link-hook.ts

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ import {
1717
type SshLinkRequest,
1818
sshLinkRequestKey
1919
} from "./app-ready-ssh-link-core.js"
20-
import { attachProjectWorkspaceSessions, showProjectTerminalScreen } from "./app-ready-ssh-link-terminal.js"
20+
import {
21+
attachLoadedSshSessionLink,
22+
attachProjectWorkspaceSessions,
23+
type LoadedSshSessionLink,
24+
showProjectTerminalScreen
25+
} from "./app-ready-ssh-link-terminal.js"
2126
import { browserMenuIndex } from "./menu.js"
2227
import { projectPickerScreen } from "./screen.js"
2328
import { terminalSessionId } from "./terminal-state.js"
24-
import { type ActiveTerminalSession, buildProjectActiveTerminalSession, projectSshRoutePath } from "./terminal.js"
29+
import type { ActiveTerminalSession } from "./terminal.js"
2530

2631
export {
2732
readSshLinkRequestFromHref,
@@ -64,14 +69,53 @@ const clearPendingSshLink = (args: SshLinkEffectArgs, requestKey: string): void
6469
}
6570
}
6671

72+
const isPendingSshLink = (args: SshLinkEffectArgs, requestKey: string): boolean =>
73+
args.pendingTokenRef.current === requestKey
74+
6775
const markSshLinkHandled = (args: SshLinkEffectArgs, requestKey: string): void => {
68-
if (args.pendingTokenRef.current !== requestKey) {
76+
if (!isPendingSshLink(args, requestKey)) {
6977
return
7078
}
7179
args.pendingTokenRef.current = null
7280
args.handledTokenRef.current = requestKey
7381
}
7482

83+
const handleTerminalSessionAttachFailure = (
84+
args: SshLinkEffectArgs,
85+
requestKey: string,
86+
sessionId: string,
87+
error: string
88+
): void => {
89+
if (!isPendingSshLink(args, requestKey)) {
90+
return
91+
}
92+
const fallbackPath = resolveMissingSshSessionFallbackPath(globalThis.location.href, sessionId, error)
93+
if (fallbackPath !== null) {
94+
clearPendingSshLink(args, requestKey)
95+
args.handledTokenRef.current = null
96+
args.deactivateTerminalWorkspace()
97+
args.actionContext.setSelectedMenuIndex(browserMenuIndex("Select"))
98+
args.actionContext.setActiveScreen(projectPickerScreen())
99+
globalThis.history.replaceState(globalThis.history.state, "", fallbackPath)
100+
args.actionContext.setMessage(`SSH terminal is no longer available: ${sessionId}.`)
101+
return
102+
}
103+
clearPendingSshLink(args, requestKey)
104+
args.actionContext.setMessage(error)
105+
}
106+
107+
const handleTerminalSessionAttachSuccess = (
108+
args: SshLinkEffectArgs,
109+
requestKey: string,
110+
{ projectDisplayName, projectKey, session }: LoadedSshSessionLink
111+
): void => {
112+
if (!isPendingSshLink(args, requestKey)) {
113+
return
114+
}
115+
markSshLinkHandled(args, requestKey)
116+
attachLoadedSshSessionLink(args, { projectDisplayName, projectKey, session })
117+
}
118+
75119
const scheduleTerminalSessionAttach = (
76120
args: SshLinkEffectArgs,
77121
requestKey: string,
@@ -84,37 +128,10 @@ const scheduleTerminalSessionAttach = (
84128
loadTerminalSessionById(sessionId).pipe(
85129
Effect.match({
86130
onFailure: (error) => {
87-
const fallbackPath = resolveMissingSshSessionFallbackPath(globalThis.location.href, sessionId, error)
88-
if (fallbackPath !== null) {
89-
clearPendingSshLink(args, requestKey)
90-
args.handledTokenRef.current = null
91-
args.deactivateTerminalWorkspace()
92-
args.actionContext.setSelectedMenuIndex(browserMenuIndex("Select"))
93-
args.actionContext.setActiveScreen(projectPickerScreen())
94-
globalThis.history.replaceState(globalThis.history.state, "", fallbackPath)
95-
args.actionContext.setMessage(`SSH terminal is no longer available: ${sessionId}.`)
96-
return
97-
}
98-
clearPendingSshLink(args, requestKey)
99-
args.actionContext.setMessage(error)
131+
handleTerminalSessionAttachFailure(args, requestKey, sessionId, error)
100132
},
101-
onSuccess: ({ projectDisplayName, projectKey, session }) => {
102-
markSshLinkHandled(args, requestKey)
103-
globalThis.history.replaceState(
104-
globalThis.history.state,
105-
"",
106-
projectSshRoutePath(projectKey, session.id)
107-
)
108-
showProjectTerminalScreen(args.actionContext, session.projectId)
109-
args.addTerminalSession(buildProjectActiveTerminalSession({
110-
onExit: args.actionContext.reloadDashboard,
111-
onReady: args.actionContext.reloadDashboard,
112-
projectDisplayName,
113-
projectId: session.projectId,
114-
projectKey,
115-
session
116-
}))
117-
args.actionContext.setMessage(`Attached SSH terminal for ${projectDisplayName}.`)
133+
onSuccess: (link) => {
134+
handleTerminalSessionAttachSuccess(args, requestKey, link)
118135
}
119136
})
120137
)
@@ -157,10 +174,16 @@ const scheduleProjectTerminalAttach = (
157174
loadProjectTerminalWorkspace(project.projectKey).pipe(
158175
Effect.match({
159176
onFailure: (error) => {
177+
if (!isPendingSshLink(args, requestKey)) {
178+
return
179+
}
160180
clearPendingSshLink(args, requestKey)
161181
args.actionContext.setMessage(error)
162182
},
163183
onSuccess: ({ activeSessionId, sessions }) => {
184+
if (!isPendingSshLink(args, requestKey)) {
185+
return
186+
}
164187
const selectedSession = selectWorkspaceTerminalSession(sessions, activeSessionId, request.terminalId)
165188
if (selectedSession === null) {
166189
if (request.terminalId !== undefined) {

packages/app/src/web/app-ready-ssh-link-terminal.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { TerminalSession } from "./api-types.js"
33
import type { DashboardProject } from "./app-ready-ssh-link-core.js"
44
import { browserMenuIndex } from "./menu.js"
55
import { projectPickerScreen } from "./screen.js"
6-
import { type ActiveTerminalSession, buildProjectActiveTerminalSession } from "./terminal.js"
6+
import { type ActiveTerminalSession, buildProjectActiveTerminalSession, projectSshRoutePath } from "./terminal.js"
77

88
type ProjectTerminalAttachArgs = {
99
readonly actionContext: Pick<
@@ -14,6 +14,44 @@ type ProjectTerminalAttachArgs = {
1414
readonly selectTerminalSession: (sessionId: string) => void
1515
}
1616

17+
export type LoadedSshSessionLink = {
18+
readonly projectDisplayName: string
19+
readonly projectKey: string
20+
readonly session: TerminalSession
21+
}
22+
23+
type LoadedSshSessionAttachArgs = {
24+
readonly actionContext: Pick<
25+
BrowserActionContext,
26+
"reloadDashboard" | "setActiveScreen" | "setMessage" | "setSelectedMenuIndex" | "setSelectedProjectId"
27+
>
28+
readonly addTerminalSession: (session: ActiveTerminalSession) => void
29+
}
30+
31+
/**
32+
* Opens the project terminal screen for a selected project.
33+
*
34+
* @param actionContext - Browser screen/menu setters.
35+
* @param projectId - Project id to select.
36+
* @returns Nothing; state is written through actionContext.
37+
* @pure false
38+
* @effect BrowserActionContext screen/menu selection setters.
39+
* @invariant selected menu is Select and selected project id equals projectId.
40+
* @precondition actionContext is live and projectId is non-empty.
41+
* @postcondition project picker screen is active for projectId.
42+
* @complexity O(1)
43+
* @throws Never
44+
*/
45+
// CHANGE: keep SSH link screen selection in a focused shell helper
46+
// WHY: async link handlers need one shared mutation path for project terminal focus
47+
// QUOTE(ТЗ): n/a
48+
// REF: PR #342 CodeRabbit review
49+
// SOURCE: n/a
50+
// FORMAT THEOREM: focus(projectId) -> selectedProjectId = projectId
51+
// PURITY: SHELL
52+
// EFFECT: BrowserActionContext -> setSelectedMenuIndex, setActiveScreen, setSelectedProjectId
53+
// INVARIANT: menu Select and project picker screen are selected together
54+
// COMPLEXITY: O(1)
1755
export const showProjectTerminalScreen = (
1856
actionContext: ProjectTerminalAttachArgs["actionContext"],
1957
projectId: string
@@ -23,6 +61,72 @@ export const showProjectTerminalScreen = (
2361
actionContext.setSelectedProjectId(projectId)
2462
}
2563

64+
/**
65+
* Attaches a loaded legacy SSH session link to browser terminal state.
66+
*
67+
* @param args - Browser action sinks used for route, terminal, and message updates.
68+
* @param link - Loaded terminal session plus project display metadata.
69+
* @returns Nothing; route and terminal state are written through shell dependencies.
70+
* @pure false
71+
* @effect globalThis.history plus BrowserActionContext setters and addTerminalSession.
72+
* @invariant The browser route points to the attached session id.
73+
* @precondition link.session belongs to link.projectKey.
74+
* @postcondition The project terminal screen is focused and the loaded session is added.
75+
* @complexity O(1)
76+
* @throws Never
77+
*/
78+
// CHANGE: isolate loaded legacy session attach side effects
79+
// WHY: stale-link guards in the hook should precede one compact shell transition
80+
// QUOTE(ТЗ): n/a
81+
// REF: PR #342 CodeRabbit review
82+
// SOURCE: n/a
83+
// FORMAT THEOREM: attach(link) -> route = projectSshRoutePath(link.projectKey, link.session.id)
84+
// PURITY: SHELL
85+
// EFFECT: History, BrowserActionContext, addTerminalSession
86+
// INVARIANT: added terminal session is built from the loaded backend session
87+
// COMPLEXITY: O(1)
88+
export const attachLoadedSshSessionLink = (
89+
args: LoadedSshSessionAttachArgs,
90+
{ projectDisplayName, projectKey, session }: LoadedSshSessionLink
91+
): void => {
92+
globalThis.history.replaceState(globalThis.history.state, "", projectSshRoutePath(projectKey, session.id))
93+
showProjectTerminalScreen(args.actionContext, session.projectId)
94+
args.addTerminalSession(buildProjectActiveTerminalSession({
95+
onExit: args.actionContext.reloadDashboard,
96+
onReady: args.actionContext.reloadDashboard,
97+
projectDisplayName,
98+
projectId: session.projectId,
99+
projectKey,
100+
session
101+
}))
102+
args.actionContext.setMessage(`Attached SSH terminal for ${projectDisplayName}.`)
103+
}
104+
105+
/**
106+
* Builds an active terminal session from a backend terminal session.
107+
*
108+
* @param args - Browser callbacks used by the terminal session lifecycle.
109+
* @param project - Dashboard project owning the session.
110+
* @param session - Backend terminal session to wrap.
111+
* @returns Active terminal session ready for the browser tab list.
112+
* @pure true
113+
* @effect n/a
114+
* @invariant session.projectId is preserved in the ActiveTerminalSession.
115+
* @precondition project and session describe the same project.
116+
* @postcondition onExit and onReady both reload the dashboard.
117+
* @complexity O(1)
118+
* @throws Never
119+
*/
120+
// CHANGE: isolate ActiveTerminalSession construction for SSH-link workspace attach
121+
// WHY: route attach and workspace attach must use identical terminal metadata
122+
// QUOTE(ТЗ): n/a
123+
// REF: PR #342 CodeRabbit review
124+
// SOURCE: n/a
125+
// FORMAT THEOREM: build(project, session).session = session
126+
// PURITY: CORE
127+
// EFFECT: n/a
128+
// INVARIANT: project display/key/id are copied without mutation
129+
// COMPLEXITY: O(1)
26130
const buildProjectTerminalSession = (
27131
args: ProjectTerminalAttachArgs,
28132
project: DashboardProject,
@@ -37,6 +141,32 @@ const buildProjectTerminalSession = (
37141
session
38142
})
39143

144+
/**
145+
* Adds workspace terminal sessions in created-at order and selects the requested one.
146+
*
147+
* @param args - Browser terminal-session sinks.
148+
* @param project - Dashboard project owning the sessions.
149+
* @param sessions - Workspace terminal sessions returned by the backend.
150+
* @param selectedSession - Session that must become active after attach.
151+
* @returns Nothing; terminal tabs and selection are written through args.
152+
* @pure false
153+
* @effect args.addTerminalSession and args.selectTerminalSession.
154+
* @invariant selectedSession is added after all other sessions.
155+
* @precondition selectedSession belongs to sessions.
156+
* @postcondition args.selectTerminalSession is called with selectedSession.id.
157+
* @complexity O(n log n) time and O(n) space where n = sessions.length.
158+
* @throws Never
159+
*/
160+
// CHANGE: preserve deterministic workspace session attach ordering
161+
// WHY: opening selected session last keeps browser tab ordering stable while selecting the intended terminal
162+
// QUOTE(ТЗ): n/a
163+
// REF: PR #342 CodeRabbit review
164+
// SOURCE: n/a
165+
// FORMAT THEOREM: attach(sessions, selected) -> selectedSession.id is selected
166+
// PURITY: SHELL
167+
// EFFECT: ProjectTerminalAttachArgs -> addTerminalSession*, selectTerminalSession
168+
// INVARIANT: non-selected sessions are added by ascending createdAt before selectedSession
169+
// COMPLEXITY: O(n log n) time and O(n) space
40170
export const attachProjectWorkspaceSessions = (
41171
args: ProjectTerminalAttachArgs,
42172
project: DashboardProject,

0 commit comments

Comments
 (0)