Skip to content

Commit caa9dba

Browse files
authored
[codex] keep Create settings visible (#316)
* fix(app): clarify create settings flow * fix(web): keep create settings visible * fix(web): require create repo URL before submit * fix(app): restore checks after main merge * test(app): split create flow coverage * test(app): deduplicate create flow tests * fix(web): preserve select ready urls * fix(app): restore ci lint and grok auth * fix(app): deduplicate create flow source * fix(app): address create flow review feedback * fix(app): align create boolean labels * test(app): deduplicate create navigation assertions * fix(app): align create display step mapping * fix(app): use mode-aware create test helpers * chore: retrigger ci * test(app): tighten create settings assertions * test(app): deduplicate empty create validation checks
1 parent 7b45d70 commit caa9dba

24 files changed

Lines changed: 2361 additions & 311 deletions

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

Lines changed: 504 additions & 49 deletions
Large diffs are not rendered by default.

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
advanceCreateFlow,
1212
createInitialFlowView,
1313
handleAdvanceCreateFlowResult,
14+
moveCreateSettingsStep,
1415
resolveCreateInputs
1516
} from "./menu-create-shared.js"
1617
import { resetToMenu } from "./menu-shared.js"
@@ -156,7 +157,9 @@ const handleCreateReturn = (
156157
})
157158
},
158159
onContinue: (view) => {
159-
context.setView({ _tag: "Create", ...view })
160+
if (view.mode === "create") {
161+
context.setView({ _tag: "Create", ...view })
162+
}
160163
context.setMessage(null)
161164
},
162165
onError: (error) => {
@@ -178,6 +181,8 @@ export const handleCreateInput = (
178181
input: string,
179182
key: {
180183
readonly escape?: boolean
184+
readonly upArrow?: boolean
185+
readonly downArrow?: boolean
181186
readonly return?: boolean
182187
readonly shift?: boolean
183188
readonly backspace?: boolean
@@ -190,6 +195,14 @@ export const handleCreateInput = (
190195
resetToMenu(context)
191196
return
192197
}
198+
if (key.upArrow || key.downArrow) {
199+
const nextView = moveCreateSettingsStep(view, key.upArrow ? "up" : "down")
200+
if (nextView !== null) {
201+
context.setView({ _tag: "Create", ...nextView })
202+
context.setMessage(null)
203+
}
204+
return
205+
}
193206
if (key.return) {
194207
handleCreateReturn({ ...context, view }, key.shift === true)
195208
return

packages/app/src/docker-git/menu-render.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react"
22

33
import { Box, Text } from "../ui/primitives.js"
4-
import { renderCreateStepLabel } from "./menu-create-shared.js"
4+
import { createSettingsHint, renderCreateStepLabel } from "./menu-create-shared.js"
55
import { renderLayout } from "./menu-render-layout.js"
66
import {
77
buildSelectLabels,
@@ -120,9 +120,7 @@ export const renderMenu = (input: MenuRenderInput): React.ReactElement => {
120120
export const renderCreate = (input: CreateRenderInput): React.ReactElement => {
121121
const { buffer, defaults, label, message, stepIndex, steps } = input
122122
const el = React.createElement
123-
const hint = stepIndex === 0
124-
? "Enter = next, Shift+Enter = quick create, Esc = cancel."
125-
: "Enter = next, Esc = cancel."
123+
const hint = stepIndex > 0 ? createSettingsHint : null
126124
const stepViews = steps.map((step, index) =>
127125
el(
128126
Text,
@@ -132,16 +130,18 @@ export const renderCreate = (input: CreateRenderInput): React.ReactElement => {
132130
)
133131
return renderLayout(
134132
"docker-git / Create",
135-
[
133+
compactElements([
136134
el(Box, { flexDirection: "column", marginTop: 1 }, ...stepViews),
137135
el(
138136
Box,
139137
{ marginTop: 1 },
140138
el(Text, null, `${label}: `),
141139
el(Text, { fg: "green" }, buffer)
142140
),
143-
el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, hint))
144-
],
141+
hint === null
142+
? null
143+
: el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, hint))
144+
]),
145145
message
146146
)
147147
}

packages/app/src/docker-git/menu-types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,14 @@ export interface ProjectAuthSnapshot {
142142

143143
export type ViewState =
144144
| { readonly _tag: "Menu" }
145-
| { readonly _tag: "Create"; readonly step: number; readonly buffer: string; readonly values: Partial<CreateInputs> }
145+
| {
146+
readonly _tag: "Create"
147+
readonly mode: "create"
148+
readonly step: number
149+
readonly buffer: string
150+
readonly inputError: string | null
151+
readonly values: Partial<CreateInputs>
152+
}
146153
| { readonly _tag: "AuthMenu"; readonly selected: number; readonly snapshot: AuthSnapshot }
147154
| {
148155
readonly _tag: "AuthPrompt"

packages/app/src/docker-git/program-auth.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ import {
2020
} from "./api-client.js"
2121
import { type ControllerRuntime, ensureControllerReady } from "./controller.js"
2222
import type { Command } from "./frontend-lib/core/domain.js"
23-
import type { ApiRequestError, CliError, ControllerBootstrapError } from "./host-errors.js"
23+
import type { ApiRequestError, CliError } from "./host-errors.js"
2424
import { terminalAuthTitle } from "./menu-auth-shared.js"
25-
import { attachTerminalSession, type TerminalSessionClientError } from "./terminal-session-client.js"
25+
import { attachTerminalSession } from "./terminal-session-client.js"
2626

2727
type OperationalCommand = Exclude<Command, { readonly _tag: "Help" }>
28+
type RoutedAuthEffect = Effect.Effect<void, CliError, ControllerRuntime>
2829

2930
export type RoutedAuthCommand = Extract<
3031
OperationalCommand,
@@ -46,10 +47,9 @@ export type RoutedAuthCommand = Extract<
4647
}
4748
>
4849

49-
const withControllerReady = <E extends CliError, R>(
50-
effect: Effect.Effect<void, E, R>
51-
): Effect.Effect<void, E | ControllerBootstrapError, R | ControllerRuntime> =>
52-
pipe(ensureControllerReady(), Effect.zipRight(effect))
50+
const withControllerReady = <R>(
51+
effect: Effect.Effect<void, CliError, R>
52+
): Effect.Effect<void, CliError, ControllerRuntime | R> => pipe(ensureControllerReady(), Effect.zipRight(effect))
5353

5454
const renderAuthPayload = (payload: JsonValue) => Effect.log(renderJsonPayload(payload))
5555

@@ -60,17 +60,6 @@ const missingAuthTerminalSessionError = (provider: "GrokOauth"): ApiRequestError
6060
message: `Controller did not create a terminal session for ${provider}.`
6161
})
6262

63-
const attachGrokTerminalSession = (
64-
session: ApiTerminalSession | null
65-
): Effect.Effect<void, ApiRequestError | TerminalSessionClientError> =>
66-
session === null
67-
? Effect.fail(missingAuthTerminalSessionError("GrokOauth"))
68-
: attachTerminalSession({
69-
header: terminalAuthTitle("GrokOauth"),
70-
session,
71-
websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws`
72-
})
73-
7463
const routedAuthTags: Readonly<Record<string, true>> = {
7564
AuthCodexImport: true,
7665
AuthCodexLogin: true,
@@ -120,12 +109,34 @@ const handleCodexLoginCommand = (
120109
command: Extract<OperationalCommand, { readonly _tag: "AuthCodexLogin" }>
121110
) => withControllerReady(codexLogin(command))
122111

112+
/**
113+
* Attaches the Grok OAuth terminal session created by the controller.
114+
*
115+
* @pure false
116+
* @effect terminal websocket attachment through `attachTerminalSession`
117+
* @invariant null controller sessions fail with a typed ApiRequestError
118+
* @precondition controller response has already been decoded as ApiTerminalSession | null
119+
* @postcondition non-null sessions are attached through the auth terminal websocket path
120+
* @complexity O(1) before terminal IO
121+
* @throws Never; errors are represented in the Effect error channel as CliError
122+
*/
123+
const attachGrokAuthTerminalSession = (
124+
session: ApiTerminalSession | null
125+
): Effect.Effect<void, CliError> =>
126+
session === null
127+
? Effect.fail(missingAuthTerminalSessionError("GrokOauth"))
128+
: attachTerminalSession({
129+
header: terminalAuthTitle("GrokOauth"),
130+
session,
131+
websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws`
132+
})
133+
123134
const handleGrokLoginCommand = (
124135
command: Extract<OperationalCommand, { readonly _tag: "AuthGrokLogin" }>
125136
) =>
126137
withControllerReady(
127138
createAuthTerminalSession("GrokOauth", command.label).pipe(
128-
Effect.flatMap((session) => attachGrokTerminalSession(session))
139+
Effect.flatMap((session) => attachGrokAuthTerminalSession(session))
129140
)
130141
)
131142

@@ -157,7 +168,7 @@ const handleCodexLogoutCommand = (
157168

158169
export const dispatchRoutedAuthCommand = (
159170
command: RoutedAuthCommand
160-
): Effect.Effect<void, CliError, ControllerRuntime> =>
171+
): RoutedAuthEffect =>
161172
Match.value(command).pipe(
162173
Match.when({ _tag: "AuthGithubLogin" }, handleGithubLoginCommand),
163174
Match.when({ _tag: "AuthGithubStatus" }, handleGithubStatusCommand),

packages/app/src/ui/primitives-web.tsx

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createElement, type CSSProperties, type JSX } from "react"
1+
import { createElement, type CSSProperties, type JSX, type KeyboardEvent } from "react"
22

33
import type { UiBoxProps, UiButtonProps, UiTextInputProps, UiTextProps } from "./primitives.js"
44

@@ -113,8 +113,67 @@ export const webPrimitives = {
113113
props.multiline === true ? <MultilineTextInput {...props} /> : <SingleLineTextInput {...props} />
114114
} as const
115115

116+
const horizontalArrowAction = (
117+
key: string,
118+
onArrowLeft: (() => void) | undefined,
119+
onArrowRight: (() => void) | undefined
120+
): (() => void) | null => {
121+
if (key === "ArrowLeft") {
122+
return onArrowLeft ?? null
123+
}
124+
if (key === "ArrowRight") {
125+
return onArrowRight ?? null
126+
}
127+
return null
128+
}
129+
130+
type TextInputKeyboardHandlers = {
131+
readonly onArrowLeft: (() => void) | undefined
132+
readonly onArrowRight: (() => void) | undefined
133+
readonly onEnter: ((shift: boolean) => void) | undefined
134+
readonly onEscape: (() => void) | undefined
135+
}
136+
137+
const stopTextInputKey = (
138+
event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
139+
): void => {
140+
event.preventDefault()
141+
event.stopPropagation()
142+
}
143+
144+
const handleMultilineTextInputKeyDown =
145+
({ onArrowLeft, onArrowRight, onEnter, onEscape }: TextInputKeyboardHandlers) =>
146+
(event: KeyboardEvent<HTMLTextAreaElement>): void => {
147+
const onArrow = horizontalArrowAction(event.key, onArrowLeft, onArrowRight)
148+
if (onArrow !== null) {
149+
stopTextInputKey(event)
150+
onArrow()
151+
return
152+
}
153+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
154+
stopTextInputKey(event)
155+
onEnter?.(event.shiftKey)
156+
return
157+
}
158+
if (event.key === "Escape") {
159+
stopTextInputKey(event)
160+
onEscape?.()
161+
}
162+
}
163+
116164
const MultilineTextInput = (
117-
{ ariaLabel, autoFocus, minRows, onChange, onEnter, onEscape, placeholder, value }: UiTextInputProps
165+
{
166+
ariaLabel,
167+
autoFocus,
168+
minRows,
169+
onArrowLeft,
170+
onArrowRight,
171+
onChange,
172+
onEnter,
173+
onEscape,
174+
placeholder,
175+
value
176+
}: UiTextInputProps
118177
): JSX.Element => {
119178
const rows = minRows ?? 6
120179
return (
@@ -124,19 +183,7 @@ const MultilineTextInput = (
124183
onChange={(event) => {
125184
onChange(event.currentTarget.value)
126185
}}
127-
onKeyDown={(event) => {
128-
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
129-
event.preventDefault()
130-
event.stopPropagation()
131-
onEnter?.(event.shiftKey)
132-
return
133-
}
134-
if (event.key === "Escape") {
135-
event.preventDefault()
136-
event.stopPropagation()
137-
onEscape?.()
138-
}
139-
}}
186+
onKeyDown={handleMultilineTextInputKeyDown({ onArrowLeft, onArrowRight, onEnter, onEscape })}
140187
placeholder={placeholder}
141188
rows={rows}
142189
style={{
@@ -152,7 +199,18 @@ const MultilineTextInput = (
152199
}
153200

154201
const SingleLineTextInput = (
155-
{ ariaLabel, autoFocus, onChange, onEnter, onEscape, placeholder, secret, value }: UiTextInputProps
202+
{
203+
ariaLabel,
204+
autoFocus,
205+
onArrowLeft,
206+
onArrowRight,
207+
onChange,
208+
onEnter,
209+
onEscape,
210+
placeholder,
211+
secret,
212+
value
213+
}: UiTextInputProps
156214
): JSX.Element => (
157215
<input
158216
aria-label={ariaLabel}
@@ -161,6 +219,13 @@ const SingleLineTextInput = (
161219
onChange(event.currentTarget.value)
162220
}}
163221
onKeyDown={(event) => {
222+
const onArrow = horizontalArrowAction(event.key, onArrowLeft, onArrowRight)
223+
if (onArrow !== null) {
224+
event.preventDefault()
225+
event.stopPropagation()
226+
onArrow()
227+
return
228+
}
164229
if (event.key === "Enter") {
165230
event.preventDefault()
166231
event.stopPropagation()

packages/app/src/ui/primitives.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export type UiTextInputProps = {
5656
readonly minRows?: number
5757
readonly multiline?: boolean
5858
readonly onChange: (value: string) => void
59+
readonly onArrowLeft?: () => void
60+
readonly onArrowRight?: () => void
5961
readonly onEnter?: (shift: boolean) => void
6062
readonly onEscape?: () => void
6163
readonly placeholder?: string

packages/app/src/web/actions-projects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ export const runProjectMenuAction = (
448448
context: BrowserActionContext
449449
) => {
450450
if (currentMenu === "Create") {
451-
context.setMessage("Create mode is active. Paste URL or URL + flags, Enter = next, Shift+Enter = quick create.")
451+
context.setMessage("Create mode is active. Paste URL or URL + flags, then choose Quick Create or Settings.")
452452
return
453453
}
454454
if (currentMenu === "Select") {

packages/app/src/web/app-ready-controller.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ import { resolveCurrentMenu, runAuthActionByIndex, runProjectAuthActionByIndex }
1919
import { useProjectBrowserReset, useTerminalBrowserAutoload } from "./app-ready-browser-hook.js"
2020
import { useBrowserShortcuts } from "./app-ready-browser-shortcuts-hook.js"
2121
import { createReadyActionContext } from "./app-ready-controller-context.js"
22-
import { cancelCreate, setCreateBuffer, submitCreateView, useCreateMenuReset } from "./app-ready-create.js"
22+
import {
23+
cancelCreate,
24+
type CreateSubmitMode,
25+
setCreateBuffer,
26+
submitCreateView,
27+
useCreateMenuReset
28+
} from "./app-ready-create.js"
2329
import { bindDatabaseActions } from "./app-ready-database-actions.js"
2430
import { useProjectDatabasesReset } from "./app-ready-databases-hook.js"
2531
import { useGithubAuthGate } from "./app-ready-github-auth-gate-hook.js"
@@ -198,13 +204,13 @@ const bindCreateActions = (
198204
onCreateCancel: () => {
199205
cancelCreate(actionContext, state.setCreateView)
200206
},
201-
onCreateSubmit: (quickCreate = false) => {
207+
onCreateSubmit: (mode: CreateSubmitMode) => {
202208
submitCreateView({
203209
context: actionContext,
204210
controllerCwd: dashboard.health.cwd,
205211
projectsRoot: dashboard.health.projectsRoot,
206212
createView: state.createView,
207-
quickCreate,
213+
mode,
208214
setCreateView: state.setCreateView
209215
})
210216
}

0 commit comments

Comments
 (0)