diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 8ce46fe..6e3f472 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -406,9 +406,119 @@ 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 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 + // 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): 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 } + } + 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 @@ -416,82 +526,268 @@ export const ADD_VARIABLE_LABEL = '+ new variable' 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 -// 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: 'secret'; name: string; hover: number } + | { kind: 'repo'; fullName: string; hover: number } + | { kind: 'repoRef'; fullName: 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: 'secret'; name: string } - | { kind: 'variable'; name: string } - | { kind: 'addVariable' } +// pane's shape. +export type EnvironmentPaneTarget = Exclude extends infer R + ? R extends { hover: number } + ? Omit + : never + : never -export interface EnvironmentPickerInput { - optionCount: number +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[] + 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[] + 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) { + 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 unionNames( + input.builtInMcpServers, + input.mcpServers.map((s) => s.name), + )) { + rows.push({ kind: 'mcpServer', 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 }) + 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 } -// 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 === 'secret') return { kind: 'secret', name: row.name } - if (row.kind === 'variable') return { kind: 'variable', name: row.name } - return { kind: 'addVariable' } + 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 function environmentPickerCount(input: EnvironmentPickerInput): number { - return environmentPickerRows(input).filter((r) => 'hover' in r).length +export const EMPTY_PANE: EnvironmentPaneState = { + repositories: [], + variables: [], + mcpServers: [], + compute: EMPTY_COMPUTE, + image: EMPTY_IMAGE, + hooks: EMPTY_HOOKS, } -// 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[], +// 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 { - if (customVariables.length === 0) return label - const n = customVariables.length - return `${label} +${n} variable${n === 1 ? '' : 's'}` + 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 @@ -511,37 +807,39 @@ export interface CustomVariable { value: string | 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. +// 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 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 variables, needed because an override array - // REPLACES the resolved list rather than appending to it. - baseVariables: readonly CustomVariable[] - customVariables: readonly CustomVariable[] -} - -// 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 @@ -551,38 +849,53 @@ function variableEntry(v: CustomVariable): { name: string; value?: string } { return v.value === null ? { name: v.name } : { name: v.name, value: v.value } } -// 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). +// 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 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) + // 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 } - if (Object.keys(environment).length > 0) override.environment = environment if (Object.keys(override).length > 0) req.override = override return req } @@ -621,11 +934,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 +966,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..c35359f 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,23 +16,35 @@ 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, 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, type ComposerChoices, type ComposerModel, + type CustomMcpServer, type CustomVariable, + type EnvironmentPaneState, + validateMcpServer, rowDescription, rowGlyph, rowMeta, @@ -298,7 +311,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 +340,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 +475,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} @@ -487,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. @@ -517,13 +548,37 @@ 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 + +// 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 @@ -537,34 +592,65 @@ type VariableEditor = { // 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, @@ -575,6 +661,8 @@ function Launcher({ environments, environmentDefaults, secretNames, + repos, + builtInServers, models, detectedRepo, sessions, @@ -596,6 +684,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 @@ -621,18 +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([]) + // 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 @@ -643,10 +747,25 @@ 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], ) + // 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], @@ -655,19 +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(() => { + // 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.variables ?? []).map((v) => ({ - name: v.name, - value: v.value ?? 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]) @@ -678,66 +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. - 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, + // 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: displayPane.repositories.map((r) => r.fullName), secretNames: secretNames ?? [], - customVariables, + variables: displayPane.variables, + builtInMcpServers: builtInServers, + mcpServers: displayPane.mcpServers, }), - [environmentOptionRows.length, secretNames, customVariables], + [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 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 === '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 }], - ) + if (row.kind === 'repo') { + 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 + } + if (row.kind === 'mcpServer') { + 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 === '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 @@ -752,29 +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). + const editRepositoryRef = (fullName: string, edit: (ref: string) => string): void => { + 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, + pane: named && pickedEnvironment?.id === null ? null : shownPane, }) } @@ -795,6 +1026,115 @@ 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) { + 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 + } + editPane((p) => ({ + ...p, + // A repeat of a name is an edit of it, not a duplicate row. + mcpServers: [...p.mcpServers.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. @@ -826,28 +1166,93 @@ 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 + } + // 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') { + 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) { + 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))) { + 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 @@ -863,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 @@ -876,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) { @@ -956,13 +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) : label + return options[Math.min(pickedIdx(key), options.length - 1)]?.label ?? 'Default' } // Columns available inside the prompt box: the terminal minus its left accent @@ -972,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 @@ -1005,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 @@ -1033,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 ( @@ -1045,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 @@ -1098,140 +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()} - - - ) - } - // The 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 - 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}`} - - - - ) - } - 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 : ' '} - {' '} - {`${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 700309c..44de24a 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -2,18 +2,33 @@ import { describe, expect, it } from 'vitest' import { applyComposerChoices, attentionFlip, + builtInMcpServers, + mcpServerEntry, + validateMcpServer, COMPOSER_MODELS, composerModelOptions, composerPickerRows, connectability, effectiveEnvironmentDefault, environmentOptions, - environmentPickerAt, - environmentPickerRows, - environmentRowSummary, + environmentPane, + environmentPaneAt, + environmentPaneRows, + environmentSourceLabel, + EMPTY_COMPUTE, + EMPTY_HOOKS, + EMPTY_IMAGE, + EMPTY_PANE, + computeOverride, + fieldsOverride, filterSessions, - mergeVariables, + mcpServerName, + oneLine, + paneEquals, + resolveRepoFullName, + scriptRowLines, parseVariableEntry, + repositoryRefLabel, variableRowLabel, modelRate, rateDollars, @@ -546,13 +561,9 @@ describe('composerPickerRows', () => { }) describe('applyComposerChoices', () => { - const untouched = { - environment: null, - model: null, - emptyEnvironment: false, - baseVariables: [], - customVariables: [], - } + // 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({ @@ -570,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' }) }) @@ -600,91 +616,291 @@ 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, pane: { ...EMPTY_PANE, image: { dockerfile_append: '', setup: 'npm install' } } }, + ) + expect(req.override?.environment).toMatchObject({ image: { setup: 'npm install' } }) + }) + + it('sends only the set hook fields', () => { + const req = applyComposerChoices( + {}, + { ...untouched, pane: { ...EMPTY_PANE, hooks: { post_start: 'doppler setup', post_clone: '' } } }, + ) + expect(req.override?.environment).toMatchObject({ hooks: { post_start: 'doppler setup' } }) + }) + + // 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, - emptyEnvironment: true, - baseVariables: [{ name: 'NODE_ENV', value: 'production' }], + 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({ - repository: 'acme/api', - override: { environment: { repositories: [], mcp_servers: [], variables: [] } }, + expect(req.override?.environment).toMatchObject({ + mcp_servers: [seeded, { name: 'docs', url: 'https://mcp.example.com' }], }) }) }) -describe('mergeVariables', () => { - it('appends a custom variable after the environment own', () => { +describe('environmentPane', () => { + it('reads every field the pane shows off an environment config', () => { expect( - mergeVariables([{ name: 'A', value: '1' }], [{ name: 'B', value: '2' }]), - ).toEqual([ - { name: 'A', value: '1' }, - { name: 'B', value: '2' }, - ]) + 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' }, + }) }) - // 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', () => { + 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) + }) + + // 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') + }) +}) + +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( - mergeVariables( - [ - { name: 'A', value: '1' }, - { name: 'B', value: '2' }, - ], - [{ name: 'A', value: 'mine' }], - ), - ).toEqual([ - { name: 'A', value: 'mine' }, - { name: 'B', value: '2' }, - ]) + 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('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('') + }) +}) + +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({}) }) }) @@ -709,54 +925,129 @@ 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'], + mcpServers: [] 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. - 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: 0 }, + { kind: 'heading', label: 'mcp servers' }, + { kind: 'mcpServer', name: 'linear', hover: 1 }, + { kind: 'addMcpServer', hover: 2 }, { 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: '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: 7 }, + { kind: 'image', field: 'setup', hover: 8 }, + { kind: 'heading', label: 'hooks' }, + { kind: 'hook', field: 'post_start', hover: 9 }, + { kind: 'hook', field: 'post_clone', hover: 10 }, + { kind: 'heading', label: 'compute' }, + { kind: 'compute', field: 'cpu', hover: 11 }, + { kind: 'compute', field: 'memory', hover: 12 }, + { kind: 'compute', field: 'timeout', hover: 13 }, ]) }) - // 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({ + // 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 = environmentPaneRows({ ...input, - customVariables: [{ name: 'API_TOKEN', value: null }], + mcpServers: [ + { name: 'linear', command: null, url: null }, + { name: 'my-server', command: 'npx my-server', url: null }, + ], }) + 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 = environmentPaneRows({ + ...input, + repoNames: ['acme/api', 'acme/web'], + checkedRepoNames: ['acme/api'], + }) + expect(rows.slice(0, 4)).toEqual([ + { kind: 'heading', label: 'repositories' }, + { 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 = environmentPaneRows({ ...input, repoNames: [] }) + expect(rows.filter((r) => r.kind === 'heading')).toEqual([ + { kind: 'heading', label: 'mcp servers' }, + { kind: 'heading', label: 'variables' }, + { kind: 'heading', label: 'image' }, + { kind: 'heading', label: 'hooks' }, + { kind: 'heading', label: 'compute' }, + ]) + }) }) -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[], + mcpServers: [] as { name: string; command: string | null; url: string | null }[], } - it('walks options, then the stored variables, then the add button', () => { - 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' }) + 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: 'addVariable' }) + expect(environmentPaneAt(input, 99)).toEqual({ kind: 'compute', field: 'timeout' }) }) }) @@ -807,6 +1098,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', () => { @@ -820,18 +1123,50 @@ 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', - ) +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( - environmentRowSummary('backend-sandbox', [ - { name: 'A', value: '1' }, - { name: 'B', value: null }, - ]), - ).toBe('backend-sandbox +2 variables') + 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() }) })