From ce698833aa265f19b4dd4c8b249a67d200c61d4f Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 28 Aug 2026 11:54:57 -0400 Subject: [PATCH 1/2] Launcher custom environment: repositories, compute, image, hooks, mcp servers Extends the launcher's custom environment section beyond variables so a run can be configured without editing an environment YAML. --- src/lib/sessions.ts | 311 +++++++++++++++++++++++- src/ui/SessionsApp.tsx | 537 ++++++++++++++++++++++++++++++++++++++++- test/sessions.test.ts | 408 ++++++++++++++++++++++++++++++- 3 files changed, 1224 insertions(+), 32 deletions(-) diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 8ce46fe..508c116 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -407,8 +407,114 @@ export function composerPickerRows( } export const CUSTOM_ENVIRONMENT_DIVIDER = 'custom environment' +export const REPOSITORIES_HEADING = 'repositories' export const VARIABLES_HEADING = 'variables' -export const ADD_VARIABLE_LABEL = '+ new variable' +export const ADD_VARIABLE_LABEL = '+ new' +export const COMPUTE_HEADING = 'compute' +export const IMAGE_HEADING = 'image' +export const HOOKS_HEADING = 'hooks' +export const MCP_SERVERS_HEADING = 'mcp servers' +export const ADD_MCP_SERVER_LABEL = '+ new' + +// The compute fields, in the order their rows appear. Each is an inline text +// input like a repo's branch row; blank = whatever the server resolves. +export const COMPUTE_FIELDS = ['cpu', 'memory', 'timeout'] as const +export type ComputeField = (typeof COMPUTE_FIELDS)[number] + +// What the custom section's compute rows hold, as typed. Strings even for cpu: +// these are input fields, and the override conversion is where parsing lives. +export type CustomCompute = Readonly> + +export const EMPTY_COMPUTE: CustomCompute = { cpu: '', memory: '', timeout: '' } + +// The image customization fields: `dockerfile_append` layers onto the image +// before any repo exists, `setup` runs at build time after checkout and is +// captured by the cached snapshot. One-line inputs here — a longer script +// belongs in an environment YAML. +export const IMAGE_FIELDS = ['dockerfile_append', 'setup'] as const +export type ImageField = (typeof IMAGE_FIELDS)[number] + +export type CustomImage = Readonly> + +export const EMPTY_IMAGE: CustomImage = { dockerfile_append: '', setup: '' } + +// The lifecycle hooks: `post_start` runs after the container starts (before +// any repo is cloned), `post_clone` after checkout, before the agent. Per-run +// scripts, never cached — same one-line inputs as image. +export const HOOK_FIELDS = ['post_start', 'post_clone'] as const +export type HookField = (typeof HOOK_FIELDS)[number] + +export type CustomHooks = Readonly> + +export const EMPTY_HOOKS: CustomHooks = { post_start: '', post_clone: '' } + +// The platform's built-in MCP servers an agent opts into by name, shown as +// checkboxes when the matching integration is connected (an unconnected one +// has no server to opt into). +export function builtInMcpServers(integrations: { + linear?: unknown + slack?: unknown +}): string[] { + return [ + ...(integrations.linear ? ['linear'] : []), + ...(integrations.slack ? ['slack'] : []), + ] +} + +// An MCP server added in the launcher's custom section. A built-in checkbox is +// a name alone; the "+ new server" form fills name plus exactly one of +// command (stdio — the harness spawns it in the sandbox) or url (remote). +// args/env/headers stay YAML-only: past a command line, define the server in +// an environment file. +export interface CustomMcpServer { + name: string + command: string | null + url: string | null +} + +// A custom server in the shape the mcp_servers override takes: `{name}` opts +// into a built-in; a command line splits into command + args (the schema's +// stdio shape); a url is the remote shape. +export function mcpServerEntry(s: CustomMcpServer): Record { + if (s.command) { + const [command, ...args] = s.command.split(/\s+/) + return args.length > 0 ? { name: s.name, command, args } : { name: s.name, command } + } + if (s.url) return { name: s.name, url: s.url } + return { name: s.name } +} + +// The form's commit check: a name is required, and command/url pick the server +// type so both at once is ambiguous. Both empty is fine — a bare name opts +// into a built-in. +export function validateMcpServer(s: CustomMcpServer): string | null { + if (!s.name.trim()) return 'name a server' + if (s.command && s.url) return 'fill command or url, not both' + return null +} + +// The set fields of a string-record section, for a merging object override. +export function fieldsOverride(fields: Readonly>): Record { + const out: Record = {} + for (const [key, value] of Object.entries(fields)) { + if (value.trim() !== '') out[key] = value.trim() + } + return out +} + +// The compute override from the typed fields: only the set ones, since an +// override's nested objects merge key by key (unlike its arrays). cpu parses +// to the number the schema wants; unparseable cpu is dropped rather than sent +// as a string the server would 400 on (the input row only admits digits, so +// this is belt and braces). +export function computeOverride(compute: CustomCompute): Record { + const out: Record = {} + const cpu = compute.cpu.trim() + if (cpu !== '' && !Number.isNaN(Number(cpu))) out.cpu = Number(cpu) + if (compute.memory.trim() !== '') out.memory = compute.memory.trim() + if (compute.timeout.trim() !== '') out.timeout = compute.timeout.trim() + return out +} // The built-in "no environment at all" option's id. A sentinel, never sent: the // launcher translates it into the cleared-list override, since the wire has no @@ -418,7 +524,8 @@ export const EMPTY_ENVIRONMENT_LABEL = '[empty]' // The open Environment list, top to bottom: the saved environments and // "[empty]" as options, a divider, then the custom section — one checkbox per -// stored secret, then the variables typed here, then the add button. +// connected repository, one per stored secret, then the variables typed here, +// then the add button. // // `hover` is the index ↑/↓ walks; rows without one are DECORATION the highlight // skips, which is what keeps this a plain list rather than a nested tree. @@ -426,24 +533,48 @@ export type EnvironmentPickerRow = | { kind: 'option'; at: number; hover: number } | { kind: 'divider'; label: string } | { kind: 'heading'; label: string } + | { kind: 'repo'; fullName: string; hover: number } + | { kind: 'repoRef'; fullName: string; hover: number } | { kind: 'secret'; name: string; hover: number } | { kind: 'variable'; name: string; hover: number } | { kind: 'addVariable'; hover: number } + | { kind: 'compute'; field: ComputeField; hover: number } + | { kind: 'image'; field: ImageField; hover: number } + | { kind: 'hook'; field: HookField; hover: number } + | { kind: 'mcpServer'; name: string; hover: number } + | { kind: 'addMcpServer'; hover: number } // What activating a hovered row does, without the renderer having to know the // list's shape. export type EnvironmentTarget = | { kind: 'option'; at: number } + | { kind: 'repo'; fullName: string } + | { kind: 'repoRef'; fullName: string } | { kind: 'secret'; name: string } | { kind: 'variable'; name: string } | { kind: 'addVariable' } + | { kind: 'compute'; field: ComputeField } + | { kind: 'image'; field: ImageField } + | { kind: 'hook'; field: HookField } + | { kind: 'mcpServer'; name: string } + | { kind: 'addMcpServer' } export interface EnvironmentPickerInput { optionCount: number + // The account's connected repositories ("owner/name"), checkable in the + // custom section; each clones at its default branch unless a ref is typed. + repoNames: readonly string[] + // Which of those are in the run. A checked repo grows a `branch:` input row + // under it, where ↓ lands and typing sets the ref (blank = default branch). + checkedRepoNames: readonly string[] // The account's stored secret names (values are write-only, so checking one // adds a variable with no value and the sandbox resolves it at start). secretNames: readonly string[] customVariables: readonly CustomVariable[] + // The built-in MCP servers connected on this account (checkboxes), and the + // servers defined here whose names aren't among them. + builtInMcpServers: readonly string[] + customMcpServers: readonly CustomMcpServer[] } export function environmentPickerRows(input: EnvironmentPickerInput): EnvironmentPickerRow[] { @@ -451,6 +582,26 @@ export function environmentPickerRows(input: EnvironmentPickerInput): Environmen let hover = 0 for (let at = 0; at < input.optionCount; at++) rows.push({ kind: 'option', at, hover: hover++ }) rows.push({ kind: 'divider', label: CUSTOM_ENVIRONMENT_DIVIDER }) + // No REPOSITORIES heading while there are no rows to sit under it — the repo + // list is empty until the fetch lands (or when the account has none). + if (input.repoNames.length > 0) { + rows.push({ kind: 'heading', label: REPOSITORIES_HEADING }) + for (const fullName of input.repoNames) { + rows.push({ kind: 'repo', fullName, hover: hover++ }) + if (input.checkedRepoNames.includes(fullName)) { + rows.push({ kind: 'repoRef', fullName, hover: hover++ }) + } + } + } + rows.push({ kind: 'heading', label: MCP_SERVERS_HEADING }) + for (const name of input.builtInMcpServers) + rows.push({ kind: 'mcpServer', name, hover: hover++ }) + // Like variables: a typed name that is also a built-in rides that row. + for (const s of input.customMcpServers) { + if (input.builtInMcpServers.includes(s.name)) continue + rows.push({ kind: 'mcpServer', name: s.name, hover: hover++ }) + } + rows.push({ kind: 'addMcpServer', hover: hover++ }) rows.push({ kind: 'heading', label: VARIABLES_HEADING }) for (const name of input.secretNames) rows.push({ kind: 'secret', name, hover: hover++ }) // A variable typed here whose name is also a secret rides that secret's row @@ -460,6 +611,12 @@ export function environmentPickerRows(input: EnvironmentPickerInput): Environmen rows.push({ kind: 'variable', name: v.name, hover: hover++ }) } rows.push({ kind: 'addVariable', hover: hover++ }) + rows.push({ kind: 'heading', label: IMAGE_HEADING }) + for (const field of IMAGE_FIELDS) rows.push({ kind: 'image', field, hover: hover++ }) + rows.push({ kind: 'heading', label: HOOKS_HEADING }) + for (const field of HOOK_FIELDS) rows.push({ kind: 'hook', field, hover: hover++ }) + rows.push({ kind: 'heading', label: COMPUTE_HEADING }) + for (const field of COMPUTE_FIELDS) rows.push({ kind: 'compute', field, hover: hover++ }) return rows } @@ -474,8 +631,15 @@ export function environmentPickerAt( ) const row = landable[Math.min(Math.max(0, hover), landable.length - 1)] if (row.kind === 'option') return { kind: 'option', at: row.at } + if (row.kind === 'repo') return { kind: 'repo', fullName: row.fullName } + if (row.kind === 'repoRef') return { kind: 'repoRef', fullName: row.fullName } if (row.kind === 'secret') return { kind: 'secret', name: row.name } if (row.kind === 'variable') return { kind: 'variable', name: row.name } + if (row.kind === 'compute') return { kind: 'compute', field: row.field } + if (row.kind === 'image') return { kind: 'image', field: row.field } + if (row.kind === 'hook') return { kind: 'hook', field: row.field } + if (row.kind === 'mcpServer') return { kind: 'mcpServer', name: row.name } + if (row.kind === 'addMcpServer') return { kind: 'addMcpServer' } return { kind: 'addVariable' } } @@ -488,10 +652,23 @@ export function environmentPickerCount(input: EnvironmentPickerInput): number { export function environmentRowSummary( label: string, customVariables: readonly CustomVariable[], + customRepositories: readonly CustomRepository[] = [], + customCompute: CustomCompute = EMPTY_COMPUTE, + customImage: CustomImage = EMPTY_IMAGE, + customHooks: CustomHooks = EMPTY_HOOKS, + customMcpServers: readonly CustomMcpServer[] = [], ): string { - if (customVariables.length === 0) return label - const n = customVariables.length - return `${label} +${n} variable${n === 1 ? '' : 's'}` + const extras: string[] = [] + const r = customRepositories.length + if (r > 0) extras.push(`+${r} repo${r === 1 ? '' : 's'}`) + const v = customVariables.length + if (v > 0) extras.push(`+${v} variable${v === 1 ? '' : 's'}`) + const m = customMcpServers.length + if (m > 0) extras.push(`+${m} mcp`) + if (Object.keys(computeOverride(customCompute)).length > 0) extras.push('+compute') + if (Object.keys(fieldsOverride(customImage)).length > 0) extras.push('+image') + if (Object.keys(fieldsOverride(customHooks)).length > 0) extras.push('+hooks') + return extras.length === 0 ? label : `${label} ${extras.join(' ')}` } // How a variable reads in the custom section: the name alone when the sandbox @@ -511,6 +688,23 @@ export interface CustomVariable { value: string | null } +// A repository the launcher's custom section adds on top of the picked +// environment, by full "owner/name". A null ref means the default branch, the +// same reading the environment YAML gives an entry with no `ref`. +export interface CustomRepository { + fullName: string + ref: string | null +} + +// How a checked repository's branch row reads: the typed ref, else the repo's +// default branch as the resting value. +export function repositoryRefLabel( + ref: string | null | undefined, + defaultBranch: string | null | undefined, +): string | null { + return ref ?? defaultBranch ?? null +} + // The composer's picks, as the new-session pane reports them. environment and // model null = that row was never touched, so the server resolves it (the // environment ladder, the account's default model). `emptyEnvironment` is the @@ -519,10 +713,21 @@ export interface ComposerChoices { environment: string | null model: string | null emptyEnvironment: boolean - // The picked environment's own variables, needed because an override array + // The picked environment's own lists, needed because an override array // REPLACES the resolved list rather than appending to it. baseVariables: readonly CustomVariable[] customVariables: readonly CustomVariable[] + baseRepositories: readonly CustomRepository[] + customRepositories: readonly CustomRepository[] + // The picked environment's own servers ride the override verbatim (they can + // be shapes the launcher can't build, e.g. stdio with env). + baseMcpServers: readonly unknown[] + customMcpServers: readonly CustomMcpServer[] + // No base counterparts: these are scalars in merging object overrides, so + // unset fields keep the resolved values. + customCompute: CustomCompute + customImage: CustomImage + customHooks: CustomHooks } // One variable list from the two that have to end up in the override, with a @@ -551,6 +756,37 @@ function variableEntry(v: CustomVariable): { name: string; value?: string } { return v.value === null ? { name: v.name } : { name: v.name, value: v.value } } +// One repository list from the two that have to end up in the override, with a +// later full name winning — same reading as mergeVariables. +export function mergeRepositories( + base: readonly CustomRepository[], + custom: readonly CustomRepository[], +): CustomRepository[] { + const merged: CustomRepository[] = [] + const at = new Map() + for (const r of [...base, ...custom]) { + const seen = at.get(r.fullName) + if (seen === undefined) { + at.set(r.fullName, merged.length) + merged.push(r) + } else merged[seen] = r + } + return merged +} + +// A repository in the shape an environment override takes: owner and name +// split, `ref` omitted (not null) for the default branch, since the config +// schema treats an absent ref as "the repo's default". +function repositoryEntry(r: CustomRepository): { owner?: string; name: string; ref?: string } { + const slash = r.fullName.indexOf('/') + const entry: { owner?: string; name: string; ref?: string } = + slash === -1 + ? { name: r.fullName } + : { owner: r.fullName.slice(0, slash), name: r.fullName.slice(slash + 1) } + if (r.ref !== null) entry.ref = r.ref + return entry +} + // The entry point's base request with the composer's picks layered on: the // environment as the session's own choice, the model and any custom // environment edits as a per-run override (the dashboard composer's shape). @@ -582,6 +818,35 @@ export function applyComposerChoices( if (choices.emptyEnvironment || choices.customVariables.length > 0) { environment.variables = variables.map(variableEntry) } + const repositories = mergeRepositories( + choices.emptyEnvironment ? [] : choices.baseRepositories, + choices.customRepositories, + ) + if (choices.emptyEnvironment || choices.customRepositories.length > 0) { + environment.repositories = repositories.map(repositoryEntry) + } + // MCP servers merge like the other lists: the base definitions verbatim, + // then the servers added here whose names the base doesn't already carry. + if (!choices.emptyEnvironment && choices.customMcpServers.length > 0) { + const baseNames = new Set( + choices.baseMcpServers.map((s) => + typeof s === 'string' ? s : ((s as { name?: string }).name ?? ''), + ), + ) + environment.mcp_servers = [ + ...choices.baseMcpServers, + ...choices.customMcpServers.filter((s) => !baseNames.has(s.name)).map(mcpServerEntry), + ] + } + if (choices.emptyEnvironment && choices.customMcpServers.length > 0) { + environment.mcp_servers = choices.customMcpServers.map(mcpServerEntry) + } + const compute = computeOverride(choices.customCompute) + if (Object.keys(compute).length > 0) environment.compute = compute + const image = fieldsOverride(choices.customImage) + if (Object.keys(image).length > 0) environment.image = image + const hooks = fieldsOverride(choices.customHooks) + if (Object.keys(hooks).length > 0) environment.hooks = hooks if (Object.keys(environment).length > 0) override.environment = environment if (Object.keys(override).length > 0) req.override = override return req @@ -621,11 +886,31 @@ export function environmentDefaultRungs( ] } +// Where a synced environment's definition lives, for its option row: +// "owner/name/path/to/file.yaml @ sha1234". Only what the API already gave us — +// no source_details (an API-managed environment) means null, and the repo id +// resolves to a name only if the connected-repos list holds it. +export function environmentSourceLabel( + e: { + source_details?: { repo_id: number; path: string } | null + last_synced_commit_sha?: string | null + }, + repoNamesById: ReadonlyMap, +): string | null { + const src = e.source_details + if (!src) return null + const repo = repoNamesById.get(src.repo_id) + if (!repo) return null + const sha = e.last_synced_commit_sha ? ` @ ${e.last_synced_commit_sha.slice(0, 7)}` : '' + return `${repo}/${src.path}${sha}` +} + // The Environment row's options: every saved environment, each labelled with -// the default rungs it holds, then the built-in "[empty]". `picked` is the row -// checked while the row is untouched — the environment the ladder resolves for -// the repo you are standing in, so the launcher can SEND what it shows instead -// of leaving the server to resolve something else. +// the default rungs it holds and the file it syncs from, then the built-in +// "[empty]". `picked` is the row checked while the row is untouched — the +// environment the ladder resolves for the repo you are standing in, so the +// launcher can SEND what it shows instead of leaving the server to resolve +// something else. // // A "Default" row appears only when no rung resolves at all: then there is no // name to show and the server's own resolution is the honest answer. @@ -633,13 +918,15 @@ export function environmentOptions( environments: readonly { id: string; name: string }[], ladder: EnvironmentDefaults | null, detectedRepo: string | null, + sourceLabels: ReadonlyMap = new Map(), ): { options: { id: string | null; label: string }[]; picked: number } { const resolved = ladder ? effectiveEnvironmentDefault(ladder, detectedRepo)?.id : undefined const listed = environments.map((e) => { - const rungs = environmentDefaultRungs(ladder, e.id) + const notes = [...(sourceLabels.has(e.id) ? [sourceLabels.get(e.id) as string] : []), + ...environmentDefaultRungs(ladder, e.id)] return { id: e.id as string | null, - label: rungs.length > 0 ? `${e.name} (${rungs.join(', ')})` : e.name, + label: notes.length > 0 ? `${e.name} (${notes.join(', ')})` : e.name, } }) const empty = { id: EMPTY_ENVIRONMENT_ID as string | null, label: EMPTY_ENVIRONMENT_LABEL } diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index 9e8ac7d..ee22e0d 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -7,6 +7,7 @@ import { errorDetail } from '../lib/api' import type { AgentSession, EnvironmentDefaults, + RepositorySummary, SavedEnvironment, StartAgentSessionRequest, SupportedModel, @@ -15,8 +16,10 @@ import { applyEditShortcut } from '../lib/editing' import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC' import { sessionUrl } from '../lib/urls' import { + ADD_MCP_SERVER_LABEL, ADD_VARIABLE_LABEL, applyComposerChoices, + builtInMcpServers, attentionFlip, composerModelOptions, composerPickerRows, @@ -27,11 +30,23 @@ import { environmentPickerCount, environmentPickerRows, environmentRowSummary, + environmentSourceLabel, parseVariableEntry, + repositoryRefLabel, variableRowLabel, + EMPTY_COMPUTE, + EMPTY_HOOKS, + EMPTY_IMAGE, type ComposerChoices, type ComposerModel, + type ComputeField, + type CustomCompute, + type CustomHooks, + type CustomImage, + type CustomMcpServer, + type CustomRepository, type CustomVariable, + validateMcpServer, rowDescription, rowGlyph, rowMeta, @@ -298,7 +313,11 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { const [environments, setEnvironments] = useState(null) const [environmentDefaults, setEnvironmentDefaults] = useState(null) const [secretNames, setSecretNames] = useState(null) + const [repos, setRepos] = useState(null) const [models, setModels] = useState(null) + // The built-in MCP server names available on this account (from the + // connected integrations); [] until the fetch lands or when none are. + const [builtInServers, setBuiltInServers] = useState([]) const pickersLoading = useRef(false) useEffect(() => { if (mainPane.type !== 'launcher' || pickersLoading.current) return @@ -323,6 +342,19 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { setSecretNames([]) reportApiError('variables', err) }) + void api.integrations.github + .repos() + .then((r) => setRepos(r.repositories)) + .catch((err) => { + setRepos([]) + reportApiError('repositories', err) + }) + void api.integrations + .list() + .then((r) => setBuiltInServers(builtInMcpServers(r))) + .catch((err) => { + reportApiError('integrations', err) + }) void api.models .list() .then((r) => setModels(r.models)) @@ -445,6 +477,8 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { environments={environments} environmentDefaults={environmentDefaults} secretNames={secretNames} + repos={repos} + builtInServers={builtInServers} models={models} detectedRepo={props.detectedRepo} sessions={rows} @@ -517,6 +551,20 @@ type VariableEditor = { error: string | null } +// Adding an MCP server: three fields walked with ↑/↓. A bare name opts into a +// built-in; command makes it a stdio server, url a remote one (one or the +// other — validateMcpServer). Enter commits from any field, esc backs out. +type ServerEditor = { + field: 'name' | 'command' | 'url' + name: string + command: string + url: string + error: string | null +} + +const SERVER_EDITOR_FIELDS = ['name', 'command', 'url'] as const + + // The launcher: one painted box holding everything about the next session — // the configuration rows on top, the prompt under them — with history below — // @@ -575,6 +623,8 @@ function Launcher({ environments, environmentDefaults, secretNames, + repos, + builtInServers, models, detectedRepo, sessions, @@ -596,6 +646,10 @@ function Launcher({ models: SupportedModel[] | null // The account's stored variable names, checkable in the custom section. secretNames: string[] | null + // The account's connected repositories, checkable in the custom section. + repos: RepositorySummary[] | null + // The built-in MCP server names available on this account. + builtInServers: string[] // The defaults ladder; null until it lands (or its fetch failed), which just // leaves the untouched Environment row reading "Default". environmentDefaults: EnvironmentDefaults | null @@ -630,6 +684,13 @@ function Launcher({ // environment. Survives closing and reopening the list, so a variable typed // before choosing an environment is not lost. const [customVariables, setCustomVariables] = useState([]) + const [customRepositories, setCustomRepositories] = useState([]) + const [customCompute, setCustomCompute] = useState(EMPTY_COMPUTE) + const [customImage, setCustomImage] = useState(EMPTY_IMAGE) + const [customHooks, setCustomHooks] = useState(EMPTY_HOOKS) + const [customMcpServers, setCustomMcpServers] = useState([]) + // The "+ new server" form's state, or null while closed. + const [serverEditor, setServerEditor] = useState(null) // The open custom key's editor, or null. `field` is which of the two steps is // being typed; `name`/`value` hold what has been typed so far. const [editor, setEditor] = useState(null) @@ -643,9 +704,21 @@ function Launcher({ // ladder resolves for the cwd's repo starts out checked — so an untouched row // SENDS the environment it shows rather than leaving the server to resolve // something the row never named. + // Each synced environment's option row names the file it came from — only + // when the API already gave us the pieces (source_details + the repo list). + const environmentSources = useMemo(() => { + const byId = new Map((repos ?? []).map((r) => [r.id, r.full_name])) + const labels = new Map() + for (const e of environments ?? []) { + const label = environmentSourceLabel(e, byId) + if (label) labels.set(e.id, label) + } + return labels + }, [environments, repos]) const { options: environmentOptionList, picked: resolvedIdx } = useMemo( - () => environmentOptions(environments ?? [], environmentDefaults, detectedRepo), - [environments, environmentDefaults, detectedRepo], + () => + environmentOptions(environments ?? [], environmentDefaults, detectedRepo, environmentSources), + [environments, environmentDefaults, detectedRepo, environmentSources], ) const environmentOptionRows = useMemo( () => environmentOptionList.map((o) => ({ id: o.id, label: o.label })), @@ -668,6 +741,63 @@ function Launcher({ value: v.value ?? null, })) }, [environments, pickedEnvironment]) + // The picked environment's own compute and image, as the resting + // placeholders of their input rows. Display only: object overrides merge key + // by key, so unlike the lists these never need re-sending. + const baseCompute = useMemo(() => { + const id = pickedEnvironment?.id + if (!id || id === EMPTY_ENVIRONMENT_ID) return EMPTY_COMPUTE + const found = (environments ?? []).find((e) => e.id === id) + const c = found?.environment.compute + return { + cpu: c?.cpu != null ? String(c.cpu) : '', + memory: typeof c?.memory === 'string' ? c.memory : '', + timeout: typeof c?.timeout === 'string' ? c.timeout : '', + } + }, [environments, pickedEnvironment]) + // A multi-line script flattens to one line for the placeholder — the row is + // one line, and it only has to say "something is set here". + const oneLine = (s: string | null | undefined): string => (s ?? '').replace(/\s+/g, ' ').trim() + const baseImage = useMemo(() => { + const id = pickedEnvironment?.id + if (!id || id === EMPTY_ENVIRONMENT_ID) return EMPTY_IMAGE + const found = (environments ?? []).find((e) => e.id === id) + const img = found?.environment.image + return { dockerfile_append: oneLine(img?.dockerfile_append), setup: oneLine(img?.setup) } + }, [environments, pickedEnvironment]) + const baseHooks = useMemo(() => { + const id = pickedEnvironment?.id + if (!id || id === EMPTY_ENVIRONMENT_ID) return EMPTY_HOOKS + const found = (environments ?? []).find((e) => e.id === id) + const hooks = found?.environment.hooks + return { post_start: oneLine(hooks?.post_start), post_clone: oneLine(hooks?.post_clone) } + }, [environments, pickedEnvironment]) + // The picked environment's own MCP servers, verbatim (they can be full + // stdio/remote definitions) — the override list replaces, so they ride along. + const baseMcpServers = useMemo(() => { + const id = pickedEnvironment?.id + if (!id || id === EMPTY_ENVIRONMENT_ID) return [] + const found = (environments ?? []).find((e) => e.id === id) + return found?.environment.mcp_servers ?? [] + }, [environments, pickedEnvironment]) + const baseMcpServerNames = useMemo( + () => + baseMcpServers + .map((s) => (typeof s === 'string' ? s : ((s as { name?: string }).name ?? ''))) + .filter(Boolean), + [baseMcpServers], + ) + // The picked environment's own repositories — same reason as baseVariables: + // an override array replaces the resolved list, so they have to ride along. + const baseRepositories = useMemo(() => { + const id = pickedEnvironment?.id + if (!id || id === EMPTY_ENVIRONMENT_ID) return [] + const found = (environments ?? []).find((e) => e.id === id) + return (found?.environment.repositories ?? []).map((r) => ({ + fullName: r.owner ? `${r.owner}/${r.name}` : r.name, + ref: r.ref ?? null, + })) + }, [environments, pickedEnvironment]) // The server's selectable set (GET /models); before it lands — and on an // older server that has no such route — the built-in fallback list. const modelOptions = useMemo(() => composerModelOptions(models ?? []), [models]) @@ -696,13 +826,38 @@ function Launcher({ // The open Environment list's shape, shared by its navigation and its // renderer: the options, then the custom section's secrets, typed variables // and add button. + // Whether a repository is in the run: checked in the custom section, or + // brought in by the picked environment itself. + const repositoryFor = (fullName: string): CustomRepository | undefined => + customRepositories.find((r) => r.fullName === fullName) ?? + baseRepositories.find((r) => r.fullName === fullName) const environmentPicker = useMemo( () => ({ optionCount: environmentOptionRows.length, + repoNames: (repos ?? []).map((r) => r.full_name), + // A checked repo grows its branch input row, so ↓ can land on it. + checkedRepoNames: (repos ?? []) + .map((r) => r.full_name) + .filter( + (name) => + customRepositories.some((r) => r.fullName === name) || + baseRepositories.some((r) => r.fullName === name), + ), secretNames: secretNames ?? [], customVariables, + builtInMcpServers: builtInServers, + customMcpServers, }), - [environmentOptionRows.length, secretNames, customVariables], + [ + environmentOptionRows.length, + repos, + secretNames, + customVariables, + customRepositories, + baseRepositories, + builtInServers, + customMcpServers, + ], ) const environmentHoverCount = environmentPickerCount(environmentPicker) // Whether a variable of this name is in the run: a checked secret and a typed @@ -710,14 +865,47 @@ function Launcher({ const variableFor = (name: string): CustomVariable | undefined => customVariables.find((v) => v.name === name) // Enter (or →) on a row of the open Environment list: an option picks and - // closes; a secret toggles; a typed variable re-opens for editing; the add - // button opens an empty editor. + // closes; a repo or secret toggles; a typed variable re-opens for editing; + // the add button opens an empty editor. const activateEnvironmentRow = (hover: number): void => { const row = environmentPickerAt(environmentPicker, hover) if (row.kind === 'option') { activate('environment', row.at) return } + if (row.kind === 'repo') { + // Unchecking drops the custom entry; a base repo (the picked + // environment's own) stays, since an additive list can't remove it. + setCustomRepositories((prev) => + prev.some((r) => r.fullName === row.fullName) + ? prev.filter((r) => r.fullName !== row.fullName) + : [...prev, { fullName: row.fullName, ref: null }], + ) + return + } + // The branch, compute, image and hook rows are text inputs, not toggles: + // enter there is a no-op, typing is what edits them (see the handler). + if ( + row.kind === 'repoRef' || + row.kind === 'compute' || + row.kind === 'image' || + row.kind === 'hook' + ) + return + if (row.kind === 'mcpServer') { + // Toggling off a base server can't be expressed additively, so only + // servers added here uncheck (mirrors the repo rows). + setCustomMcpServers((prev) => + prev.some((s) => s.name === row.name) + ? prev.filter((s) => s.name !== row.name) + : [...prev, { name: row.name, command: null, url: null }], + ) + return + } + if (row.kind === 'addMcpServer') { + setServerEditor({ field: 'name', name: '', command: '', url: '', error: null }) + return + } if (row.kind === 'secret') { // Values are write-only, so a checked secret is a variable with no value: // the sandbox resolves the name from stored secrets at start. @@ -764,6 +952,21 @@ function Launcher({ }) setEditor(null) } + // Typing on a checked repo's branch row edits its ref in place: backspace + // erases, an emptied ref returns to the default branch (null). A base repo + // whose ref was edited becomes a custom entry, which wins the merge. + const editRepositoryRef = (fullName: string, edit: (ref: string) => string): void => { + const current = repositoryFor(fullName)?.ref ?? '' + const next = edit(current) + const entry = { fullName, ref: next === '' ? null : next } + setCustomRepositories((prev) => { + const existing = prev.findIndex((r) => r.fullName === fullName) + if (existing === -1) return [...prev, entry] + const out = [...prev] + out[existing] = entry + return out + }) + } // Enter with an empty prompt is a real start: the session comes up idle and // waits for the first message, so you can open a sandbox before you know @@ -775,6 +978,13 @@ function Launcher({ emptyEnvironment: emptyPicked, baseVariables, customVariables, + baseRepositories, + customRepositories, + customCompute, + customImage, + customHooks, + baseMcpServers, + customMcpServers, }) } @@ -795,6 +1005,55 @@ function Launcher({ useInput( (ch, key) => { + // The "+ new server" form: like the variable form, it owns every key + // while up. ↑/↓ walk the three fields, enter commits, esc backs out. + if (serverEditor !== null) { + if (key.escape) { + setServerEditor(null) + return + } + if (key.return) { + const entry: CustomMcpServer = { + name: serverEditor.name.trim(), + command: serverEditor.command.trim() || null, + url: serverEditor.url.trim() || null, + } + const error = validateMcpServer(entry) + if (error) { + setServerEditor({ ...serverEditor, error }) + return + } + setCustomMcpServers((prev) => [ + // A repeat of a name is an edit of it, not a duplicate row. + ...prev.filter((s) => s.name !== entry.name), + entry, + ]) + setServerEditor(null) + return + } + const fields = SERVER_EDITOR_FIELDS + const at = fields.indexOf(serverEditor.field) + if (key.upArrow) { + setServerEditor({ ...serverEditor, field: fields[Math.max(0, at - 1)] }) + return + } + if (key.downArrow) { + setServerEditor({ + ...serverEditor, + field: fields[Math.min(fields.length - 1, at + 1)], + }) + return + } + const typed = serverEditor[serverEditor.field] + if (key.backspace || key.delete) { + setServerEditor({ ...serverEditor, [serverEditor.field]: typed.slice(0, -1), error: null }) + return + } + if (ch && !key.ctrl && !key.meta) { + setServerEditor({ ...serverEditor, [serverEditor.field]: typed + ch, error: null }) + } + return + } // The variable form is the innermost modal: while it is up it owns every // key, so space is typed text rather than list navigation. ↑/↓ move the // caret between the two fields, enter commits, esc backs out. @@ -844,6 +1103,60 @@ function Launcher({ setOpenPicker((p) => p && { ...p, hover: Math.min(rowCount - 1, p.hover + 1) }) return } + // The branch and compute rows are text inputs under the cursor: typing + // and backspace edit them in place, blank = whatever the server + // resolves. Space stays list navigation (no ref or size holds one). + if (isEnv) { + const hover = Math.min(openPicker.hover, rowCount - 1) + const target = environmentPickerAt(environmentPicker, hover) + const typing = + ch && ch !== ' ' && !key.ctrl && !key.meta && !key.return && !key.rightArrow + if (target.kind === 'repoRef') { + if (key.backspace || key.delete) { + editRepositoryRef(target.fullName, (ref) => ref.slice(0, -1)) + return + } + if (typing) { + editRepositoryRef(target.fullName, (ref) => ref + ch) + return + } + } + if (target.kind === 'compute') { + if (key.backspace || key.delete) { + setCustomCompute((c) => ({ ...c, [target.field]: c[target.field].slice(0, -1) })) + return + } + // cpu is a number on the wire, so its field only admits digits. + if (typing && (target.field !== 'cpu' || /^[0-9.]$/.test(ch))) { + setCustomCompute((c) => ({ ...c, [target.field]: c[target.field] + ch })) + return + } + } + // Image and hook fields are shell/Dockerfile lines, so space is + // typed text — but only once something has been typed, so space on + // the untouched row still activates like everywhere else. + if (target.kind === 'image' || target.kind === 'hook') { + const typed = + target.kind === 'image' + ? customImage[target.field] + : customHooks[target.field] + const set = + target.kind === 'image' + ? (edit: (s: string) => string) => + setCustomImage((c) => ({ ...c, [target.field]: edit(c[target.field]) })) + : (edit: (s: string) => string) => + setCustomHooks((c) => ({ ...c, [target.field]: edit(c[target.field]) })) + const spaceTyping = ch === ' ' && typed !== '' && !key.ctrl && !key.meta + if (key.backspace || key.delete) { + set((s) => s.slice(0, -1)) + return + } + if (typing || spaceTyping) { + set((s) => s + ch) + return + } + } + } if (key.rightArrow || key.return || ch === ' ') { const hover = Math.min(openPicker.hover, rowCount - 1) if (isEnv) activateEnvironmentRow(hover) @@ -962,7 +1275,17 @@ function Launcher({ const rowValue = (key: PickerRow['key']): string => { const options = optionsFor(key) const label = options[Math.min(pickedIdx(key), options.length - 1)]?.label ?? 'Default' - return key === 'environment' ? environmentRowSummary(label, customVariables) : label + return key === 'environment' + ? environmentRowSummary( + label, + customVariables, + customRepositories, + customCompute, + customImage, + customHooks, + customMcpServers, + ) + : label } // Columns available inside the prompt box: the terminal minus its left accent @@ -1123,9 +1446,10 @@ function Launcher({ ) } - // The form owns the caret while it is open, so the list's own + // A form owns the caret while it is open, so the list's own // highlight goes dark rather than showing a second one. - const hovered = envRow.hover === openHover && editor === null + const hovered = + envRow.hover === openHover && editor === null && serverEditor === null if (envRow.kind === 'option') { const opt = openOptions[envRow.at] if (!opt) return null @@ -1142,6 +1466,197 @@ function Launcher({ ) } + // A connected repository: enter/space toggles it into the + // run. The ref stays off the row until it is checked — the + // branch input under it is where that lives. + if (envRow.kind === 'repo') { + const checked = repositoryFor(envRow.fullName) !== undefined + return ( + + + {' '} + {hovered ? SELECTION_GLYPH : ' '}{' '} + + {` [${checked ? 'x' : ' '}] ${envRow.fullName}`} + + + + ) + } + // A checked repo's branch input: typing edits the ref in + // place; empty rests on the repo's default branch. + if (envRow.kind === 'repoRef') { + const ref = repositoryFor(envRow.fullName)?.ref ?? null + const resting = repositoryRefLabel( + ref, + (repos ?? []).find((r) => r.full_name === envRow.fullName) + ?.default_branch ?? null, + ) + return ( + + + {' '} + {hovered ? SELECTION_GLYPH : ' '}{' '} + {/* Aligned under the repo name, past its checkbox. */} + {' branch: '} + {ref !== null ? ( + + {ref} + {hovered && } + + ) : ( + // The default branch as a placeholder: it is what + // an untouched row clones, and typing replaces it. + + {hovered && resting ? ( + + {resting[0]} + {resting.slice(1)} + + ) : ( + (resting ?? '') + )} + + )} + + + ) + } + // A compute, image or hook field: an inline input like a + // branch row. Blank rests on the picked environment's own + // value (muted), which is what an untouched field keeps — + // object overrides merge key by key. + if ( + envRow.kind === 'compute' || + envRow.kind === 'image' || + envRow.kind === 'hook' + ) { + const typed = + envRow.kind === 'compute' + ? customCompute[envRow.field] + : envRow.kind === 'image' + ? customImage[envRow.field] + : customHooks[envRow.field] + const resting = + envRow.kind === 'compute' + ? baseCompute[envRow.field] + : envRow.kind === 'image' + ? baseImage[envRow.field] + : baseHooks[envRow.field] + return ( + + + {' '} + {hovered ? SELECTION_GLYPH : ' '}{' '} + {` ${envRow.field}: `} + {typed !== '' ? ( + + {typed} + {hovered && } + + ) : ( + + {hovered && resting !== '' ? ( + + {resting[0]} + {resting.slice(1)} + + ) : ( + resting + )} + {hovered && resting === '' && } + + )} + + + ) + } + // An MCP server: a built-in (or typed) name, checked when the + // picked environment carries it or it was checked here. + if (envRow.kind === 'mcpServer') { + const checked = + customMcpServers.some((s) => s.name === envRow.name) || + baseMcpServerNames.includes(envRow.name) + return ( + + + {' '} + {hovered ? SELECTION_GLYPH : ' '}{' '} + + {` [${checked ? 'x' : ' '}] ${envRow.name}`} + + + + ) + } + if (envRow.kind === 'addMcpServer') { + return ( + + + + {' '} + {hovered ? SELECTION_GLYPH : ' '}{' '} + + {' '} + {ADD_MCP_SERVER_LABEL} + + + + {serverEditor !== null && ( + + {SERVER_EDITOR_FIELDS.map((field) => { + const here = serverEditor.field === field + const typed = serverEditor[field] + // name is required; command/url pick the type, + // so their placeholders say the either/or. + const ghost = + typed !== '' + ? null + : field === 'name' + ? 'my-tools' + : field === 'command' + ? 'stdio: npx -y my-tools-mcp' + : 'remote: https://mcp.example.com' + return ( + + + {' '} + + {here ? SELECTION_GLYPH : ' '} + {' '} + {/* Aligned under the button text, past + its "+ ". */} + {` ${field}: `} + {typed} + {ghost ? ( + + {here ? ( + {ghost[0]} + ) : ( + {ghost[0]} + )} + {ghost.slice(1)} + + ) : ( + here && + )} + + + ) + })} + {serverEditor.error !== null && ( + + + {' '} + {serverEditor.error} + + + )} + + )} + + ) + } if (envRow.kind === 'addVariable') { return ( @@ -1174,11 +1689,13 @@ function Launcher({ return ( - {' '} + {' '} {here ? SELECTION_GLYPH : ' '} {' '} - {`${field}: `} + {/* Aligned under the button text, past + its "+ ". */} + {` ${field}: `} {typed} {/* The caret sits on the placeholder's first character rather than pushing it right. */} diff --git a/test/sessions.test.ts b/test/sessions.test.ts index 700309c..9297c08 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest' import { applyComposerChoices, attentionFlip, + builtInMcpServers, + mcpServerEntry, + validateMcpServer, COMPOSER_MODELS, composerModelOptions, composerPickerRows, @@ -11,9 +14,17 @@ import { environmentPickerAt, environmentPickerRows, environmentRowSummary, + environmentSourceLabel, + EMPTY_COMPUTE, + EMPTY_HOOKS, + EMPTY_IMAGE, + computeOverride, + fieldsOverride, filterSessions, + mergeRepositories, mergeVariables, parseVariableEntry, + repositoryRefLabel, variableRowLabel, modelRate, rateDollars, @@ -552,6 +563,13 @@ describe('applyComposerChoices', () => { emptyEnvironment: false, baseVariables: [], customVariables: [], + baseRepositories: [], + customRepositories: [], + customCompute: EMPTY_COMPUTE, + customImage: EMPTY_IMAGE, + customHooks: EMPTY_HOOKS, + baseMcpServers: [], + customMcpServers: [], } it('leaves the base request alone when nothing was picked', () => { @@ -651,6 +669,7 @@ describe('applyComposerChoices', () => { ...untouched, emptyEnvironment: true, baseVariables: [{ name: 'NODE_ENV', value: 'production' }], + baseRepositories: [{ fullName: 'acme/api', ref: null }], }, ) expect(req).toEqual({ @@ -658,6 +677,168 @@ describe('applyComposerChoices', () => { override: { environment: { repositories: [], mcp_servers: [], variables: [] } }, }) }) + + // Same rule as variables: an override array replaces the resolved list, so + // the picked environment's own repositories ride along with a checked one. + it('ships the picked environment repositories alongside a checked one', () => { + const req = applyComposerChoices( + {}, + { + ...untouched, + environment: 'env_1', + baseRepositories: [{ fullName: 'acme/api', ref: 'main' }], + customRepositories: [{ fullName: 'acme/web', ref: null }], + }, + ) + expect(req).toEqual({ + environment: 'env_1', + override: { + environment: { + repositories: [ + { owner: 'acme', name: 'api', ref: 'main' }, + { owner: 'acme', name: 'web' }, + ], + }, + }, + }) + }) + + it('sends no repositories override while none were checked', () => { + const req = applyComposerChoices( + {}, + { + ...untouched, + environment: 'env_1', + baseRepositories: [{ fullName: 'acme/api', ref: null }], + }, + ) + expect(req).toEqual({ environment: 'env_1' }) + }) + + // Compute is scalars in a merging object override, so only the typed fields + // ship — no base counterpart to re-send. + it('sends only the typed compute fields', () => { + const req = applyComposerChoices( + {}, + { ...untouched, customCompute: { cpu: '4', memory: '16GB', timeout: '' } }, + ) + expect(req.override).toEqual({ environment: { compute: { cpu: 4, memory: '16GB' } } }) + }) + + it('sends only the typed image fields', () => { + const req = applyComposerChoices( + {}, + { ...untouched, customImage: { dockerfile_append: '', setup: 'npm install' } }, + ) + expect(req.override).toEqual({ environment: { image: { setup: 'npm install' } } }) + }) + + it('sends only the typed hook fields', () => { + const req = applyComposerChoices( + {}, + { ...untouched, customHooks: { post_start: 'doppler setup', post_clone: '' } }, + ) + expect(req.override).toEqual({ environment: { hooks: { post_start: 'doppler setup' } } }) + }) + + // The base servers ride verbatim — they can be full stdio/remote definitions + // — and a checked name the base already carries adds no duplicate. + it('ships the base MCP servers alongside the ones added here', () => { + const req = applyComposerChoices( + {}, + { + ...untouched, + baseMcpServers: [{ name: 'my-tools', command: 'npx', args: ['my-tools'] }, 'linear'], + customMcpServers: [ + { name: 'slack', command: null, url: null }, + { name: 'linear', command: null, url: null }, + { name: 'docs', command: null, url: 'https://mcp.example.com' }, + ], + }, + ) + expect(req.override).toEqual({ + environment: { + mcp_servers: [ + { name: 'my-tools', command: 'npx', args: ['my-tools'] }, + 'linear', + { name: 'slack' }, + { name: 'docs', url: 'https://mcp.example.com' }, + ], + }, + }) + }) + + it('sends no MCP override while none were checked', () => { + const req = applyComposerChoices( + {}, + { ...untouched, baseMcpServers: ['linear'] }, + ) + expect(req).toEqual({}) + }) +}) + +describe('builtInMcpServers', () => { + it('lists only the connected integrations that back a built-in server', () => { + expect(builtInMcpServers({ linear: { org: 'x' }, slack: null })).toEqual(['linear']) + expect(builtInMcpServers({})).toEqual([]) + expect(builtInMcpServers({ linear: {}, slack: {} })).toEqual(['linear', 'slack']) + }) +}) + +describe('mcpServerEntry', () => { + it('splits a command line into the stdio shape', () => { + expect( + mcpServerEntry({ name: 't', command: 'npx -y my-tools-mcp', url: null }), + ).toEqual({ name: 't', command: 'npx', args: ['-y', 'my-tools-mcp'] }) + expect(mcpServerEntry({ name: 't', command: 'server-bin', url: null })).toEqual({ + name: 't', + command: 'server-bin', + }) + }) + + it('builds the remote shape from a url, and a bare name from neither', () => { + expect(mcpServerEntry({ name: 'd', command: null, url: 'https://x' })).toEqual({ + name: 'd', + url: 'https://x', + }) + expect(mcpServerEntry({ name: 'linear', command: null, url: null })).toEqual({ + name: 'linear', + }) + }) +}) + +describe('validateMcpServer', () => { + it('requires a name and rejects command with url', () => { + expect(validateMcpServer({ name: '', command: null, url: null })).toBeTruthy() + expect(validateMcpServer({ name: 'x', command: 'c', url: 'u' })).toBeTruthy() + expect(validateMcpServer({ name: 'x', command: 'c', url: null })).toBeNull() + expect(validateMcpServer({ name: 'x', command: null, url: 'u' })).toBeNull() + expect(validateMcpServer({ name: 'x', command: null, url: null })).toBeNull() + }) +}) + +describe('computeOverride', () => { + it('parses cpu to a number and keeps the others as typed', () => { + expect(computeOverride({ cpu: '4', memory: '16GB', timeout: '30m' })).toEqual({ + cpu: 4, + memory: '16GB', + timeout: '30m', + }) + }) + + it('drops blank and unparseable fields', () => { + expect(computeOverride(EMPTY_COMPUTE)).toEqual({}) + expect(computeOverride({ cpu: '..', memory: ' ', timeout: '' })).toEqual({}) + }) +}) + +describe('fieldsOverride', () => { + it('keeps only the set fields, trimmed', () => { + expect(fieldsOverride({ dockerfile_append: '', setup: ' npm install ' })).toEqual({ + setup: 'npm install', + }) + expect(fieldsOverride(EMPTY_IMAGE)).toEqual({}) + }) }) describe('mergeVariables', () => { @@ -712,22 +893,84 @@ describe('parseVariableEntry', () => { describe('environmentPickerRows', () => { const input = { optionCount: 2, + repoNames: ['acme/api'], + checkedRepoNames: [] as string[], secretNames: ['API_TOKEN', 'NPM_TOKEN'], customVariables: [{ name: 'PORT', value: '3000' }], + builtInMcpServers: ['linear'], + customMcpServers: [] as { name: string; command: string | null; url: string | null }[], } - // Options, divider, heading, the stored variables, what was typed here, the - // add button. Only the pickable rows carry a hover index. + // Options, divider, the repositories, the stored variables, what was typed + // here, the add button. Only the pickable rows carry a hover index. it('lays the list out and numbers only the pickable rows', () => { expect(environmentPickerRows(input)).toEqual([ { kind: 'option', at: 0, hover: 0 }, { kind: 'option', at: 1, hover: 1 }, { kind: 'divider', label: 'custom environment' }, + { kind: 'heading', label: 'repositories' }, + { kind: 'repo', fullName: 'acme/api', hover: 2 }, + { kind: 'heading', label: 'mcp servers' }, + { kind: 'mcpServer', name: 'linear', hover: 3 }, + { kind: 'addMcpServer', hover: 4 }, + { kind: 'heading', label: 'variables' }, + { kind: 'secret', name: 'API_TOKEN', hover: 5 }, + { kind: 'secret', name: 'NPM_TOKEN', hover: 6 }, + { kind: 'variable', name: 'PORT', hover: 7 }, + { kind: 'addVariable', hover: 8 }, + { kind: 'heading', label: 'image' }, + { kind: 'image', field: 'dockerfile_append', hover: 9 }, + { kind: 'image', field: 'setup', hover: 10 }, + { kind: 'heading', label: 'hooks' }, + { kind: 'hook', field: 'post_start', hover: 11 }, + { kind: 'hook', field: 'post_clone', hover: 12 }, + { kind: 'heading', label: 'compute' }, + { kind: 'compute', field: 'cpu', hover: 13 }, + { kind: 'compute', field: 'memory', hover: 14 }, + { kind: 'compute', field: 'timeout', hover: 15 }, + ]) + }) + + // A typed server name that is also a built-in rides that row, like the + // variables (one name, one row). + it('gives a built-in server one row even once it is checked', () => { + const rows = environmentPickerRows({ + ...input, + customMcpServers: [ + { name: 'linear', command: null, url: null }, + { name: 'my-server', command: 'npx my-server', url: null }, + ], + }) + const serverRows = rows.filter((r) => r.kind === 'mcpServer') + expect(serverRows.map((r) => (r as { name: string }).name)).toEqual(['linear', 'my-server']) + }) + + // A checked repo grows a branch input row directly under it, so ↓ can land + // there and type a ref. + it('adds a branch row under a checked repo only', () => { + const rows = environmentPickerRows({ + ...input, + repoNames: ['acme/api', 'acme/web'], + checkedRepoNames: ['acme/api'], + }) + expect(rows.slice(3, 7)).toEqual([ + { kind: 'heading', label: 'repositories' }, + { kind: 'repo', fullName: 'acme/api', hover: 2 }, + { kind: 'repoRef', fullName: 'acme/api', hover: 3 }, + { kind: 'repo', fullName: 'acme/web', hover: 4 }, + ]) + }) + + // An empty repo list (not landed yet, or none connected) prints no heading + // with nothing under it. + it('drops the repositories heading while there are no repos', () => { + const rows = environmentPickerRows({ ...input, repoNames: [] }) + expect(rows.filter((r) => r.kind === 'heading')).toEqual([ + { kind: 'heading', label: 'mcp servers' }, { kind: 'heading', label: 'variables' }, - { kind: 'secret', name: 'API_TOKEN', hover: 2 }, - { kind: 'secret', name: 'NPM_TOKEN', hover: 3 }, - { kind: 'variable', name: 'PORT', hover: 4 }, - { kind: 'addVariable', hover: 5 }, + { kind: 'heading', label: 'image' }, + { kind: 'heading', label: 'hooks' }, + { kind: 'heading', label: 'compute' }, ]) }) @@ -745,18 +988,26 @@ describe('environmentPickerRows', () => { describe('environmentPickerAt', () => { const input = { optionCount: 2, + repoNames: ['acme/api'], + checkedRepoNames: ['acme/api'], secretNames: ['API_TOKEN'], customVariables: [] as { name: string; value: string | null }[], + builtInMcpServers: [] as string[], + customMcpServers: [] as { name: string; command: string | null; url: string | null }[], } - it('walks options, then the stored variables, then the add button', () => { + it('walks options, repos and branch rows, servers, variables, then the fields', () => { expect(environmentPickerAt(input, 1)).toEqual({ kind: 'option', at: 1 }) - expect(environmentPickerAt(input, 2)).toEqual({ kind: 'secret', name: 'API_TOKEN' }) - expect(environmentPickerAt(input, 3)).toEqual({ kind: 'addVariable' }) + expect(environmentPickerAt(input, 2)).toEqual({ kind: 'repo', fullName: 'acme/api' }) + expect(environmentPickerAt(input, 3)).toEqual({ kind: 'repoRef', fullName: 'acme/api' }) + expect(environmentPickerAt(input, 4)).toEqual({ kind: 'addMcpServer' }) + expect(environmentPickerAt(input, 5)).toEqual({ kind: 'secret', name: 'API_TOKEN' }) + expect(environmentPickerAt(input, 6)).toEqual({ kind: 'addVariable' }) + expect(environmentPickerAt(input, 7)).toEqual({ kind: 'image', field: 'dockerfile_append' }) }) it('clamps a hover past the last row', () => { - expect(environmentPickerAt(input, 99)).toEqual({ kind: 'addVariable' }) + expect(environmentPickerAt(input, 99)).toEqual({ kind: 'compute', field: 'timeout' }) }) }) @@ -807,6 +1058,18 @@ describe('environmentOptions', () => { const { options, picked } = environmentOptions(environments, null, 'acme/api') expect(options[picked]).toEqual({ id: null, label: 'Default' }) }) + + // A synced environment's row explains where its definition lives, ahead of + // the default rungs it holds. + it('names the source file before the default rungs', () => { + const { options } = environmentOptions( + environments, + { account: 'env_1', repositories: {} }, + null, + new Map([['env_1', 'acme/api/e.yaml @ abcdef1']]), + ) + expect(options[0].label).toBe('backend (acme/api/e.yaml @ abcdef1, account default)') + }) }) describe('variableRowLabel', () => { @@ -833,6 +1096,131 @@ describe('environmentRowSummary', () => { ]), ).toBe('backend-sandbox +2 variables') }) + + it('counts checked repositories, before the variables', () => { + expect( + environmentRowSummary('backend-sandbox', [], [{ fullName: 'acme/api', ref: null }]), + ).toBe('backend-sandbox +1 repo') + expect( + environmentRowSummary( + 'backend-sandbox', + [{ name: 'A', value: '1' }], + [ + { fullName: 'acme/api', ref: null }, + { fullName: 'acme/web', ref: 'dev' }, + ], + ), + ).toBe('backend-sandbox +2 repos +1 variable') + }) + + it('notes a compute override without counting its fields', () => { + expect( + environmentRowSummary('backend-sandbox', [], [], { cpu: '4', memory: '', timeout: '' }), + ).toBe('backend-sandbox +compute') + }) + + it('notes an image override', () => { + expect( + environmentRowSummary('backend-sandbox', [], [], EMPTY_COMPUTE, { + dockerfile_append: '', + setup: 'npm install', + }), + ).toBe('backend-sandbox +image') + }) + + it('notes a hooks override', () => { + expect( + environmentRowSummary('backend-sandbox', [], [], EMPTY_COMPUTE, EMPTY_IMAGE, { + post_start: 'doppler setup', + post_clone: '', + }), + ).toBe('backend-sandbox +hooks') + }) + + it('counts checked MCP servers', () => { + expect( + environmentRowSummary('backend-sandbox', [], [], EMPTY_COMPUTE, EMPTY_IMAGE, EMPTY_HOOKS, [ + { name: 'linear', command: null, url: null }, + { name: 'docs', command: null, url: 'https://x' }, + ]), + ).toBe('backend-sandbox +2 mcp') + }) +}) + +describe('repositoryRefLabel', () => { + // The branch row says which ref a start would clone: the typed one, else the + // repo's default branch. + it('shows the typed ref over the default branch', () => { + expect(repositoryRefLabel('release', 'main')).toBe('release') + expect(repositoryRefLabel(null, 'main')).toBe('main') + expect(repositoryRefLabel(null, null)).toBeNull() + }) +}) + +describe('environmentSourceLabel', () => { + const repoNames = new Map([[42, 'acme/api']]) + + it('names the file and short sha a synced environment came from', () => { + expect( + environmentSourceLabel( + { + source_details: { repo_id: 42, path: 'agents/environments/dev.yaml', branch: 'main' }, + last_synced_commit_sha: 'abcdef1234567890', + }, + repoNames, + ), + ).toBe('acme/api/agents/environments/dev.yaml @ abcdef1') + }) + + it('drops the sha when the API has none', () => { + expect( + environmentSourceLabel( + { source_details: { repo_id: 42, path: 'e.yaml', branch: 'main' } }, + repoNames, + ), + ).toBe('acme/api/e.yaml') + }) + + // Only what the API already gave us: no source (API-managed), or a repo the + // connected list can't name, says nothing rather than guessing. + it('says nothing for an API-managed environment or an unknown repo', () => { + expect(environmentSourceLabel({ source_details: null }, repoNames)).toBeNull() + expect( + environmentSourceLabel( + { source_details: { repo_id: 7, path: 'e.yaml', branch: 'main' } }, + repoNames, + ), + ).toBeNull() + }) +}) + +describe('mergeRepositories', () => { + it('appends a custom repository after the environment own', () => { + expect( + mergeRepositories( + [{ fullName: 'acme/api', ref: null }], + [{ fullName: 'acme/web', ref: 'dev' }], + ), + ).toEqual([ + { fullName: 'acme/api', ref: null }, + { fullName: 'acme/web', ref: 'dev' }, + ]) + }) + + it('lets a custom entry override a base one in place', () => { + expect( + mergeRepositories( + [ + { fullName: 'acme/api', ref: null }, + { fullName: 'acme/web', ref: null }, + ], + [{ fullName: 'acme/api', ref: 'release' }], + ), + ).toEqual([ + { fullName: 'acme/api', ref: 'release' }, + { fullName: 'acme/web', ref: null }, + ]) + }) }) describe('effectiveEnvironmentDefault', () => { From 42c42972b892e5458a3150a1987e630d7054b625 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 28 Aug 2026 13:12:26 -0400 Subject: [PATCH 2/2] . --- src/lib/sessions.ts | 452 ++++++++------ src/ui/SessionsApp.tsx | 1347 +++++++++++++++++++++------------------- test/sessions.test.ts | 561 ++++++++--------- 3 files changed, 1208 insertions(+), 1152 deletions(-) diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 508c116..6e3f472 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -406,7 +406,6 @@ export function composerPickerRows( return rows } -export const CUSTOM_ENVIRONMENT_DIVIDER = 'custom environment' export const REPOSITORIES_HEADING = 'repositories' export const VARIABLES_HEADING = 'variables' export const ADD_VARIABLE_LABEL = '+ new' @@ -470,12 +469,17 @@ export interface CustomMcpServer { name: string command: string | null url: string | null + // The entry a seeded server came from, kept verbatim so a definition the + // launcher's three fields can't hold (args, env, headers) survives being + // carried through a custom run rather than being quietly flattened away. + raw?: unknown } // A custom server in the shape the mcp_servers override takes: `{name}` opts // into a built-in; a command line splits into command + args (the schema's // stdio shape); a url is the remote shape. -export function mcpServerEntry(s: CustomMcpServer): Record { +export function mcpServerEntry(s: CustomMcpServer): unknown { + if (s.raw !== undefined) return s.raw if (s.command) { const [command, ...args] = s.command.split(/\s+/) return args.length > 0 ? { name: s.name, command, args } : { name: s.name, command } @@ -522,20 +526,28 @@ export function computeOverride(compute: CustomCompute): Record export const EMPTY_ENVIRONMENT_ID = 'empty:builtin' export const EMPTY_ENVIRONMENT_LABEL = '[empty]' -// The open Environment list, top to bottom: the saved environments and -// "[empty]" as options, a divider, then the custom section — one checkbox per -// connected repository, one per stored secret, then the variables typed here, -// then the add button. +// The row that appears at the bottom of the list, checked, once the pane no +// longer matches any saved environment. Also a sentinel: it names no +// environment on the wire — the pane's own lists are the whole message. +export const CUSTOM_ENVIRONMENT_ID = 'custom:builtin' +export const CUSTOM_ENVIRONMENT_LABEL = 'custom' + +// The configuration pane above the Environment row: what THIS run's sandbox +// will be, section by section — the connected repositories, the MCP servers, +// the variables, then the image, hook and compute fields. +// +// The pane is the whole truth. Picking an environment seeds it (see +// environmentPaneState); editing any row of it is what makes the run "custom", +// and what the pane holds is what ships. So unchecking a repository the picked +// environment brought in actually drops that repository from the run, which the +// old additive section could not express. // // `hover` is the index ↑/↓ walks; rows without one are DECORATION the highlight // skips, which is what keeps this a plain list rather than a nested tree. -export type EnvironmentPickerRow = - | { kind: 'option'; at: number; hover: number } - | { kind: 'divider'; label: string } +export type EnvironmentPaneRow = | { kind: 'heading'; label: string } | { kind: 'repo'; fullName: string; hover: number } | { kind: 'repoRef'; fullName: string; hover: number } - | { kind: 'secret'; name: string; hover: number } | { kind: 'variable'; name: string; hover: number } | { kind: 'addVariable'; hover: number } | { kind: 'compute'; field: ComputeField; hover: number } @@ -545,43 +557,42 @@ export type EnvironmentPickerRow = | { kind: 'addMcpServer'; hover: number } // What activating a hovered row does, without the renderer having to know the -// list's shape. -export type EnvironmentTarget = - | { kind: 'option'; at: number } - | { kind: 'repo'; fullName: string } - | { kind: 'repoRef'; fullName: string } - | { kind: 'secret'; name: string } - | { kind: 'variable'; name: string } - | { kind: 'addVariable' } - | { kind: 'compute'; field: ComputeField } - | { kind: 'image'; field: ImageField } - | { kind: 'hook'; field: HookField } - | { kind: 'mcpServer'; name: string } - | { kind: 'addMcpServer' } - -export interface EnvironmentPickerInput { - optionCount: number - // The account's connected repositories ("owner/name"), checkable in the - // custom section; each clones at its default branch unless a ref is typed. +// pane's shape. +export type EnvironmentPaneTarget = Exclude extends infer R + ? R extends { hover: number } + ? Omit + : never + : never + +export interface EnvironmentPaneInput { + // The account's connected repositories ("owner/name"), one checkbox each; + // a checked one clones at its default branch unless a ref is typed. repoNames: readonly string[] // Which of those are in the run. A checked repo grows a `branch:` input row // under it, where ↓ lands and typing sets the ref (blank = default branch). checkedRepoNames: readonly string[] // The account's stored secret names (values are write-only, so checking one - // adds a variable with no value and the sandbox resolves it at start). + // ships a variable with no value and the sandbox resolves it at start), plus + // whatever the pane holds — a name from either side gets exactly one row. secretNames: readonly string[] - customVariables: readonly CustomVariable[] - // The built-in MCP servers connected on this account (checkboxes), and the - // servers defined here whose names aren't among them. + variables: readonly CustomVariable[] + // The built-in MCP servers connected on this account, plus the pane's own + // servers whose names aren't among them. builtInMcpServers: readonly string[] - customMcpServers: readonly CustomMcpServer[] + mcpServers: readonly CustomMcpServer[] } -export function environmentPickerRows(input: EnvironmentPickerInput): EnvironmentPickerRow[] { - const rows: EnvironmentPickerRow[] = [] +// One row per name, in the order the two lists give them: a stored secret the +// pane also carries is one row, not two. +function unionNames(first: readonly string[], second: readonly string[]): string[] { + const out = [...first] + for (const name of second) if (!out.includes(name)) out.push(name) + return out +} + +export function environmentPaneRows(input: EnvironmentPaneInput): EnvironmentPaneRow[] { + const rows: EnvironmentPaneRow[] = [] let hover = 0 - for (let at = 0; at < input.optionCount; at++) rows.push({ kind: 'option', at, hover: hover++ }) - rows.push({ kind: 'divider', label: CUSTOM_ENVIRONMENT_DIVIDER }) // No REPOSITORIES heading while there are no rows to sit under it — the repo // list is empty until the fetch lands (or when the account has none). if (input.repoNames.length > 0) { @@ -594,21 +605,19 @@ export function environmentPickerRows(input: EnvironmentPickerInput): Environmen } } rows.push({ kind: 'heading', label: MCP_SERVERS_HEADING }) - for (const name of input.builtInMcpServers) + for (const name of unionNames( + input.builtInMcpServers, + input.mcpServers.map((s) => s.name), + )) { rows.push({ kind: 'mcpServer', name, hover: hover++ }) - // Like variables: a typed name that is also a built-in rides that row. - for (const s of input.customMcpServers) { - if (input.builtInMcpServers.includes(s.name)) continue - rows.push({ kind: 'mcpServer', name: s.name, hover: hover++ }) } rows.push({ kind: 'addMcpServer', hover: hover++ }) rows.push({ kind: 'heading', label: VARIABLES_HEADING }) - for (const name of input.secretNames) rows.push({ kind: 'secret', name, hover: hover++ }) - // A variable typed here whose name is also a secret rides that secret's row - // instead of getting a second one: one name, one row, whichever way it got in. - for (const v of input.customVariables) { - if (input.secretNames.includes(v.name)) continue - rows.push({ kind: 'variable', name: v.name, hover: hover++ }) + for (const name of unionNames( + input.secretNames, + input.variables.map((v) => v.name), + )) { + rows.push({ kind: 'variable', name, hover: hover++ }) } rows.push({ kind: 'addVariable', hover: hover++ }) rows.push({ kind: 'heading', label: IMAGE_HEADING }) @@ -620,55 +629,165 @@ export function environmentPickerRows(input: EnvironmentPickerInput): Environmen return rows } -// Where a hover index lands, clamped to the list. -export function environmentPickerAt( - input: EnvironmentPickerInput, +// Where a hover index lands, clamped to the pane. +export function environmentPaneAt( + input: EnvironmentPaneInput, hover: number, -): EnvironmentTarget { - const rows = environmentPickerRows(input) - const landable = rows.filter( - (r): r is Extract => 'hover' in r, +): EnvironmentPaneTarget { + const landable = environmentPaneRows(input).filter( + (r): r is Extract => 'hover' in r, ) const row = landable[Math.min(Math.max(0, hover), landable.length - 1)] - if (row.kind === 'option') return { kind: 'option', at: row.at } - if (row.kind === 'repo') return { kind: 'repo', fullName: row.fullName } - if (row.kind === 'repoRef') return { kind: 'repoRef', fullName: row.fullName } - if (row.kind === 'secret') return { kind: 'secret', name: row.name } - if (row.kind === 'variable') return { kind: 'variable', name: row.name } - if (row.kind === 'compute') return { kind: 'compute', field: row.field } - if (row.kind === 'image') return { kind: 'image', field: row.field } - if (row.kind === 'hook') return { kind: 'hook', field: row.field } - if (row.kind === 'mcpServer') return { kind: 'mcpServer', name: row.name } - if (row.kind === 'addMcpServer') return { kind: 'addMcpServer' } - return { kind: 'addVariable' } -} - -export function environmentPickerCount(input: EnvironmentPickerInput): number { - return environmentPickerRows(input).filter((r) => 'hover' in r).length -} - -// What the resting Environment row says: the picked environment, plus a count -// of whatever the custom section adds on top of it. -export function environmentRowSummary( - label: string, - customVariables: readonly CustomVariable[], - customRepositories: readonly CustomRepository[] = [], - customCompute: CustomCompute = EMPTY_COMPUTE, - customImage: CustomImage = EMPTY_IMAGE, - customHooks: CustomHooks = EMPTY_HOOKS, - customMcpServers: readonly CustomMcpServer[] = [], + const { hover: _at, ...target } = row + return target as EnvironmentPaneTarget +} + +export function environmentPaneCount(input: EnvironmentPaneInput): number { + return environmentPaneRows(input).filter((r) => 'hover' in r).length +} + +// What the pane holds: the next run's sandbox, whole. Seeded from the picked +// environment (environmentPane), then edited in place — and once edited it is +// what ships, so what you read here is what you get. +export interface EnvironmentPaneState { + repositories: readonly CustomRepository[] + variables: readonly CustomVariable[] + mcpServers: readonly CustomMcpServer[] + compute: CustomCompute + // Scripts keep their newlines here even though their rows are one line — the + // pane ships what it holds, so flattening for display must not reach the wire. + image: CustomImage + hooks: CustomHooks +} + +export const EMPTY_PANE: EnvironmentPaneState = { + repositories: [], + variables: [], + mcpServers: [], + compute: EMPTY_COMPUTE, + image: EMPTY_IMAGE, + hooks: EMPTY_HOOKS, +} + +// How an MCP server entry names itself, across the shapes the config admits: a +// bare string opts into a built-in, an object carries its name. +export function mcpServerName(server: unknown): string { + if (typeof server === 'string') return server + const name = (server as { name?: unknown })?.name + return typeof name === 'string' ? name : '' +} + +// An environment entry's repository as the connected list names it. A YAML entry +// may omit `owner` ("name: ellipsis"), and the same repository is then one row, +// not two — so a bare name resolves against the connected repositories. Only an +// unambiguous match counts: two owners with the same repo name would be a guess. +export function resolveRepoFullName( + fullName: string, + repoNames: readonly string[], ): string { - const extras: string[] = [] - const r = customRepositories.length - if (r > 0) extras.push(`+${r} repo${r === 1 ? '' : 's'}`) - const v = customVariables.length - if (v > 0) extras.push(`+${v} variable${v === 1 ? '' : 's'}`) - const m = customMcpServers.length - if (m > 0) extras.push(`+${m} mcp`) - if (Object.keys(computeOverride(customCompute)).length > 0) extras.push('+compute') - if (Object.keys(fieldsOverride(customImage)).length > 0) extras.push('+image') - if (Object.keys(fieldsOverride(customHooks)).length > 0) extras.push('+hooks') - return extras.length === 0 ? label : `${label} ${extras.join(' ')}` + if (fullName.includes('/') || repoNames.includes(fullName)) return fullName + const matches = repoNames.filter((name) => name.slice(name.indexOf('/') + 1) === fullName) + return matches.length === 1 ? matches[0] : fullName +} + +// A saved environment's config as the pane's starting state. Every field the +// pane can show, resolved to the strings its rows edit; anything the config +// leaves unset stays blank, which reads as "whatever the server resolves". +export function environmentPane( + config: + | { + repositories?: readonly { owner?: string | null; name: string; ref?: string | null }[] + variables?: readonly { name: string; value?: string | null }[] + mcp_servers?: readonly unknown[] + compute?: { cpu?: number | null; memory?: unknown; timeout?: unknown } | null + image?: { dockerfile_append?: string | null; setup?: string | null } | null + hooks?: { post_start?: string | null; post_clone?: string | null } | null + } + | null + | undefined, + // The connected repositories, so an entry that omitted its owner lands on the + // row it belongs to instead of growing one of its own. + repoNames: readonly string[] = [], +): EnvironmentPaneState { + if (!config) return EMPTY_PANE + const compute = config.compute + return { + repositories: (config.repositories ?? []).map((r) => ({ + fullName: resolveRepoFullName(r.owner ? `${r.owner}/${r.name}` : r.name, repoNames), + ref: r.ref ?? null, + })), + variables: (config.variables ?? []).map((v) => ({ name: v.name, value: v.value ?? null })), + mcpServers: (config.mcp_servers ?? []) + .map((s) => ({ + name: mcpServerName(s), + command: typeof s === 'string' ? null : ((s as { command?: string }).command ?? null), + url: typeof s === 'string' ? null : ((s as { url?: string }).url ?? null), + raw: s, + })) + .filter((s) => s.name !== ''), + compute: { + cpu: compute?.cpu != null ? String(compute.cpu) : '', + memory: typeof compute?.memory === 'string' ? compute.memory : '', + timeout: typeof compute?.timeout === 'string' ? compute.timeout : '', + }, + image: { + dockerfile_append: config.image?.dockerfile_append ?? '', + setup: config.image?.setup ?? '', + }, + hooks: { + post_start: config.hooks?.post_start ?? '', + post_clone: config.hooks?.post_clone ?? '', + }, + } +} + +// Whether the pane still says what the environment it was seeded from says. The +// moment it doesn't, the run is "custom": no saved environment is checked and +// the pane ships in full. +export function paneEquals(a: EnvironmentPaneState, b: EnvironmentPaneState): boolean { + return JSON.stringify(paneKey(a)) === JSON.stringify(paneKey(b)) +} + +// The pane compared field by field, with the list orders normalized — a repo +// checked and unchecked again is the same sandbox even if it moved in the list. +function paneKey(p: EnvironmentPaneState): unknown { + return { + repositories: [...p.repositories] + .map((r) => `${r.fullName}@${r.ref ?? ''}`) + .sort(), + variables: [...p.variables].map((v) => `${v.name}=${v.value ?? ''}`).sort(), + mcpServers: [...p.mcpServers] + .map((s) => `${s.name}|${s.command ?? ''}|${s.url ?? ''}`) + .sort(), + compute: p.compute, + image: p.image, + hooks: p.hooks, + } +} + +// A script flattened to the one line its row is: newlines and runs of space +// collapse. Display only — paneEquals and the wire see the raw value. +export function oneLine(s: string): string { + return s.replace(/\s+/g, ' ').trim() +} + +// How many lines of a script row print before the rest is elided. A Dockerfile +// or setup script is often long, and the pane sits above everything else in the +// launcher, so the row states its shape rather than its whole contents. +export const SCRIPT_ROW_LINES = 5 + +// A script as its row prints it: the lines, capped at SCRIPT_ROW_LINES with a +// count of what was left off, unless the row is open (then all of them). +// +// `truncated` is what the row appends — never silently dropped, since a hidden +// line is a hidden instruction to the sandbox. +export function scriptRowLines( + value: string, + expanded: boolean, +): { lines: string[]; hidden: number } { + const lines = value.split('\n') + if (expanded || lines.length <= SCRIPT_ROW_LINES) return { lines, hidden: 0 } + return { lines: lines.slice(0, SCRIPT_ROW_LINES), hidden: lines.length - SCRIPT_ROW_LINES } } // How a variable reads in the custom section: the name alone when the sandbox @@ -705,48 +824,22 @@ export function repositoryRefLabel( return ref ?? defaultBranch ?? null } -// The composer's picks, as the new-session pane reports them. environment and -// model null = that row was never touched, so the server resolves it (the -// environment ladder, the account's default model). `emptyEnvironment` is the -// built-in "[empty]" pick: no saved environment, and the resolved lists cleared. +// The composer's picks, as the launcher reports them. `model` null = that row +// was never touched, so the server resolves it (the account's default model). +// +// `environment` is the saved environment the pane still matches. When it is +// null the pane is what says the sandbox, and it ships in full — every list in +// an override replaces the resolved one, so nothing the pane doesn't show can +// reach the run. +// +// `pane` null is the third case: nothing about the environment is stated at all, +// so the server's own ladder resolves it. That is what an untouched "Default" +// row means, and the honest thing to send when the environment list never +// loaded — an explicit empty pane there would wipe a default we never saw. export interface ComposerChoices { environment: string | null model: string | null - emptyEnvironment: boolean - // The picked environment's own lists, needed because an override array - // REPLACES the resolved list rather than appending to it. - baseVariables: readonly CustomVariable[] - customVariables: readonly CustomVariable[] - baseRepositories: readonly CustomRepository[] - customRepositories: readonly CustomRepository[] - // The picked environment's own servers ride the override verbatim (they can - // be shapes the launcher can't build, e.g. stdio with env). - baseMcpServers: readonly unknown[] - customMcpServers: readonly CustomMcpServer[] - // No base counterparts: these are scalars in merging object overrides, so - // unset fields keep the resolved values. - customCompute: CustomCompute - customImage: CustomImage - customHooks: CustomHooks -} - -// One variable list from the two that have to end up in the override, with a -// later name winning: a custom entry that repeats a base name is an edit of it, -// in place, not a duplicate the server would have to break the tie on. -export function mergeVariables( - base: readonly CustomVariable[], - custom: readonly CustomVariable[], -): CustomVariable[] { - const merged: CustomVariable[] = [] - const at = new Map() - for (const v of [...base, ...custom]) { - const seen = at.get(v.name) - if (seen === undefined) { - at.set(v.name, merged.length) - merged.push(v) - } else merged[seen] = v - } - return merged + pane: EnvironmentPaneState | null } // A variable in the shape an environment override takes: `value` omitted (not @@ -756,24 +849,6 @@ function variableEntry(v: CustomVariable): { name: string; value?: string } { return v.value === null ? { name: v.name } : { name: v.name, value: v.value } } -// One repository list from the two that have to end up in the override, with a -// later full name winning — same reading as mergeVariables. -export function mergeRepositories( - base: readonly CustomRepository[], - custom: readonly CustomRepository[], -): CustomRepository[] { - const merged: CustomRepository[] = [] - const at = new Map() - for (const r of [...base, ...custom]) { - const seen = at.get(r.fullName) - if (seen === undefined) { - at.set(r.fullName, merged.length) - merged.push(r) - } else merged[seen] = r - } - return merged -} - // A repository in the shape an environment override takes: owner and name // split, `ref` omitted (not null) for the default branch, since the config // schema treats an absent ref as "the repo's default". @@ -787,67 +862,40 @@ function repositoryEntry(r: CustomRepository): { owner?: string; name: string; r return entry } -// The entry point's base request with the composer's picks layered on: the -// environment as the session's own choice, the model and any custom -// environment edits as a per-run override (the dashboard composer's shape). +// The entry point's base request with the launcher's picks layered on. // -// Every list in an override REPLACES the resolved one, so a custom variable -// ships alongside the picked environment's own — that is what makes the custom -// section additive rather than a silent wipe of the environment it sits under. +// A named environment is the session's own choice and ships alone: the pane +// still matches it, so re-stating its lists would only risk saying it worse. +// Without a name the run is custom, and every list in the override REPLACES the +// resolved one — which is exactly what the pane means. Its lists therefore ship +// unconditionally, empty included: an empty repositories array is how "no repos" +// is said, and omitting it would let the ladder resolve some. export function applyComposerChoices( base: StartAgentSessionRequest, choices: ComposerChoices, ): StartAgentSessionRequest { const req: StartAgentSessionRequest = { ...base } - // Never combined with a config source: the launcher sends no config_id, so - // the environment is always the session's to name (the server 400s both). - if (choices.environment) req.environment = choices.environment const override: Record = {} if (choices.model) override.claude = { model: choices.model } - const environment: Record = {} - // "[empty]" names no environment, so the ladder would still resolve one: - // clearing the lists is what actually empties the sandbox. - if (choices.emptyEnvironment) { - environment.repositories = [] - environment.mcp_servers = [] - } - const variables = mergeVariables( - choices.emptyEnvironment ? [] : choices.baseVariables, - choices.customVariables, - ) - if (choices.emptyEnvironment || choices.customVariables.length > 0) { - environment.variables = variables.map(variableEntry) - } - const repositories = mergeRepositories( - choices.emptyEnvironment ? [] : choices.baseRepositories, - choices.customRepositories, - ) - if (choices.emptyEnvironment || choices.customRepositories.length > 0) { - environment.repositories = repositories.map(repositoryEntry) - } - // MCP servers merge like the other lists: the base definitions verbatim, - // then the servers added here whose names the base doesn't already carry. - if (!choices.emptyEnvironment && choices.customMcpServers.length > 0) { - const baseNames = new Set( - choices.baseMcpServers.map((s) => - typeof s === 'string' ? s : ((s as { name?: string }).name ?? ''), - ), - ) - environment.mcp_servers = [ - ...choices.baseMcpServers, - ...choices.customMcpServers.filter((s) => !baseNames.has(s.name)).map(mcpServerEntry), - ] - } - if (choices.emptyEnvironment && choices.customMcpServers.length > 0) { - environment.mcp_servers = choices.customMcpServers.map(mcpServerEntry) + // Never combined with a config source: the launcher sends no config_id, so + // the environment is always the session's to name (the server 400s both). + if (choices.environment) { + req.environment = choices.environment + } else if (choices.pane) { + const pane = choices.pane + const environment: Record = { + repositories: pane.repositories.map(repositoryEntry), + variables: pane.variables.map(variableEntry), + mcp_servers: pane.mcpServers.map(mcpServerEntry), + } + const compute = computeOverride(pane.compute) + if (Object.keys(compute).length > 0) environment.compute = compute + const image = fieldsOverride(pane.image) + if (Object.keys(image).length > 0) environment.image = image + const hooks = fieldsOverride(pane.hooks) + if (Object.keys(hooks).length > 0) environment.hooks = hooks + override.environment = environment } - const compute = computeOverride(choices.customCompute) - if (Object.keys(compute).length > 0) environment.compute = compute - const image = fieldsOverride(choices.customImage) - if (Object.keys(image).length > 0) environment.image = image - const hooks = fieldsOverride(choices.customHooks) - if (Object.keys(hooks).length > 0) environment.hooks = hooks - if (Object.keys(environment).length > 0) override.environment = environment if (Object.keys(override).length > 0) req.override = override return req } diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index ee22e0d..c35359f 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -24,28 +24,26 @@ import { composerModelOptions, composerPickerRows, connectability, + CUSTOM_ENVIRONMENT_ID, + CUSTOM_ENVIRONMENT_LABEL, EMPTY_ENVIRONMENT_ID, + EMPTY_PANE, environmentOptions, - environmentPickerAt, - environmentPickerCount, - environmentPickerRows, - environmentRowSummary, + environmentPane, + environmentPaneAt, + environmentPaneCount, + environmentPaneRows, environmentSourceLabel, + paneEquals, parseVariableEntry, repositoryRefLabel, + scriptRowLines, variableRowLabel, - EMPTY_COMPUTE, - EMPTY_HOOKS, - EMPTY_IMAGE, type ComposerChoices, type ComposerModel, - type ComputeField, - type CustomCompute, - type CustomHooks, - type CustomImage, type CustomMcpServer, - type CustomRepository, type CustomVariable, + type EnvironmentPaneState, validateMcpServer, rowDescription, rowGlyph, @@ -521,9 +519,8 @@ const PICKER_ROWS: readonly PickerRow[] = [ { key: 'model', label: 'Model' }, ] -// What the prompt box says before you type: the whole key map for the block, so -// nothing about the launcher has to be remembered. -const PROMPT_HINT = 'Enter to start a session, up to configure it, down to explore old sessions...' +// What the empty prompt box says: what this box is for, nothing else. +const PROMPT_HINT = 'Start a cloud session...' // The variable form's placeholders, each stating what leaving that field empty // means, in the field it applies to. @@ -564,14 +561,24 @@ type ServerEditor = { const SERVER_EDITOR_FIELDS = ['name', 'command', 'url'] as const +// A script field opened for editing: a real multi-line caret, since a Dockerfile +// or a setup script is written in lines. Arrows move within the text, enter +// inserts a newline, esc commits and collapses the row back to its summary. +type ScriptEditor = { + section: 'image' | 'hooks' + field: string + text: string + cursor: number +} + // The launcher: one painted box holding everything about the next session — // the configuration rows on top, the prompt under them — with history below — // -// | ▶ Environment: backend-sandbox (account default) -// | Model: claude-opus-5 +// | ▶ ENVIRONMENT: backend-sandbox (account default) +// | MODEL: claude-opus-5 // | -// | Enter to start a session, up to configure it, down to explore old… +// | Start a cloud session... // // Recent sessions: @me in account // ● latest session $0.20, 2m ago @@ -585,34 +592,65 @@ const SERVER_EDITOR_FIELDS = ['name', 'command', 'url'] as const // nothing swaps out and the session list stays. ↓ walks down into the list, // where enter opens a session. // -// All three rows share one glyph gutter, so the ▶ moves down a single left -// edge; the box's accent bar stays lit throughout, marking the block rather -// than any one row. Typing anywhere returns the cursor to the prompt. Agent -// configs stay a CLI choice (`agent session start -c`), since a config decides -// its own environment and the server refuses both at once. +// Opening the Environment row shows the list AND, under it, the configuration +// pane — the environment the cursor is on, read out rather than trusted — +// +// | ENVIRONMENT: backend-sandbox (account default) +// | ▶ [x] backend-sandbox (default for acme/api) +// | [ ] web-e2e (account default) +// | [ ] [empty] +// | REPOSITORIES +// | [x] acme/api +// | branch: main +// | [ ] acme/web +// | MCP SERVERS +// | [x] linear +// | + new +// | VARIABLES +// | [x] API_TOKEN +// | + new +// | IMAGE +// | dockerfile_append: | +// | RUN apt-get update && apt-get install -y gpgv +// | … 4 more lines +// | setup: +// | HOOKS +// | post_start: +// | post_clone: +// | COMPUTE +// | cpu: 4 +// | memory: +// | timeout: +// | MODEL: claude-opus-5 +// +// The row's own ▶ goes away while its list is open — the caret is on whichever +// list or pane row the cursor is on, and two carets would say the cursor is in +// two places. ↑/↓ run down the options and on into the pane, one walk; esc +// closes both. // -// The open Environment list has two halves — +// Walking the options MOVES THE PANE: it always reads out the row the cursor is +// on, so the list is how you compare environments rather than a list of names +// you have to already know. Nothing is checked by looking — enter picks. // -// [x] backend-sandbox (default for acme/api) -// [ ] web-e2e (account default) -// [ ] [empty] -// ─── custom environment ─── -// VARIABLES -// [x] API_TOKEN -// [ ] NPM_TOKEN -// [x] PORT=3000 -// + new variable -// name: SENTRY_DSN -// ▶ value: leave empty to pull from secrets +// The pane is the whole truth about the sandbox. Picking an environment seeds +// every one of its rows. Editing any row is what makes the run custom: every +// saved environment unchecks, a `custom` row appears at the bottom of the list +// and takes the check, and from then on the pane's own lists are what ship. +// Checking a saved environment again reseeds the pane and the `custom` row goes +// away. That is why unchecking a repository the environment brought in actually +// drops it: the pane replaces the resolved lists rather than adding to them (see +// applyComposerChoices). // -// — every saved environment plus the built-in [empty] above the divider, and -// below it what you can set for this run alone. Each environment names the -// default rungs it holds, and the one the ladder resolves for the cwd's repo -// starts out checked, so an untouched row SENDS the environment it shows. +// A script field (image, hooks) prints as a YAML block scalar over its own lines, +// capped at SCRIPT_ROW_LINES with a count of what is hidden. Enter opens it into +// a real multi-line editor — arrows move within the text, enter is a newline, esc +// commits — because a Dockerfile is written in lines, not on one. // -// The two halves compose: a variable added down there layers ON TOP of whichever -// environment is checked above (see applyComposerChoices, which has to re-send -// that environment's own variables because an override array replaces the list). +// The rows share one glyph gutter, so the ▶ moves down a single left edge; the +// box's accent bar stays lit throughout, marking the block rather than any one +// row. Typing anywhere outside an input row returns the cursor to the prompt. +// Agent configs stay a CLI choice (`agent session start -c`), since a config +// decides its own environment and the server refuses both at once. function Launcher({ width, whoLine, @@ -675,25 +713,30 @@ function Launcher({ // server-resolved pick (see modelIdx). The list arrives async, so there is no // index to seed this with at mount. const [modelPick, setModelPick] = useState(null) - // The open row's dropdown state: which picker is open and where its - // highlight sits. null = no subtree open. - const [openPicker, setOpenPicker] = useState<{ key: PickerRow['key']; hover: number } | null>( - null, - ) - // What the custom section below the divider adds on top of the picked - // environment. Survives closing and reopening the list, so a variable typed - // before choosing an environment is not lost. - const [customVariables, setCustomVariables] = useState([]) - const [customRepositories, setCustomRepositories] = useState([]) - const [customCompute, setCustomCompute] = useState(EMPTY_COMPUTE) - const [customImage, setCustomImage] = useState(EMPTY_IMAGE) - const [customHooks, setCustomHooks] = useState(EMPTY_HOOKS) - const [customMcpServers, setCustomMcpServers] = useState([]) + // The open row's dropdown state: which picker is open and where its highlight + // sits. null = no subtree open. + // + // `hover` is always an option index. `paneHover` is where the cursor sits in + // the configuration pane below the open Environment list, or null while the + // cursor is on the options — kept apart from `hover` so walking down into the + // pane and back up returns to the option you left, and so the pane always + // knows which environment it is showing. + const [openPicker, setOpenPicker] = useState<{ + key: PickerRow['key'] + hover: number + paneHover: number | null + } | null>(null) + // The configuration pane: this run's sandbox, whole. null = untouched, so it + // shows (and sends) whatever the picked environment resolves to; the first + // edit seeds it and from then on the pane is the truth. + const [pane, setPane] = useState(null) // The "+ new server" form's state, or null while closed. const [serverEditor, setServerEditor] = useState(null) // The open custom key's editor, or null. `field` is which of the two steps is // being typed; `name`/`value` hold what has been typed so far. const [editor, setEditor] = useState(null) + // The open script field's editor, or null while every script row is collapsed. + const [scriptEditor, setScriptEditor] = useState(null) // Both pickers deal in the same option shape (ComposerModel), so the // renderer can ask either of them for a group heading or a subtext; only the @@ -720,6 +763,9 @@ function Launcher({ environmentOptions(environments ?? [], environmentDefaults, detectedRepo, environmentSources), [environments, environmentDefaults, detectedRepo, environmentSources], ) + // The saved environments plus the built-in [empty]. The `custom` row the list + // grows once the pane diverges is NOT here: it names no environment, so it + // would have nothing to seed the pane from (see environmentRowsWithCustom). const environmentOptionRows = useMemo( () => environmentOptionList.map((o) => ({ id: o.id, label: o.label })), [environmentOptionList], @@ -728,76 +774,32 @@ function Launcher({ environmentPick !== null ? Math.min(environmentPick, environmentOptionRows.length - 1) : resolvedIdx - // The picked environment's own variables. They have to ride the override - // alongside the custom ones, since an override array replaces the resolved - // list rather than appending to it. const pickedEnvironment = environmentOptionRows[environmentIdx] - const baseVariables = useMemo(() => { - const id = pickedEnvironment?.id - if (!id || id === EMPTY_ENVIRONMENT_ID) return [] - const found = (environments ?? []).find((e) => e.id === id) - return (found?.environment.variables ?? []).map((v) => ({ - name: v.name, - value: v.value ?? null, - })) - }, [environments, pickedEnvironment]) - // The picked environment's own compute and image, as the resting - // placeholders of their input rows. Display only: object overrides merge key - // by key, so unlike the lists these never need re-sending. - const baseCompute = useMemo(() => { - const id = pickedEnvironment?.id - if (!id || id === EMPTY_ENVIRONMENT_ID) return EMPTY_COMPUTE - const found = (environments ?? []).find((e) => e.id === id) - const c = found?.environment.compute - return { - cpu: c?.cpu != null ? String(c.cpu) : '', - memory: typeof c?.memory === 'string' ? c.memory : '', - timeout: typeof c?.timeout === 'string' ? c.timeout : '', - } - }, [environments, pickedEnvironment]) - // A multi-line script flattens to one line for the placeholder — the row is - // one line, and it only has to say "something is set here". - const oneLine = (s: string | null | undefined): string => (s ?? '').replace(/\s+/g, ' ').trim() - const baseImage = useMemo(() => { - const id = pickedEnvironment?.id - if (!id || id === EMPTY_ENVIRONMENT_ID) return EMPTY_IMAGE - const found = (environments ?? []).find((e) => e.id === id) - const img = found?.environment.image - return { dockerfile_append: oneLine(img?.dockerfile_append), setup: oneLine(img?.setup) } - }, [environments, pickedEnvironment]) - const baseHooks = useMemo(() => { - const id = pickedEnvironment?.id - if (!id || id === EMPTY_ENVIRONMENT_ID) return EMPTY_HOOKS - const found = (environments ?? []).find((e) => e.id === id) - const hooks = found?.environment.hooks - return { post_start: oneLine(hooks?.post_start), post_clone: oneLine(hooks?.post_clone) } - }, [environments, pickedEnvironment]) - // The picked environment's own MCP servers, verbatim (they can be full - // stdio/remote definitions) — the override list replaces, so they ride along. - const baseMcpServers = useMemo(() => { - const id = pickedEnvironment?.id - if (!id || id === EMPTY_ENVIRONMENT_ID) return [] - const found = (environments ?? []).find((e) => e.id === id) - return found?.environment.mcp_servers ?? [] - }, [environments, pickedEnvironment]) - const baseMcpServerNames = useMemo( - () => - baseMcpServers - .map((s) => (typeof s === 'string' ? s : ((s as { name?: string }).name ?? ''))) - .filter(Boolean), - [baseMcpServers], - ) - // The picked environment's own repositories — same reason as baseVariables: - // an override array replaces the resolved list, so they have to ride along. - const baseRepositories = useMemo(() => { + // What the picked environment resolves to, as pane rows. This is what the pane + // shows until it is edited, and what "custom" is measured against. + const connectedRepoNames = useMemo(() => (repos ?? []).map((r) => r.full_name), [repos]) + const seededPane = useMemo(() => { const id = pickedEnvironment?.id - if (!id || id === EMPTY_ENVIRONMENT_ID) return [] - const found = (environments ?? []).find((e) => e.id === id) - return (found?.environment.repositories ?? []).map((r) => ({ - fullName: r.owner ? `${r.owner}/${r.name}` : r.name, - ref: r.ref ?? null, - })) - }, [environments, pickedEnvironment]) + if (!id || id === EMPTY_ENVIRONMENT_ID) return EMPTY_PANE + return environmentPane( + (environments ?? []).find((e) => e.id === id)?.environment, + connectedRepoNames, + ) + }, [environments, pickedEnvironment, connectedRepoNames]) + // The pane as the run would use it: the edits if there are any, else the + // picked environment's own values. + const shownPane = pane ?? seededPane + // Once the pane no longer says what the environment it was seeded from says, + // no environment is checked and the pane is what ships. + const isCustom = pane !== null && !paneEquals(pane, seededPane) + // Any environment read out as pane rows. + const paneOf = (id: string | null | undefined): EnvironmentPaneState => + !id || id === EMPTY_ENVIRONMENT_ID + ? EMPTY_PANE + : environmentPane( + (environments ?? []).find((e) => e.id === id)?.environment, + connectedRepoNames, + ) // The server's selectable set (GET /models); before it lands — and on an // older server that has no such route — the built-in fallback list. const modelOptions = useMemo(() => composerModelOptions(models ?? []), [models]) @@ -808,124 +810,148 @@ function Launcher({ const at = modelOptions.findIndex((o) => o.id === null) return at === -1 ? 0 : at }, [modelPick, modelOptions]) + // What the open Environment list shows: the saved environments, plus the + // `custom` row that appears at the bottom and takes the check once the pane + // has diverged. A real row, so ↓ reaches it — and picking it is a no-op that + // just closes the list, since the pane already says what it names. + const environmentRowsWithCustom = useMemo( + () => + isCustom + ? [...environmentOptionRows, { id: CUSTOM_ENVIRONMENT_ID, label: CUSTOM_ENVIRONMENT_LABEL }] + : environmentOptionRows, + [environmentOptionRows, isCustom], + ) const optionsFor = (key: PickerRow['key']) => - key === 'environment' ? environmentOptionRows : modelOptions + key === 'environment' ? environmentRowsWithCustom : modelOptions const pickedIdx = (key: PickerRow['key']): number => - key === 'environment' ? environmentIdx : modelIdx + key === 'environment' ? (isCustom ? environmentOptionRows.length : environmentIdx) : modelIdx const isPicked = (key: PickerRow['key'], at: number): boolean => at === Math.min(pickedIdx(key), optionsFor(key).length - 1) - const emptyPicked = pickedEnvironment?.id === EMPTY_ENVIRONMENT_ID - // Both rows single-pick, so activating an OPTION closes the dropdown. The - // Environment list's custom rows below the divider are not options: they - // toggle or type in place and keep the list up. + // Checking an environment drops the pane's edits, since the pane is a reading of + // whatever environment is checked. The `custom` row names no environment, so + // landing on it keeps the edits it stands for. + const pickEnvironment = (at: number): void => { + if (optionsFor('environment')[at]?.id === CUSTOM_ENVIRONMENT_ID) return + setEnvironmentPick(at) + setPane(null) + } + // Both rows single-pick, so activating an option closes the dropdown. const activate = (key: PickerRow['key'], at: number): void => { - if (key === 'environment') setEnvironmentPick(at) + if (key === 'environment') pickEnvironment(at) else setModelPick(at) setOpenPicker(null) } - // The open Environment list's shape, shared by its navigation and its - // renderer: the options, then the custom section's secrets, typed variables - // and add button. - // Whether a repository is in the run: checked in the custom section, or - // brought in by the picked environment itself. - const repositoryFor = (fullName: string): CustomRepository | undefined => - customRepositories.find((r) => r.fullName === fullName) ?? - baseRepositories.find((r) => r.fullName === fullName) - const environmentPicker = useMemo( + // What the pane shows. While the Environment list is open it reads out the + // option the list's cursor is on — walking the list is how you see what each + // environment holds, and nothing is checked by looking. Closed, it is the run's + // own configuration. The `custom` row stands for the edits themselves, so it + // reads them rather than an environment. + // + // The list's own cursor doesn't move while you walk the pane below it, so the + // pane never switches out from under you mid-edit. + const previewed = (() => { + if (openPicker?.key !== 'environment') return undefined + const opt = environmentRowsWithCustom[Math.min(openPicker.hover, environmentRowsWithCustom.length - 1)] + return opt === undefined || opt.id === CUSTOM_ENVIRONMENT_ID || isPicked('environment', openPicker.hover) + ? undefined + : opt + })() + const displayPane = previewed ? paneOf(previewed.id) : shownPane + // Editing any row seeds the pane from what is on screen, so the first keystroke + // doesn't silently drop the rest of the environment. Editing an environment you + // had only been LOOKING at checks it first: the edit is of what you can see, and + // leaving the previous pick checked would apply your change to something else. + const editPane = (edit: (p: EnvironmentPaneState) => EnvironmentPaneState): void => { + if (previewed) { + setEnvironmentPick(environmentOptionRows.findIndex((o) => o.id === previewed.id)) + setPane(edit(displayPane)) + return + } + setPane((prev) => edit(prev ?? seededPane)) + } + // Whether a repository is in the run, and at which ref. + const repositoryFor = (fullName: string) => + displayPane.repositories.find((r) => r.fullName === fullName) + // The pane's shape, shared by its navigation and its renderer. + const paneInput = useMemo( () => ({ - optionCount: environmentOptionRows.length, - repoNames: (repos ?? []).map((r) => r.full_name), + // The connected repositories, plus any the picked environment named that + // aren't among them — the pane is the whole truth, so a repo that will be + // cloned has to be visible (and uncheckable) even if it is no longer + // connected. + repoNames: [ + ...connectedRepoNames, + ...displayPane.repositories + .map((r) => r.fullName) + .filter((name) => !connectedRepoNames.includes(name)), + ], // A checked repo grows its branch input row, so ↓ can land on it. - checkedRepoNames: (repos ?? []) - .map((r) => r.full_name) - .filter( - (name) => - customRepositories.some((r) => r.fullName === name) || - baseRepositories.some((r) => r.fullName === name), - ), + checkedRepoNames: displayPane.repositories.map((r) => r.fullName), secretNames: secretNames ?? [], - customVariables, + variables: displayPane.variables, builtInMcpServers: builtInServers, - customMcpServers, + mcpServers: displayPane.mcpServers, }), - [ - environmentOptionRows.length, - repos, - secretNames, - customVariables, - customRepositories, - baseRepositories, - builtInServers, - customMcpServers, - ], + [connectedRepoNames, secretNames, displayPane, builtInServers], ) - const environmentHoverCount = environmentPickerCount(environmentPicker) - // Whether a variable of this name is in the run: a checked secret and a typed - // entry are the same thing once committed. + const paneRowCount = environmentPaneCount(paneInput) + // Whether a variable of this name is in the run, and at which value. const variableFor = (name: string): CustomVariable | undefined => - customVariables.find((v) => v.name === name) - // Enter (or →) on a row of the open Environment list: an option picks and - // closes; a repo or secret toggles; a typed variable re-opens for editing; - // the add button opens an empty editor. - const activateEnvironmentRow = (hover: number): void => { - const row = environmentPickerAt(environmentPicker, hover) - if (row.kind === 'option') { - activate('environment', row.at) + displayPane.variables.find((v) => v.name === name) + // Enter (or →) on a pane row: a repo, server or variable toggles; a variable + // already in the run re-opens for editing; the add buttons open a form; a + // script field opens its multi-line editor. The branch and compute rows are + // one-line inputs — enter there is a no-op, typing is what edits them. + const activatePaneRow = (hover: number): void => { + const row = environmentPaneAt(paneInput, hover) + if (row.kind === 'image' || row.kind === 'hook') { + const section = row.kind === 'image' ? 'image' : 'hooks' + const text = displayPane[section][row.field as never] as string + setScriptEditor({ section, field: row.field, text, cursor: text.length }) return } if (row.kind === 'repo') { - // Unchecking drops the custom entry; a base repo (the picked - // environment's own) stays, since an additive list can't remove it. - setCustomRepositories((prev) => - prev.some((r) => r.fullName === row.fullName) - ? prev.filter((r) => r.fullName !== row.fullName) - : [...prev, { fullName: row.fullName, ref: null }], - ) + editPane((p) => ({ + ...p, + repositories: p.repositories.some((r) => r.fullName === row.fullName) + ? p.repositories.filter((r) => r.fullName !== row.fullName) + : [...p.repositories, { fullName: row.fullName, ref: null }], + })) return } - // The branch, compute, image and hook rows are text inputs, not toggles: - // enter there is a no-op, typing is what edits them (see the handler). - if ( - row.kind === 'repoRef' || - row.kind === 'compute' || - row.kind === 'image' || - row.kind === 'hook' - ) - return if (row.kind === 'mcpServer') { - // Toggling off a base server can't be expressed additively, so only - // servers added here uncheck (mirrors the repo rows). - setCustomMcpServers((prev) => - prev.some((s) => s.name === row.name) - ? prev.filter((s) => s.name !== row.name) - : [...prev, { name: row.name, command: null, url: null }], - ) + editPane((p) => ({ + ...p, + mcpServers: p.mcpServers.some((s) => s.name === row.name) + ? p.mcpServers.filter((s) => s.name !== row.name) + : [...p.mcpServers, { name: row.name, command: null, url: null }], + })) return } if (row.kind === 'addMcpServer') { setServerEditor({ field: 'name', name: '', command: '', url: '', error: null }) return } - if (row.kind === 'secret') { - // Values are write-only, so a checked secret is a variable with no value: - // the sandbox resolves the name from stored secrets at start. - setCustomVariables((prev) => - prev.some((v) => v.name === row.name) - ? prev.filter((v) => v.name !== row.name) - : [...prev, { name: row.name, value: null }], - ) - return - } if (row.kind === 'variable') { - setEditor({ - field: 'value', - name: row.name, - value: variableFor(row.name)?.value ?? '', - error: null, - }) + const held = variableFor(row.name) + // A valueless name is a plain checkbox — it ships the name alone and the + // sandbox resolves the value from stored secrets — so enter toggles it. + // One carrying a value opens for editing instead, since a value typed here + // is worth more than the keystroke it took and enter should not drop it. + if (held === undefined) { + editPane((p) => ({ ...p, variables: [...p.variables, { name: row.name, value: null }] })) + return + } + if (held.value === null) { + editPane((p) => ({ ...p, variables: p.variables.filter((v) => v.name !== row.name) })) + return + } + setEditor({ field: 'value', name: row.name, value: held.value, error: null }) return } - setEditor({ field: 'name', name: '', value: '', error: null }) + if (row.kind === 'addVariable') { + setEditor({ field: 'name', name: '', value: '', error: null }) + } } // Enter commits from either field. A name that isn't a legal shell identifier // keeps the form open with the reason. An empty value means the sandbox @@ -940,51 +966,46 @@ function Launcher({ // "NAME=value" typed into the name field carries its own value, so it wins // over the (necessarily untouched) value field. const value = parsed.value !== null ? parsed.value : editor.value === '' ? null : editor.value - setCustomVariables((prev) => { - const next = [...prev] + editPane((p) => { + const next = [...p.variables] const entry = { name: parsed.name, value } // A repeat of a name already in the list replaces it in place, so the // second typing of a name reads as an edit and not a duplicate row. const existing = next.findIndex((v) => v.name === parsed.name) if (existing !== -1) next[existing] = entry else next.push(entry) - return next + return { ...p, variables: next } }) setEditor(null) } // Typing on a checked repo's branch row edits its ref in place: backspace - // erases, an emptied ref returns to the default branch (null). A base repo - // whose ref was edited becomes a custom entry, which wins the merge. + // erases, an emptied ref returns to the default branch (null). const editRepositoryRef = (fullName: string, edit: (ref: string) => string): void => { - const current = repositoryFor(fullName)?.ref ?? '' - const next = edit(current) - const entry = { fullName, ref: next === '' ? null : next } - setCustomRepositories((prev) => { - const existing = prev.findIndex((r) => r.fullName === fullName) - if (existing === -1) return [...prev, entry] - const out = [...prev] - out[existing] = entry - return out + editPane((p) => { + const existing = p.repositories.findIndex((r) => r.fullName === fullName) + const ref = edit(p.repositories[existing]?.ref ?? '') + const entry = { fullName, ref: ref === '' ? null : ref } + const next = [...p.repositories] + if (existing === -1) next.push(entry) + else next[existing] = entry + return { ...p, repositories: next } }) } // Enter with an empty prompt is a real start: the session comes up idle and // waits for the first message, so you can open a sandbox before you know // what to ask it. + // + // A checked environment ships by name and the pane stays home; once the pane + // has diverged (or [empty] is checked, which is the pane emptied) it ships + // instead, and an untouched "Default" row ships neither — the server's own + // ladder is what that row names. const submit = (): void => { + const named = !isCustom && pickedEnvironment?.id !== EMPTY_ENVIRONMENT_ID onSubmit(text.trim(), { - environment: emptyPicked ? null : (pickedEnvironment?.id ?? null), + environment: named ? (pickedEnvironment?.id ?? null) : null, model: modelOptions[modelIdx]?.id ?? null, - emptyEnvironment: emptyPicked, - baseVariables, - customVariables, - baseRepositories, - customRepositories, - customCompute, - customImage, - customHooks, - baseMcpServers, - customMcpServers, + pane: named && pickedEnvironment?.id === null ? null : shownPane, }) } @@ -1005,6 +1026,66 @@ function Launcher({ useInput( (ch, key) => { + // An open script field owns every key: a Dockerfile is written in lines, so + // enter is a newline here rather than the commit it is everywhere else, and + // esc is what commits and collapses the row. + if (scriptEditor !== null) { + const { text: script, cursor: at } = scriptEditor + const set = (next: string, cursorAt: number): void => + setScriptEditor({ ...scriptEditor, text: next, cursor: cursorAt }) + // Every keystroke writes through to the pane, so the row under the editor + // is never out of date with what is being typed. + const write = (next: string, cursorAt: number): void => { + set(next, cursorAt) + editPane((p) => ({ + ...p, + [scriptEditor.section]: { + ...p[scriptEditor.section], + [scriptEditor.field]: next, + }, + })) + } + if (key.escape) { + setScriptEditor(null) + return + } + if (key.return) { + write(script.slice(0, at) + '\n' + script.slice(at), at + 1) + return + } + if (key.leftArrow) { + set(script, Math.max(0, at - 1)) + return + } + if (key.rightArrow) { + set(script, Math.min(script.length, at + 1)) + return + } + // ↑/↓ move a line at a time, keeping the column where it can. + if (key.upArrow || key.downArrow) { + const lineStart = script.lastIndexOf('\n', at - 1) + 1 + const column = at - lineStart + if (key.upArrow) { + if (lineStart === 0) return + const prevStart = script.lastIndexOf('\n', lineStart - 2) + 1 + set(script, Math.min(prevStart + column, lineStart - 1)) + return + } + const lineEnd = script.indexOf('\n', at) + if (lineEnd === -1) return + const nextEnd = script.indexOf('\n', lineEnd + 1) + set(script, Math.min(lineEnd + 1 + column, nextEnd === -1 ? script.length : nextEnd)) + return + } + if (key.backspace || key.delete) { + if (at > 0) write(script.slice(0, at - 1) + script.slice(at), at - 1) + return + } + if (ch && !key.ctrl && !key.meta) { + write(script.slice(0, at) + ch + script.slice(at), at + ch.length) + } + return + } // The "+ new server" form: like the variable form, it owns every key // while up. ↑/↓ walk the three fields, enter commits, esc backs out. if (serverEditor !== null) { @@ -1023,11 +1104,11 @@ function Launcher({ setServerEditor({ ...serverEditor, error }) return } - setCustomMcpServers((prev) => [ + editPane((p) => ({ + ...p, // A repeat of a name is an edit of it, not a duplicate row. - ...prev.filter((s) => s.name !== entry.name), - entry, - ]) + mcpServers: [...p.mcpServers.filter((s) => s.name !== entry.name), entry], + })) setServerEditor(null) return } @@ -1085,30 +1166,59 @@ function Launcher({ } return } - // An open dropdown is a modal subtree: ↑/↓ walk the rows, - // → (or enter/space) activates the highlighted one — an option picks and - // closes, a custom key opens its editor — ← (or esc) backs out. + // An open dropdown is a modal subtree: ↑/↓ walk its rows, + // → (or enter/space) activates the highlighted one, ← (or esc) backs out. + // + // The open Environment row is that plus the configuration pane under its + // options. ↑/↓ walk the options, and → (enter) on one CHECKS it and drops + // the cursor into the pane, which is that environment's configuration — so + // the same key that says "this one" is the one that takes you into it. if (openPicker !== null) { - const isEnv = openPicker.key === 'environment' - const rowCount = isEnv ? environmentHoverCount : optionsFor(openPicker.key).length - if (key.escape || key.leftArrow) { + const optionCount = optionsFor(openPicker.key).length + const paneWalk = openPicker.key === 'environment' ? paneRowCount : 0 + if (key.escape) { setOpenPicker(null) return } + // ← backs out of the pane to the options, then out of the list entirely. + if (key.leftArrow) { + if (openPicker.paneHover !== null) setOpenPicker((p) => p && { ...p, paneHover: null }) + else setOpenPicker(null) + return + } if (key.upArrow) { - setOpenPicker((p) => p && { ...p, hover: Math.max(0, p.hover - 1) }) + setOpenPicker( + (p) => + p && + (p.paneHover === null + ? { ...p, hover: Math.max(0, p.hover - 1) } + : // ↑ off the pane's first row returns to the option it reads out. + p.paneHover === 0 + ? { ...p, paneHover: null } + : { ...p, paneHover: p.paneHover - 1 }), + ) return } if (key.downArrow) { - setOpenPicker((p) => p && { ...p, hover: Math.min(rowCount - 1, p.hover + 1) }) + setOpenPicker( + (p) => + p && + (p.paneHover !== null + ? { ...p, paneHover: Math.min(paneWalk - 1, p.paneHover + 1) } + : // ↓ off the last option walks into the pane under it. + p.hover < optionCount - 1 + ? { ...p, hover: p.hover + 1 } + : paneWalk > 0 + ? { ...p, paneHover: 0 } + : p), + ) return } - // The branch and compute rows are text inputs under the cursor: typing - // and backspace edit them in place, blank = whatever the server - // resolves. Space stays list navigation (no ref or size holds one). - if (isEnv) { - const hover = Math.min(openPicker.hover, rowCount - 1) - const target = environmentPickerAt(environmentPicker, hover) + // A pane row is under the cursor: the branch and compute rows are + // one-line inputs edited by typing, blank = whatever the server resolves. + if (openPicker.paneHover !== null) { + const hover = Math.min(openPicker.paneHover, paneWalk - 1) + const target = environmentPaneAt(paneInput, hover) const typing = ch && ch !== ' ' && !key.ctrl && !key.meta && !key.return && !key.rightArrow if (target.kind === 'repoRef') { @@ -1123,44 +1233,26 @@ function Launcher({ } if (target.kind === 'compute') { if (key.backspace || key.delete) { - setCustomCompute((c) => ({ ...c, [target.field]: c[target.field].slice(0, -1) })) + editPane((p) => ({ + ...p, + compute: { ...p.compute, [target.field]: p.compute[target.field].slice(0, -1) }, + })) return } // cpu is a number on the wire, so its field only admits digits. if (typing && (target.field !== 'cpu' || /^[0-9.]$/.test(ch))) { - setCustomCompute((c) => ({ ...c, [target.field]: c[target.field] + ch })) - return - } - } - // Image and hook fields are shell/Dockerfile lines, so space is - // typed text — but only once something has been typed, so space on - // the untouched row still activates like everywhere else. - if (target.kind === 'image' || target.kind === 'hook') { - const typed = - target.kind === 'image' - ? customImage[target.field] - : customHooks[target.field] - const set = - target.kind === 'image' - ? (edit: (s: string) => string) => - setCustomImage((c) => ({ ...c, [target.field]: edit(c[target.field]) })) - : (edit: (s: string) => string) => - setCustomHooks((c) => ({ ...c, [target.field]: edit(c[target.field]) })) - const spaceTyping = ch === ' ' && typed !== '' && !key.ctrl && !key.meta - if (key.backspace || key.delete) { - set((s) => s.slice(0, -1)) - return - } - if (typing || spaceTyping) { - set((s) => s + ch) + editPane((p) => ({ + ...p, + compute: { ...p.compute, [target.field]: p.compute[target.field] + ch }, + })) return } } + if (key.return || key.rightArrow || ch === ' ') activatePaneRow(hover) + return } if (key.rightArrow || key.return || ch === ' ') { - const hover = Math.min(openPicker.hover, rowCount - 1) - if (isEnv) activateEnvironmentRow(hover) - else activate(openPicker.key, hover) + activate(openPicker.key, Math.min(openPicker.hover, optionCount - 1)) return } return @@ -1176,9 +1268,9 @@ function Launcher({ return } if (cursor.kind === 'option') { - // The configuration rows above the prompt: ↑ walks up them and stops at - // the first, ↓ off the last returns to the prompt, →/enter opens the - // row's list, esc returns to the prompt, typing does too. + // The two picker rows: ↑ walks up them and stops at the first, ↓ off the + // last returns to the prompt, →/enter opens the row's list, esc returns + // to the prompt, typing does too. if (key.upArrow) { if (cursor.at > 0) setCursor({ kind: 'option', at: cursor.at - 1 }) return @@ -1189,7 +1281,10 @@ function Launcher({ return } if (key.return || key.rightArrow) { - setOpenPicker({ key: PICKER_ROWS[cursor.at].key, hover: 0 }) + // The list opens on the checked row, so the walk starts where the run + // currently stands rather than at the top. + const key_ = PICKER_ROWS[cursor.at].key + setOpenPicker({ key: key_, hover: pickedIdx(key_), paneHover: null }) return } if (key.escape) { @@ -1269,23 +1364,13 @@ function Launcher({ { isActive: focused && rawMode }, ) - // The summary shown on a row: the pick's label, plus what the custom section - // adds on top of it. Never "loading…": every resting value is known locally, - // so a pending fetch has nothing to do with what this run would use. + // The value shown on a picker row: its pick's label, or "custom" once the pane + // has diverged from it. Never "loading…": every resting value is known + // locally, so a pending fetch has nothing to do with what this run would use. const rowValue = (key: PickerRow['key']): string => { + if (key === 'environment' && isCustom) return CUSTOM_ENVIRONMENT_LABEL const options = optionsFor(key) - const label = options[Math.min(pickedIdx(key), options.length - 1)]?.label ?? 'Default' - return key === 'environment' - ? environmentRowSummary( - label, - customVariables, - customRepositories, - customCompute, - customImage, - customHooks, - customMcpServers, - ) - : label + return options[Math.min(pickedIdx(key), options.length - 1)]?.label ?? 'Default' } // Columns available inside the prompt box: the terminal minus its left accent @@ -1295,15 +1380,11 @@ function Launcher({ const contentWidth = Math.max(1, width - 1 - PROMPT_PAD_X * 2) const open = openPicker const openOptions = open ? optionsFor(open.key) : [] - const openIsEnv = open?.key === 'environment' - const openRowCount = openIsEnv ? environmentHoverCount : openOptions.length - const openHover = open ? Math.min(open.hover, openRowCount - 1) : 0 + const openHover = open ? Math.min(open.hover, openOptions.length - 1) : -1 // Every option plus its group heading — an open dropdown prints the whole // list, so a long model list grows the block and the terminal scrolls rather - // than hiding rows behind a window. The Environment list adds the divider and - // its custom key rows below the options. - const visibleRows = open && !openIsEnv ? composerPickerRows(openOptions) : [] - const environmentRows = openIsEnv ? environmentPickerRows(environmentPicker) : [] + // than hiding rows behind a window. + const visibleRows = open ? composerPickerRows(openOptions) : [] // The price table's column widths: the label column, then one numeric column // per lane, each as wide as its widest cell (its heading included) so the // dollars read down a right-aligned column. null when there is no price to @@ -1328,6 +1409,304 @@ function Launcher({ (rate?.output ?? '').padStart(rateTable.output) : '' + // Which pane row the open Environment row's walk is on, or -1 while the cursor + // is still on the options above the pane. + const openPaneHover = open?.key === 'environment' ? (open.paneHover ?? -1) : -1 + + // The configuration pane: the checked environment read out, section by section, + // under the list that names it. Its rows continue the open row's single walk, + // so ↓ off the last option lands on the first pane row. + const renderPane = (): React.ReactNode => + environmentPaneRows(paneInput).map((paneRow) => { + if (paneRow.kind === 'heading') { + return ( + + + {' '} + {paneRow.label.toUpperCase()} + + + ) + } + // A form owns the caret while it is open, so the pane's own highlight goes + // dark rather than showing a second one. + const hovered = + focused && + openPaneHover === paneRow.hover && + editor === null && + serverEditor === null && + scriptEditor === null + const glyph = {hovered ? SELECTION_GLYPH : ' '} + if (paneRow.kind === 'repo') { + const checked = repositoryFor(paneRow.fullName) !== undefined + return ( + + + {glyph}{' '} + + {` [${checked ? 'x' : ' '}] ${paneRow.fullName}`} + + + + ) + } + // A checked repo's branch input: typing edits the ref in place; empty rests + // on the repo's default branch. + if (paneRow.kind === 'repoRef') { + const ref = repositoryFor(paneRow.fullName)?.ref ?? null + const resting = repositoryRefLabel( + ref, + (repos ?? []).find((r) => r.full_name === paneRow.fullName)?.default_branch ?? null, + ) + return ( + + + {glyph}{' '} + {/* Aligned under the repo name, past its checkbox. */} + {' branch: '} + {ref !== null ? ( + + {ref} + {hovered && } + + ) : ( + // The default branch as a placeholder: it is what an untouched + // row clones, and typing replaces it. + + {hovered && resting ? ( + + {resting[0]} + {resting.slice(1)} + + ) : ( + (resting ?? '') + )} + + )} + + + ) + } + // A compute field: a one-line input like a branch row, blank meaning + // whatever the server resolves. + if (paneRow.kind === 'compute') { + const held = displayPane.compute[paneRow.field] + return ( + + + {glyph} {` ${paneRow.field}: `} + + {held} + {hovered && } + + + + ) + } + // An image or hook field: a script, so it prints as a YAML block scalar + // over its own lines rather than crushed onto the label's line. Long ones + // are capped and say how many lines they are hiding, since a hidden line + // is a hidden instruction to the sandbox. Enter opens it (scriptEditor), + // which prints every line and puts a real caret in the text. + if (paneRow.kind === 'image' || paneRow.kind === 'hook') { + const section = paneRow.kind === 'image' ? 'image' : 'hooks' + const editing = + scriptEditor?.section === section && scriptEditor.field === paneRow.field + const held = editing + ? scriptEditor.text + : (displayPane[section][paneRow.field as never] as string) + const { lines, hidden } = scriptRowLines(held, editing) + return ( + + + + {glyph}{' '} + {` ${paneRow.field}:`} + {/* The block-scalar marker, so a multi-line value reads the way + it would in the environment YAML. */} + {held === '' ? '' : ' |'} + {held === '' && (hovered || editing) && } + + + {held !== '' && + lines.map((line, at) => { + // The caret sits in the open editor's text, at the line and + // column it is actually on. + const before = lines.slice(0, at).reduce((n, l) => n + l.length + 1, 0) + const column = editing ? scriptEditor.cursor - before : -1 + const here = editing && column >= 0 && column <= line.length + return ( + + + {' '} + + {here ? ( + + {line.slice(0, column)} + {line[column] ?? ' '} + {line.slice(column + 1)} + + ) : ( + line + )} + + + + ) + })} + {hidden > 0 && ( + + + {` … ${hidden} more line${hidden === 1 ? '' : 's'}`} + + + )} + + ) + } + if (paneRow.kind === 'mcpServer') { + const checked = displayPane.mcpServers.some((s) => s.name === paneRow.name) + return ( + + + {glyph}{' '} + + {` [${checked ? 'x' : ' '}] ${paneRow.name}`} + + + + ) + } + if (paneRow.kind === 'variable') { + const held = variableFor(paneRow.name) + return ( + + + {glyph}{' '} + + {` [${held !== undefined ? 'x' : ' '}] ${variableRowLabel(paneRow.name, held?.value)}`} + + + + ) + } + if (paneRow.kind === 'addMcpServer') { + return ( + + + + {glyph}{' '} + + {' '} + {ADD_MCP_SERVER_LABEL} + + + + {serverEditor !== null && ( + + {SERVER_EDITOR_FIELDS.map((field) => { + const here = serverEditor.field === field + const typed = serverEditor[field] + // name is required; command/url pick the type, so their + // placeholders say the either/or. + const ghost = + typed !== '' + ? null + : field === 'name' + ? 'my-tools' + : field === 'command' + ? 'stdio: npx -y my-tools-mcp' + : 'remote: https://mcp.example.com' + return ( + + + {here ? SELECTION_GLYPH : ' '}{' '} + {/* Aligned under the button text, past its "+ ". */} + {` ${field}: `} + {typed} + {ghost ? ( + + {here ? ( + {ghost[0]} + ) : ( + {ghost[0]} + )} + {ghost.slice(1)} + + ) : ( + here && + )} + + + ) + })} + {serverEditor.error !== null && ( + + + {' '} + {serverEditor.error} + + + )} + + )} + + ) + } + return ( + + + + {glyph}{' '} + + {' '} + {ADD_VARIABLE_LABEL} + + + + {editor !== null && ( + + {(['name', 'value'] as const).map((field) => { + const here = editor.field === field + const typed = editor[field] + const ghost = + typed !== '' ? null : field === 'name' ? NAME_PLACEHOLDER : VALUE_PLACEHOLDER + return ( + + + {here ? SELECTION_GLYPH : ' '}{' '} + {` ${field}: `} + {typed} + {ghost ? ( + + {here ? ( + {ghost[0]} + ) : ( + {ghost[0]} + )} + {ghost.slice(1)} + + ) : ( + here && + )} + + + ) + })} + {editor.error !== null && ( + + + {' '} + {editor.error} + + + )} + + )} + + ) + }) + const caretVisible = focused && cursor.kind === 'prompt' && !starting && openPicker === null const listWin = navSlice(shown.length, LIST_ROWS, listIdx) const showList = !hideList @@ -1356,11 +1735,16 @@ function Launcher({ paddingY={1} paddingX={PROMPT_PAD_X} > - {/* What the next run will use, on top: two rows you walk with ↑ from - the input below, each opening its own list in place. */} + {/* What the next run will use: two rows you walk with ↑ from the input + below, each opening its own list in place. Under the Environment + list sits the configuration pane — the checked environment, read + out. */} {PICKER_ROWS.map((r, i) => { - const active = focused && cursor.kind === 'option' && cursor.at === i const isOpen = open?.key === r.key + // The row's own caret only while its list is CLOSED: once open, the + // caret belongs to whichever row of the list (or the pane under it) the + // cursor is on, and two carets would say the cursor is in two places. + const active = focused && cursor.kind === 'option' && cursor.at === i && !isOpen return ( @@ -1368,8 +1752,12 @@ function Launcher({ {active ? SELECTION_GLYPH : ' '} - {r.label}: - {rowValue(r.key)} + {/* Upper-cased like the pane's section headings, so the whole + configuration block reads with one kind of label. */} + {r.label.toUpperCase()}: + + {rowValue(r.key)} + {/* The price table's column heads, over the numeric columns they @@ -1421,334 +1809,7 @@ function Launcher({ ) })} - {/* The Environment list: its options, the divider, then the custom - section — a checkbox per stored variable, whatever was typed - here, and the add button. */} - {isOpen && - environmentRows.map((envRow) => { - if (envRow.kind === 'divider') { - return ( - - - {' '} - {`─── ${envRow.label} ───`} - - - ) - } - if (envRow.kind === 'heading') { - return ( - - - {' '} - {envRow.label.toUpperCase()} - - - ) - } - // A form owns the caret while it is open, so the list's own - // highlight goes dark rather than showing a second one. - const hovered = - envRow.hover === openHover && editor === null && serverEditor === null - if (envRow.kind === 'option') { - const opt = openOptions[envRow.at] - if (!opt) return null - const picked = isPicked('environment', envRow.at) - return ( - - - {' '} - {hovered ? SELECTION_GLYPH : ' '}{' '} - - {`[${picked ? 'x' : ' '}] ${opt.label}`} - - - - ) - } - // A connected repository: enter/space toggles it into the - // run. The ref stays off the row until it is checked — the - // branch input under it is where that lives. - if (envRow.kind === 'repo') { - const checked = repositoryFor(envRow.fullName) !== undefined - return ( - - - {' '} - {hovered ? SELECTION_GLYPH : ' '}{' '} - - {` [${checked ? 'x' : ' '}] ${envRow.fullName}`} - - - - ) - } - // A checked repo's branch input: typing edits the ref in - // place; empty rests on the repo's default branch. - if (envRow.kind === 'repoRef') { - const ref = repositoryFor(envRow.fullName)?.ref ?? null - const resting = repositoryRefLabel( - ref, - (repos ?? []).find((r) => r.full_name === envRow.fullName) - ?.default_branch ?? null, - ) - return ( - - - {' '} - {hovered ? SELECTION_GLYPH : ' '}{' '} - {/* Aligned under the repo name, past its checkbox. */} - {' branch: '} - {ref !== null ? ( - - {ref} - {hovered && } - - ) : ( - // The default branch as a placeholder: it is what - // an untouched row clones, and typing replaces it. - - {hovered && resting ? ( - - {resting[0]} - {resting.slice(1)} - - ) : ( - (resting ?? '') - )} - - )} - - - ) - } - // A compute, image or hook field: an inline input like a - // branch row. Blank rests on the picked environment's own - // value (muted), which is what an untouched field keeps — - // object overrides merge key by key. - if ( - envRow.kind === 'compute' || - envRow.kind === 'image' || - envRow.kind === 'hook' - ) { - const typed = - envRow.kind === 'compute' - ? customCompute[envRow.field] - : envRow.kind === 'image' - ? customImage[envRow.field] - : customHooks[envRow.field] - const resting = - envRow.kind === 'compute' - ? baseCompute[envRow.field] - : envRow.kind === 'image' - ? baseImage[envRow.field] - : baseHooks[envRow.field] - return ( - - - {' '} - {hovered ? SELECTION_GLYPH : ' '}{' '} - {` ${envRow.field}: `} - {typed !== '' ? ( - - {typed} - {hovered && } - - ) : ( - - {hovered && resting !== '' ? ( - - {resting[0]} - {resting.slice(1)} - - ) : ( - resting - )} - {hovered && resting === '' && } - - )} - - - ) - } - // An MCP server: a built-in (or typed) name, checked when the - // picked environment carries it or it was checked here. - if (envRow.kind === 'mcpServer') { - const checked = - customMcpServers.some((s) => s.name === envRow.name) || - baseMcpServerNames.includes(envRow.name) - return ( - - - {' '} - {hovered ? SELECTION_GLYPH : ' '}{' '} - - {` [${checked ? 'x' : ' '}] ${envRow.name}`} - - - - ) - } - if (envRow.kind === 'addMcpServer') { - return ( - - - - {' '} - {hovered ? SELECTION_GLYPH : ' '}{' '} - - {' '} - {ADD_MCP_SERVER_LABEL} - - - - {serverEditor !== null && ( - - {SERVER_EDITOR_FIELDS.map((field) => { - const here = serverEditor.field === field - const typed = serverEditor[field] - // name is required; command/url pick the type, - // so their placeholders say the either/or. - const ghost = - typed !== '' - ? null - : field === 'name' - ? 'my-tools' - : field === 'command' - ? 'stdio: npx -y my-tools-mcp' - : 'remote: https://mcp.example.com' - return ( - - - {' '} - - {here ? SELECTION_GLYPH : ' '} - {' '} - {/* Aligned under the button text, past - its "+ ". */} - {` ${field}: `} - {typed} - {ghost ? ( - - {here ? ( - {ghost[0]} - ) : ( - {ghost[0]} - )} - {ghost.slice(1)} - - ) : ( - here && - )} - - - ) - })} - {serverEditor.error !== null && ( - - - {' '} - {serverEditor.error} - - - )} - - )} - - ) - } - if (envRow.kind === 'addVariable') { - return ( - - - - {' '} - {hovered ? SELECTION_GLYPH : ' '}{' '} - - {' '} - {ADD_VARIABLE_LABEL} - - - - {/* The form, one level in from the button that opened - it: both fields at once, the ▶ on whichever ↑/↓ put - the caret on. */} - {editor !== null && ( - - {(['name', 'value'] as const).map((field) => { - const here = editor.field === field - const typed = field === 'name' ? editor.name : editor.value - // The value's placeholder says what an empty one - // means, in the field it applies to. - const ghost = - field === 'value' && typed === '' - ? VALUE_PLACEHOLDER - : field === 'name' && typed === '' - ? NAME_PLACEHOLDER - : null - return ( - - - {' '} - - {here ? SELECTION_GLYPH : ' '} - {' '} - {/* Aligned under the button text, past - its "+ ". */} - {` ${field}: `} - {typed} - {/* The caret sits on the placeholder's first - character rather than pushing it right. */} - {ghost ? ( - - {here ? ( - {ghost[0]} - ) : ( - {ghost[0]} - )} - {ghost.slice(1)} - - ) : ( - here && - )} - - - ) - })} - {editor.error !== null && ( - - - {' '} - {editor.error} - - - )} - - )} - - ) - } - // A stored variable (checkbox) or one typed here. Both read as - // one row per name: checking a stored one and typing a value - // for it are the same entry. - const entry = variableFor(envRow.name) - const checked = entry !== undefined - const label = - envRow.kind === 'secret' - ? variableRowLabel(envRow.name, entry ? entry.value : undefined) - : variableRowLabel(envRow.name, entry?.value ?? null) - return ( - - - {' '} - {hovered ? SELECTION_GLYPH : ' '}{' '} - - {` [${checked ? 'x' : ' '}] ${label}`} - - - - ) - })} + {isOpen && r.key === 'environment' && renderPane()} ) })} diff --git a/test/sessions.test.ts b/test/sessions.test.ts index 9297c08..44de24a 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -11,18 +11,22 @@ import { connectability, effectiveEnvironmentDefault, environmentOptions, - environmentPickerAt, - environmentPickerRows, - environmentRowSummary, + environmentPane, + environmentPaneAt, + environmentPaneRows, environmentSourceLabel, EMPTY_COMPUTE, EMPTY_HOOKS, EMPTY_IMAGE, + EMPTY_PANE, computeOverride, fieldsOverride, filterSessions, - mergeRepositories, - mergeVariables, + mcpServerName, + oneLine, + paneEquals, + resolveRepoFullName, + scriptRowLines, parseVariableEntry, repositoryRefLabel, variableRowLabel, @@ -557,20 +561,9 @@ describe('composerPickerRows', () => { }) describe('applyComposerChoices', () => { - const untouched = { - environment: null, - model: null, - emptyEnvironment: false, - baseVariables: [], - customVariables: [], - baseRepositories: [], - customRepositories: [], - customCompute: EMPTY_COMPUTE, - customImage: EMPTY_IMAGE, - customHooks: EMPTY_HOOKS, - baseMcpServers: [], - customMcpServers: [], - } + // Nothing stated about the environment: no name, and a null pane, so the + // server's own ladder resolves it. + const untouched = { environment: null, model: null, pane: null } it('leaves the base request alone when nothing was picked', () => { expect(applyComposerChoices({ prompt: 'hi', repository: 'acme/api' }, untouched)).toEqual({ @@ -588,8 +581,13 @@ describe('applyComposerChoices', () => { expect(req.repository).toBe('acme/api') }) + // A named environment ships alone: the pane still matches it, so re-stating + // its lists could only say them worse. it('names a chosen environment on the request, not in the override', () => { - const req = applyComposerChoices({ prompt: 'ship it' }, { ...untouched, environment: 'env_1' }) + const req = applyComposerChoices( + { prompt: 'ship it' }, + { ...untouched, environment: 'env_1', pane: { ...EMPTY_PANE, variables: [{ name: 'A', value: '1' }] } }, + ) expect(req).toEqual({ prompt: 'ship it', environment: 'env_1' }) }) @@ -618,162 +616,227 @@ describe('applyComposerChoices', () => { expect(base).toEqual({ repository: 'acme/api' }) }) - // The whole point of the custom section: an override array REPLACES the - // resolved list, so the picked environment's own variables have to ride along - // or adding one would silently drop the rest. - it('ships the picked environment variables alongside a custom one', () => { + // The whole point of the pane: without an environment name it IS the sandbox, + // and every list in an override replaces the resolved one — so all three ship + // together, empty included, or the ladder would fill the gaps back in. + it('ships the whole pane when no environment is named', () => { const req = applyComposerChoices( {}, { ...untouched, - environment: 'env_1', - baseVariables: [{ name: 'NODE_ENV', value: 'production' }], - customVariables: [{ name: 'SENTRY_DSN', value: 'https://x' }], + pane: { + ...EMPTY_PANE, + repositories: [{ fullName: 'acme/api', ref: 'main' }], + variables: [{ name: 'NODE_ENV', value: 'production' }], + mcpServers: [{ name: 'linear', command: null, url: null }], + }, }, ) expect(req).toEqual({ - environment: 'env_1', override: { environment: { - variables: [ - { name: 'NODE_ENV', value: 'production' }, - { name: 'SENTRY_DSN', value: 'https://x' }, - ], + repositories: [{ owner: 'acme', name: 'api', ref: 'main' }], + variables: [{ name: 'NODE_ENV', value: 'production' }], + mcp_servers: [{ name: 'linear' }], }, }, }) }) - it('sends no variables override while the custom section is empty', () => { + // The [empty] pick is the pane emptied, and empty arrays are how "nothing" is + // said: omitting them would let the ladder resolve an environment instead. + it('clears every list for an empty pane', () => { + const req = applyComposerChoices({ repository: 'acme/api' }, { ...untouched, pane: EMPTY_PANE }) + expect(req).toEqual({ + repository: 'acme/api', + override: { environment: { repositories: [], variables: [], mcp_servers: [] } }, + }) + }) + + it('omits the value of a variable that resolves from stored secrets', () => { const req = applyComposerChoices( {}, - { ...untouched, environment: 'env_1', baseVariables: [{ name: 'NODE_ENV', value: 'x' }] }, + { ...untouched, pane: { ...EMPTY_PANE, variables: [{ name: 'API_TOKEN', value: null }] } }, ) - expect(req).toEqual({ environment: 'env_1' }) + expect(req.override?.environment).toMatchObject({ variables: [{ name: 'API_TOKEN' }] }) }) - it('omits the value of a variable that resolves from stored secrets', () => { + // Compute, image and hooks are scalars in merging object overrides, so only + // the set fields ship — an unset one keeps whatever the server resolves. + it('sends only the set compute fields', () => { const req = applyComposerChoices( {}, - { ...untouched, customVariables: [{ name: 'API_TOKEN', value: null }] }, + { ...untouched, pane: { ...EMPTY_PANE, compute: { cpu: '4', memory: '16GB', timeout: '' } } }, ) - expect(req.override).toEqual({ environment: { variables: [{ name: 'API_TOKEN' }] } }) + expect(req.override?.environment).toMatchObject({ compute: { cpu: 4, memory: '16GB' } }) }) - // "[empty]" names no environment, so the ladder would still resolve one: - // clearing the lists is what actually empties the sandbox. - it('clears every list for the [empty] pick, and drops the base variables', () => { + it('sends only the set image fields', () => { const req = applyComposerChoices( - { repository: 'acme/api' }, - { - ...untouched, - emptyEnvironment: true, - baseVariables: [{ name: 'NODE_ENV', value: 'production' }], - baseRepositories: [{ fullName: 'acme/api', ref: null }], - }, + {}, + { ...untouched, pane: { ...EMPTY_PANE, image: { dockerfile_append: '', setup: 'npm install' } } }, ) - expect(req).toEqual({ - repository: 'acme/api', - override: { environment: { repositories: [], mcp_servers: [], variables: [] } }, - }) + expect(req.override?.environment).toMatchObject({ image: { setup: 'npm install' } }) }) - // Same rule as variables: an override array replaces the resolved list, so - // the picked environment's own repositories ride along with a checked one. - it('ships the picked environment repositories alongside a checked one', () => { + it('sends only the set hook fields', () => { const req = applyComposerChoices( {}, - { - ...untouched, - environment: 'env_1', - baseRepositories: [{ fullName: 'acme/api', ref: 'main' }], - customRepositories: [{ fullName: 'acme/web', ref: null }], - }, + { ...untouched, pane: { ...EMPTY_PANE, hooks: { post_start: 'doppler setup', post_clone: '' } } }, ) - expect(req).toEqual({ - environment: 'env_1', - override: { - environment: { - repositories: [ - { owner: 'acme', name: 'api', ref: 'main' }, - { owner: 'acme', name: 'web' }, - ], - }, - }, - }) + expect(req.override?.environment).toMatchObject({ hooks: { post_start: 'doppler setup' } }) }) - it('sends no repositories override while none were checked', () => { + // A server the pane was seeded with keeps its own entry, so a definition the + // launcher's three fields can't hold survives a custom run. + it('ships a seeded server verbatim, and builds the rest from their fields', () => { + const seeded = { name: 'my-tools', command: 'npx', args: ['my-tools'], env: { A: '1' } } const req = applyComposerChoices( {}, { ...untouched, - environment: 'env_1', - baseRepositories: [{ fullName: 'acme/api', ref: null }], + pane: { + ...EMPTY_PANE, + mcpServers: [ + { name: 'my-tools', command: 'npx', url: null, raw: seeded }, + { name: 'docs', command: null, url: 'https://mcp.example.com' }, + ], + }, }, ) - expect(req).toEqual({ environment: 'env_1' }) + expect(req.override?.environment).toMatchObject({ + mcp_servers: [seeded, { name: 'docs', url: 'https://mcp.example.com' }], + }) }) +}) - // Compute is scalars in a merging object override, so only the typed fields - // ship — no base counterpart to re-send. - it('sends only the typed compute fields', () => { - const req = applyComposerChoices( - {}, - { ...untouched, customCompute: { cpu: '4', memory: '16GB', timeout: '' } }, - ) - expect(req.override).toEqual({ environment: { compute: { cpu: 4, memory: '16GB' } } }) +describe('environmentPane', () => { + it('reads every field the pane shows off an environment config', () => { + expect( + environmentPane({ + repositories: [{ owner: 'acme', name: 'api', ref: 'main' }, { name: 'solo' }], + variables: [{ name: 'A', value: '1' }, { name: 'B' }], + mcp_servers: ['linear', { name: 'docs', url: 'https://x' }], + compute: { cpu: 4, memory: '16GB' }, + image: { setup: 'npm ci' }, + hooks: { post_clone: 'make' }, + }), + ).toEqual({ + repositories: [ + { fullName: 'acme/api', ref: 'main' }, + { fullName: 'solo', ref: null }, + ], + variables: [ + { name: 'A', value: '1' }, + { name: 'B', value: null }, + ], + mcpServers: [ + { name: 'linear', command: null, url: null, raw: 'linear' }, + { name: 'docs', command: null, url: 'https://x', raw: { name: 'docs', url: 'https://x' } }, + ], + compute: { cpu: '4', memory: '16GB', timeout: '' }, + image: { dockerfile_append: '', setup: 'npm ci' }, + hooks: { post_start: '', post_clone: 'make' }, + }) }) - it('sends only the typed image fields', () => { - const req = applyComposerChoices( - {}, - { ...untouched, customImage: { dockerfile_append: '', setup: 'npm install' } }, - ) - expect(req.override).toEqual({ environment: { image: { setup: 'npm install' } } }) + it('is the empty pane for an environment that sets nothing, and for none at all', () => { + expect(environmentPane({})).toEqual(EMPTY_PANE) + expect(environmentPane(null)).toEqual(EMPTY_PANE) }) - it('sends only the typed hook fields', () => { - const req = applyComposerChoices( - {}, - { ...untouched, customHooks: { post_start: 'doppler setup', post_clone: '' } }, - ) - expect(req.override).toEqual({ environment: { hooks: { post_start: 'doppler setup' } } }) + // A script keeps its newlines here: the pane ships what it holds, and only its + // row flattens (oneLine). + it('keeps a multi-line script whole', () => { + expect(environmentPane({ image: { setup: 'a\nb' } }).image.setup).toBe('a\nb') }) +}) - // The base servers ride verbatim — they can be full stdio/remote definitions - // — and a checked name the base already carries adds no duplicate. - it('ships the base MCP servers alongside the ones added here', () => { - const req = applyComposerChoices( - {}, - { - ...untouched, - baseMcpServers: [{ name: 'my-tools', command: 'npx', args: ['my-tools'] }, 'linear'], - customMcpServers: [ - { name: 'slack', command: null, url: null }, - { name: 'linear', command: null, url: null }, - { name: 'docs', command: null, url: 'https://mcp.example.com' }, - ], - }, - ) - expect(req.override).toEqual({ - environment: { - mcp_servers: [ - { name: 'my-tools', command: 'npx', args: ['my-tools'] }, - 'linear', - { name: 'slack' }, - { name: 'docs', url: 'https://mcp.example.com' }, - ], - }, +describe('resolveRepoFullName', () => { + const connected = ['ellipsis-dev/ellipsis', 'ellipsis-dev/cli'] + + // An environment YAML may write "name: ellipsis" with no owner, and that is the + // same repository as the connected "ellipsis-dev/ellipsis" — one row, not two. + it('resolves a bare name against the connected repositories', () => { + expect(resolveRepoFullName('ellipsis', connected)).toBe('ellipsis-dev/ellipsis') + expect(resolveRepoFullName('cli', connected)).toBe('ellipsis-dev/cli') + }) + + it('leaves an owner-qualified name and an unknown one alone', () => { + expect(resolveRepoFullName('other-org/ellipsis', connected)).toBe('other-org/ellipsis') + expect(resolveRepoFullName('mystery', connected)).toBe('mystery') + }) + + // Two owners with the same repo name would be a guess, so it stays as written. + it('does not guess between two owners of the same name', () => { + expect(resolveRepoFullName('api', ['acme/api', 'other/api'])).toBe('api') + }) +}) + +describe('environmentPane repository dedupe', () => { + it('seeds an ownerless entry onto its connected row', () => { + expect( + environmentPane({ repositories: [{ name: 'ellipsis' }] }, ['ellipsis-dev/ellipsis']) + .repositories, + ).toEqual([{ fullName: 'ellipsis-dev/ellipsis', ref: null }]) + }) +}) + +describe('scriptRowLines', () => { + const script = ['a', 'b', 'c', 'd', 'e', 'f', 'g'].join('\n') + + it('caps a long script and counts what it hid', () => { + expect(scriptRowLines(script, false)).toEqual({ + lines: ['a', 'b', 'c', 'd', 'e'], + hidden: 2, }) }) - it('sends no MCP override while none were checked', () => { - const req = applyComposerChoices( - {}, - { ...untouched, baseMcpServers: ['linear'] }, - ) - expect(req).toEqual({}) + it('shows every line while the row is open', () => { + expect(scriptRowLines(script, true).hidden).toBe(0) + expect(scriptRowLines(script, true).lines).toHaveLength(7) + }) + + it('hides nothing from a short script', () => { + expect(scriptRowLines('one\ntwo', false)).toEqual({ lines: ['one', 'two'], hidden: 0 }) + }) +}) + +describe('paneEquals', () => { + it('ignores the order a list was built in', () => { + const a = { + ...EMPTY_PANE, + repositories: [{ fullName: 'acme/api', ref: null }, { fullName: 'acme/web', ref: null }], + } + const b = { + ...EMPTY_PANE, + repositories: [{ fullName: 'acme/web', ref: null }, { fullName: 'acme/api', ref: null }], + } + expect(paneEquals(a, b)).toBe(true) + }) + + it('sees a changed field, a dropped entry and an edited ref', () => { + const base = { ...EMPTY_PANE, repositories: [{ fullName: 'acme/api', ref: null }] } + expect(paneEquals(base, EMPTY_PANE)).toBe(false) + expect( + paneEquals(base, { ...EMPTY_PANE, repositories: [{ fullName: 'acme/api', ref: 'dev' }] }), + ).toBe(false) + expect(paneEquals(base, { ...base, compute: { ...EMPTY_COMPUTE, cpu: '4' } })).toBe(false) + }) +}) + +describe('mcpServerName', () => { + it('reads the name off every shape the config admits', () => { + expect(mcpServerName('linear')).toBe('linear') + expect(mcpServerName({ name: 'docs', url: 'https://x' })).toBe('docs') + expect(mcpServerName({})).toBe('') + }) +}) + +describe('oneLine', () => { + it('collapses a script to the one line its row is', () => { + expect(oneLine('npm ci\n npm test\n')).toBe('npm ci npm test') + expect(oneLine('')).toBe('') }) }) @@ -841,34 +904,6 @@ describe('fieldsOverride', () => { }) }) -describe('mergeVariables', () => { - it('appends a custom variable after the environment own', () => { - expect( - mergeVariables([{ name: 'A', value: '1' }], [{ name: 'B', value: '2' }]), - ).toEqual([ - { name: 'A', value: '1' }, - { name: 'B', value: '2' }, - ]) - }) - - // Two entries with one name would leave the server to break the tie, so a - // custom repeat of a base name edits it in place instead. - it('lets a custom variable override a base one in place', () => { - expect( - mergeVariables( - [ - { name: 'A', value: '1' }, - { name: 'B', value: '2' }, - ], - [{ name: 'A', value: 'mine' }], - ), - ).toEqual([ - { name: 'A', value: 'mine' }, - { name: 'B', value: '2' }, - ]) - }) -}) - describe('parseVariableEntry', () => { it('reads a bare name as a stored-secret lookup', () => { expect(parseVariableEntry(' API_TOKEN ')).toEqual({ name: 'API_TOKEN', value: null }) @@ -890,81 +925,98 @@ describe('parseVariableEntry', () => { }) }) -describe('environmentPickerRows', () => { +describe('environmentPaneRows', () => { const input = { - optionCount: 2, repoNames: ['acme/api'], checkedRepoNames: [] as string[], secretNames: ['API_TOKEN', 'NPM_TOKEN'], - customVariables: [{ name: 'PORT', value: '3000' }], + variables: [{ name: 'PORT', value: '3000' }], builtInMcpServers: ['linear'], - customMcpServers: [] as { name: string; command: string | null; url: string | null }[], + mcpServers: [] as { name: string; command: string | null; url: string | null }[], } - // Options, divider, the repositories, the stored variables, what was typed - // here, the add button. Only the pickable rows carry a hover index. - it('lays the list out and numbers only the pickable rows', () => { - expect(environmentPickerRows(input)).toEqual([ - { kind: 'option', at: 0, hover: 0 }, - { kind: 'option', at: 1, hover: 1 }, - { kind: 'divider', label: 'custom environment' }, + // The repositories, the servers, the variables, then the field sections. Only + // the landable rows carry a hover index; headings are decoration. + it('lays the pane out and numbers only the landable rows', () => { + expect(environmentPaneRows(input)).toEqual([ { kind: 'heading', label: 'repositories' }, - { kind: 'repo', fullName: 'acme/api', hover: 2 }, + { kind: 'repo', fullName: 'acme/api', hover: 0 }, { kind: 'heading', label: 'mcp servers' }, - { kind: 'mcpServer', name: 'linear', hover: 3 }, - { kind: 'addMcpServer', hover: 4 }, + { kind: 'mcpServer', name: 'linear', hover: 1 }, + { kind: 'addMcpServer', hover: 2 }, { kind: 'heading', label: 'variables' }, - { kind: 'secret', name: 'API_TOKEN', hover: 5 }, - { kind: 'secret', name: 'NPM_TOKEN', hover: 6 }, - { kind: 'variable', name: 'PORT', hover: 7 }, - { kind: 'addVariable', hover: 8 }, + { kind: 'variable', name: 'API_TOKEN', hover: 3 }, + { kind: 'variable', name: 'NPM_TOKEN', hover: 4 }, + { kind: 'variable', name: 'PORT', hover: 5 }, + { kind: 'addVariable', hover: 6 }, { kind: 'heading', label: 'image' }, - { kind: 'image', field: 'dockerfile_append', hover: 9 }, - { kind: 'image', field: 'setup', hover: 10 }, + { kind: 'image', field: 'dockerfile_append', hover: 7 }, + { kind: 'image', field: 'setup', hover: 8 }, { kind: 'heading', label: 'hooks' }, - { kind: 'hook', field: 'post_start', hover: 11 }, - { kind: 'hook', field: 'post_clone', hover: 12 }, + { kind: 'hook', field: 'post_start', hover: 9 }, + { kind: 'hook', field: 'post_clone', hover: 10 }, { kind: 'heading', label: 'compute' }, - { kind: 'compute', field: 'cpu', hover: 13 }, - { kind: 'compute', field: 'memory', hover: 14 }, - { kind: 'compute', field: 'timeout', hover: 15 }, + { kind: 'compute', field: 'cpu', hover: 11 }, + { kind: 'compute', field: 'memory', hover: 12 }, + { kind: 'compute', field: 'timeout', hover: 13 }, ]) }) - // A typed server name that is also a built-in rides that row, like the - // variables (one name, one row). + // A server the pane carries whose name is also a built-in rides that row, like + // the variables: one name, one row, whichever way it got in. it('gives a built-in server one row even once it is checked', () => { - const rows = environmentPickerRows({ + const rows = environmentPaneRows({ ...input, - customMcpServers: [ + mcpServers: [ { name: 'linear', command: null, url: null }, { name: 'my-server', command: 'npx my-server', url: null }, ], }) - const serverRows = rows.filter((r) => r.kind === 'mcpServer') - expect(serverRows.map((r) => (r as { name: string }).name)).toEqual(['linear', 'my-server']) + expect( + rows.filter((r) => r.kind === 'mcpServer').map((r) => (r as { name: string }).name), + ).toEqual(['linear', 'my-server']) + }) + + // Same for a variable: checking a stored one adds it to the pane, so without + // the union it would appear twice. + it('gives a stored variable one row even once it is checked', () => { + const rows = environmentPaneRows({ ...input, variables: [{ name: 'API_TOKEN', value: null }] }) + expect(rows.filter((r) => 'name' in r && r.name === 'API_TOKEN')).toHaveLength(1) + }) + + // A variable an environment brought in that the account has no secret for + // still gets a row: the pane shows the whole sandbox, not just what is stored. + it('lists a variable the account holds no secret for', () => { + const rows = environmentPaneRows({ + ...input, + secretNames: [], + variables: [{ name: 'NODE_ENV', value: 'production' }], + }) + expect(rows.filter((r) => r.kind === 'variable')).toEqual([ + { kind: 'variable', name: 'NODE_ENV', hover: 3 }, + ]) }) // A checked repo grows a branch input row directly under it, so ↓ can land // there and type a ref. it('adds a branch row under a checked repo only', () => { - const rows = environmentPickerRows({ + const rows = environmentPaneRows({ ...input, repoNames: ['acme/api', 'acme/web'], checkedRepoNames: ['acme/api'], }) - expect(rows.slice(3, 7)).toEqual([ + expect(rows.slice(0, 4)).toEqual([ { kind: 'heading', label: 'repositories' }, - { kind: 'repo', fullName: 'acme/api', hover: 2 }, - { kind: 'repoRef', fullName: 'acme/api', hover: 3 }, - { kind: 'repo', fullName: 'acme/web', hover: 4 }, + { kind: 'repo', fullName: 'acme/api', hover: 0 }, + { kind: 'repoRef', fullName: 'acme/api', hover: 1 }, + { kind: 'repo', fullName: 'acme/web', hover: 2 }, ]) }) // An empty repo list (not landed yet, or none connected) prints no heading // with nothing under it. it('drops the repositories heading while there are no repos', () => { - const rows = environmentPickerRows({ ...input, repoNames: [] }) + const rows = environmentPaneRows({ ...input, repoNames: [] }) expect(rows.filter((r) => r.kind === 'heading')).toEqual([ { kind: 'heading', label: 'mcp servers' }, { kind: 'heading', label: 'variables' }, @@ -973,41 +1025,29 @@ describe('environmentPickerRows', () => { { kind: 'heading', label: 'compute' }, ]) }) - - // Checking a stored variable adds it to customVariables, so without this it - // would appear twice — once as the checkbox, once as a typed entry. - it('gives a stored variable one row even once it is checked', () => { - const rows = environmentPickerRows({ - ...input, - customVariables: [{ name: 'API_TOKEN', value: null }], - }) - expect(rows.filter((r) => 'name' in r && r.name === 'API_TOKEN')).toHaveLength(1) - }) }) -describe('environmentPickerAt', () => { +describe('environmentPaneAt', () => { const input = { - optionCount: 2, repoNames: ['acme/api'], checkedRepoNames: ['acme/api'], secretNames: ['API_TOKEN'], - customVariables: [] as { name: string; value: string | null }[], + variables: [] as { name: string; value: string | null }[], builtInMcpServers: [] as string[], - customMcpServers: [] as { name: string; command: string | null; url: string | null }[], + mcpServers: [] as { name: string; command: string | null; url: string | null }[], } - it('walks options, repos and branch rows, servers, variables, then the fields', () => { - expect(environmentPickerAt(input, 1)).toEqual({ kind: 'option', at: 1 }) - expect(environmentPickerAt(input, 2)).toEqual({ kind: 'repo', fullName: 'acme/api' }) - expect(environmentPickerAt(input, 3)).toEqual({ kind: 'repoRef', fullName: 'acme/api' }) - expect(environmentPickerAt(input, 4)).toEqual({ kind: 'addMcpServer' }) - expect(environmentPickerAt(input, 5)).toEqual({ kind: 'secret', name: 'API_TOKEN' }) - expect(environmentPickerAt(input, 6)).toEqual({ kind: 'addVariable' }) - expect(environmentPickerAt(input, 7)).toEqual({ kind: 'image', field: 'dockerfile_append' }) + it('walks repos and branch rows, servers, variables, then the fields', () => { + expect(environmentPaneAt(input, 0)).toEqual({ kind: 'repo', fullName: 'acme/api' }) + expect(environmentPaneAt(input, 1)).toEqual({ kind: 'repoRef', fullName: 'acme/api' }) + expect(environmentPaneAt(input, 2)).toEqual({ kind: 'addMcpServer' }) + expect(environmentPaneAt(input, 3)).toEqual({ kind: 'variable', name: 'API_TOKEN' }) + expect(environmentPaneAt(input, 4)).toEqual({ kind: 'addVariable' }) + expect(environmentPaneAt(input, 5)).toEqual({ kind: 'image', field: 'dockerfile_append' }) }) it('clamps a hover past the last row', () => { - expect(environmentPickerAt(input, 99)).toEqual({ kind: 'compute', field: 'timeout' }) + expect(environmentPaneAt(input, 99)).toEqual({ kind: 'compute', field: 'timeout' }) }) }) @@ -1083,70 +1123,6 @@ describe('variableRowLabel', () => { }) }) -describe('environmentRowSummary', () => { - it('states the pick alone until the custom section adds something', () => { - expect(environmentRowSummary('backend-sandbox', [])).toBe('backend-sandbox') - expect(environmentRowSummary('backend-sandbox', [{ name: 'A', value: '1' }])).toBe( - 'backend-sandbox +1 variable', - ) - expect( - environmentRowSummary('backend-sandbox', [ - { name: 'A', value: '1' }, - { name: 'B', value: null }, - ]), - ).toBe('backend-sandbox +2 variables') - }) - - it('counts checked repositories, before the variables', () => { - expect( - environmentRowSummary('backend-sandbox', [], [{ fullName: 'acme/api', ref: null }]), - ).toBe('backend-sandbox +1 repo') - expect( - environmentRowSummary( - 'backend-sandbox', - [{ name: 'A', value: '1' }], - [ - { fullName: 'acme/api', ref: null }, - { fullName: 'acme/web', ref: 'dev' }, - ], - ), - ).toBe('backend-sandbox +2 repos +1 variable') - }) - - it('notes a compute override without counting its fields', () => { - expect( - environmentRowSummary('backend-sandbox', [], [], { cpu: '4', memory: '', timeout: '' }), - ).toBe('backend-sandbox +compute') - }) - - it('notes an image override', () => { - expect( - environmentRowSummary('backend-sandbox', [], [], EMPTY_COMPUTE, { - dockerfile_append: '', - setup: 'npm install', - }), - ).toBe('backend-sandbox +image') - }) - - it('notes a hooks override', () => { - expect( - environmentRowSummary('backend-sandbox', [], [], EMPTY_COMPUTE, EMPTY_IMAGE, { - post_start: 'doppler setup', - post_clone: '', - }), - ).toBe('backend-sandbox +hooks') - }) - - it('counts checked MCP servers', () => { - expect( - environmentRowSummary('backend-sandbox', [], [], EMPTY_COMPUTE, EMPTY_IMAGE, EMPTY_HOOKS, [ - { name: 'linear', command: null, url: null }, - { name: 'docs', command: null, url: 'https://x' }, - ]), - ).toBe('backend-sandbox +2 mcp') - }) -}) - describe('repositoryRefLabel', () => { // The branch row says which ref a start would clone: the typed one, else the // repo's default branch. @@ -1194,35 +1170,6 @@ describe('environmentSourceLabel', () => { }) }) -describe('mergeRepositories', () => { - it('appends a custom repository after the environment own', () => { - expect( - mergeRepositories( - [{ fullName: 'acme/api', ref: null }], - [{ fullName: 'acme/web', ref: 'dev' }], - ), - ).toEqual([ - { fullName: 'acme/api', ref: null }, - { fullName: 'acme/web', ref: 'dev' }, - ]) - }) - - it('lets a custom entry override a base one in place', () => { - expect( - mergeRepositories( - [ - { fullName: 'acme/api', ref: null }, - { fullName: 'acme/web', ref: null }, - ], - [{ fullName: 'acme/api', ref: 'release' }], - ), - ).toEqual([ - { fullName: 'acme/api', ref: 'release' }, - { fullName: 'acme/web', ref: null }, - ]) - }) -}) - describe('effectiveEnvironmentDefault', () => { const ladder = { account: 'env_account', repositories: { 'Acme/API': 'env_repo' } }