@@ -5,6 +5,7 @@ import { theme } from './theme'
55import type {
66 AgentSession ,
77 AgentSessionSource ,
8+ EnvironmentDefaults ,
89 ListAgentSessionsQuery ,
910 ModelManufacturer ,
1011 ModelRateCard ,
@@ -405,70 +406,268 @@ export function composerPickerRows(
405406 return rows
406407}
407408
408- // A saved config's display name (the YAML's ellipsis.name), falling back to
409- // the row id.
410- export function configDisplayName ( config : {
411- id : string
412- agent_config : Record < string , unknown >
413- } ) : string {
414- const ellipsis = config . agent_config ?. ellipsis
415- if ( ellipsis && typeof ellipsis === 'object' ) {
416- const name = ( ellipsis as Record < string , unknown > ) . name
417- if ( typeof name === 'string' && name . trim ( ) ) return name
409+ export const CUSTOM_ENVIRONMENT_DIVIDER = 'custom environment'
410+ export const VARIABLES_HEADING = 'variables'
411+ export const ADD_VARIABLE_LABEL = '+ new variable'
412+
413+ // The built-in "no environment at all" option's id. A sentinel, never sent: the
414+ // launcher translates it into the cleared-list override, since the wire has no
415+ // name for "empty" (omitting the environment would let the ladder resolve one).
416+ export const EMPTY_ENVIRONMENT_ID = 'empty:builtin'
417+ export const EMPTY_ENVIRONMENT_LABEL = '[empty]'
418+
419+ // The open Environment list, top to bottom: the saved environments and
420+ // "[empty]" as options, a divider, then the custom section — one checkbox per
421+ // stored secret, then the variables typed here, then the add button.
422+ //
423+ // `hover` is the index ↑/↓ walks; rows without one are DECORATION the highlight
424+ // skips, which is what keeps this a plain list rather than a nested tree.
425+ export type EnvironmentPickerRow =
426+ | { kind : 'option' ; at : number ; hover : number }
427+ | { kind : 'divider' ; label : string }
428+ | { kind : 'heading' ; label : string }
429+ | { kind : 'secret' ; name : string ; hover : number }
430+ | { kind : 'variable' ; name : string ; hover : number }
431+ | { kind : 'addVariable' ; hover : number }
432+
433+ // What activating a hovered row does, without the renderer having to know the
434+ // list's shape.
435+ export type EnvironmentTarget =
436+ | { kind : 'option' ; at : number }
437+ | { kind : 'secret' ; name : string }
438+ | { kind : 'variable' ; name : string }
439+ | { kind : 'addVariable' }
440+
441+ export interface EnvironmentPickerInput {
442+ optionCount : number
443+ // The account's stored secret names (values are write-only, so checking one
444+ // adds a variable with no value and the sandbox resolves it at start).
445+ secretNames : readonly string [ ]
446+ customVariables : readonly CustomVariable [ ]
447+ }
448+
449+ export function environmentPickerRows ( input : EnvironmentPickerInput ) : EnvironmentPickerRow [ ] {
450+ const rows : EnvironmentPickerRow [ ] = [ ]
451+ let hover = 0
452+ for ( let at = 0 ; at < input . optionCount ; at ++ ) rows . push ( { kind : 'option' , at, hover : hover ++ } )
453+ rows . push ( { kind : 'divider' , label : CUSTOM_ENVIRONMENT_DIVIDER } )
454+ rows . push ( { kind : 'heading' , label : VARIABLES_HEADING } )
455+ for ( const name of input . secretNames ) rows . push ( { kind : 'secret' , name, hover : hover ++ } )
456+ // A variable typed here whose name is also a secret rides that secret's row
457+ // instead of getting a second one: one name, one row, whichever way it got in.
458+ for ( const v of input . customVariables ) {
459+ if ( input . secretNames . includes ( v . name ) ) continue
460+ rows . push ( { kind : 'variable' , name : v . name , hover : hover ++ } )
418461 }
419- return config . id
462+ rows . push ( { kind : 'addVariable' , hover : hover ++ } )
463+ return rows
420464}
421465
422- // "owner/name" -> the config-override repository shape.
423- export function repoOverrideEntry ( fullName : string ) : { owner : string ; name : string } | null {
424- const [ owner , name ] = fullName . split ( '/' )
425- if ( ! owner || ! name ) return null
426- return { owner, name }
466+ // Where a hover index lands, clamped to the list.
467+ export function environmentPickerAt (
468+ input : EnvironmentPickerInput ,
469+ hover : number ,
470+ ) : EnvironmentTarget {
471+ const rows = environmentPickerRows ( input )
472+ const landable = rows . filter (
473+ ( r ) : r is Extract < EnvironmentPickerRow , { hover : number } > => 'hover' in r ,
474+ )
475+ const row = landable [ Math . min ( Math . max ( 0 , hover ) , landable . length - 1 ) ]
476+ if ( row . kind === 'option' ) return { kind : 'option' , at : row . at }
477+ if ( row . kind === 'secret' ) return { kind : 'secret' , name : row . name }
478+ if ( row . kind === 'variable' ) return { kind : 'variable' , name : row . name }
479+ return { kind : 'addVariable' }
427480}
428481
429- // The composer's picks, as the new-session pane reports them. `repos` null =
430- // the Repository row was never touched, so the server's own resolution stands;
431- // an array is an explicit checkout set, and [] is the legitimate "no repository
432- // at all" sandbox.
482+ export function environmentPickerCount ( input : EnvironmentPickerInput ) : number {
483+ return environmentPickerRows ( input ) . filter ( ( r ) => 'hover' in r ) . length
484+ }
485+
486+ // What the resting Environment row says: the picked environment, plus a count
487+ // of whatever the custom section adds on top of it.
488+ export function environmentRowSummary (
489+ label : string ,
490+ customVariables : readonly CustomVariable [ ] ,
491+ ) : string {
492+ if ( customVariables . length === 0 ) return label
493+ const n = customVariables . length
494+ return `${ label } +${ n } variable${ n === 1 ? '' : 's' } `
495+ }
496+
497+ // How a variable reads in the custom section: the name alone when the sandbox
498+ // resolves its value from stored secrets, otherwise the value as typed. The
499+ // VARIABLES heading already says what these are, so a valueless row needs no
500+ // note of its own.
501+ export function variableRowLabel ( name : string , value : string | null | undefined ) : string {
502+ return value === undefined || value === null ? name : `${ name } =${ value } `
503+ }
504+
505+ // A variable the launcher's custom section adds on top of the picked
506+ // environment. A null value means "resolve this name from the account's stored
507+ // secrets", the same reading `agent variable set NAME` and the environment YAML
508+ // give it.
509+ export interface CustomVariable {
510+ name : string
511+ value : string | null
512+ }
513+
514+ // The composer's picks, as the new-session pane reports them. environment and
515+ // model null = that row was never touched, so the server resolves it (the
516+ // environment ladder, the account's default model). `emptyEnvironment` is the
517+ // built-in "[empty]" pick: no saved environment, and the resolved lists cleared.
433518export interface ComposerChoices {
434- configId : string | null
519+ environment : string | null
435520 model : string | null
436- repos : string [ ] | null
521+ emptyEnvironment : boolean
522+ // The picked environment's own variables, needed because an override array
523+ // REPLACES the resolved list rather than appending to it.
524+ baseVariables : readonly CustomVariable [ ]
525+ customVariables : readonly CustomVariable [ ]
526+ }
527+
528+ // One variable list from the two that have to end up in the override, with a
529+ // later name winning: a custom entry that repeats a base name is an edit of it,
530+ // in place, not a duplicate the server would have to break the tie on.
531+ export function mergeVariables (
532+ base : readonly CustomVariable [ ] ,
533+ custom : readonly CustomVariable [ ] ,
534+ ) : CustomVariable [ ] {
535+ const merged : CustomVariable [ ] = [ ]
536+ const at = new Map < string , number > ( )
537+ for ( const v of [ ...base , ...custom ] ) {
538+ const seen = at . get ( v . name )
539+ if ( seen === undefined ) {
540+ at . set ( v . name , merged . length )
541+ merged . push ( v )
542+ } else merged [ seen ] = v
543+ }
544+ return merged
545+ }
546+
547+ // A variable in the shape an environment override takes: `value` omitted (not
548+ // null) when the name resolves from stored secrets, since the config schema
549+ // treats an absent value as the secret lookup.
550+ function variableEntry ( v : CustomVariable ) : { name : string ; value ?: string } {
551+ return v . value === null ? { name : v . name } : { name : v . name , value : v . value }
437552}
438553
439- // The entry point's base request with the composer's picks layered on: a saved
440- // config as the source, the model + repositories as a per-run config override
441- // (the dashboard composer's shape).
554+ // The entry point's base request with the composer's picks layered on: the
555+ // environment as the session's own choice, the model and any custom
556+ // environment edits as a per-run override (the dashboard composer's shape).
557+ //
558+ // Every list in an override REPLACES the resolved one, so a custom variable
559+ // ships alongside the picked environment's own — that is what makes the custom
560+ // section additive rather than a silent wipe of the environment it sits under.
442561export function applyComposerChoices (
443562 base : StartAgentSessionRequest ,
444563 choices : ComposerChoices ,
445564) : StartAgentSessionRequest {
446565 const req : StartAgentSessionRequest = { ...base }
447- if ( choices . configId ) req . config_id = choices . configId
566+ // Never combined with a config source: the launcher sends no config_id, so
567+ // the environment is always the session's to name (the server 400s both).
568+ if ( choices . environment ) req . environment = choices . environment
448569 const override : Record < string , unknown > = { }
449570 if ( choices . model ) override . claude = { model : choices . model }
450- if ( choices . repos !== null ) {
451- // Lists replace wholesale in a config override, so this set becomes the
452- // run's entire checkout — including the empty set, which a sandbox
453- // supports (zero, one, or many repositories are all valid).
454- override . environment = {
455- repositories : choices . repos
456- . map ( repoOverrideEntry )
457- . filter ( ( e ) : e is { owner : string ; name : string } => e !== null ) ,
458- }
459- // The server merges the request's `repository` context into the checkout
460- // unconditionally, even under an explicit config, so leaving it on would
461- // re-add a repo the user just unchecked. Dropping it also moves default-
462- // config resolution off that repo's rung, which is the honest reading of
463- // "not this one".
464- if ( req . repository != null && ! choices . repos . includes ( req . repository ) ) {
465- delete req . repository
466- }
571+ const environment : Record < string , unknown > = { }
572+ // "[empty]" names no environment, so the ladder would still resolve one:
573+ // clearing the lists is what actually empties the sandbox.
574+ if ( choices . emptyEnvironment ) {
575+ environment . repositories = [ ]
576+ environment . mcp_servers = [ ]
577+ }
578+ const variables = mergeVariables (
579+ choices . emptyEnvironment ? [ ] : choices . baseVariables ,
580+ choices . customVariables ,
581+ )
582+ if ( choices . emptyEnvironment || choices . customVariables . length > 0 ) {
583+ environment . variables = variables . map ( variableEntry )
467584 }
585+ if ( Object . keys ( environment ) . length > 0 ) override . environment = environment
468586 if ( Object . keys ( override ) . length > 0 ) req . override = override
469587 return req
470588}
471589
590+ // "NAME=value" / "NAME" as typed into the custom section's name field, or an
591+ // error to show in place. A bare name resolves from stored secrets (null
592+ // value); an empty value after the equals is a real empty string, which is a
593+ // legitimate thing to set a variable to.
594+ export function parseVariableEntry ( input : string ) : CustomVariable | { error : string } {
595+ const text = input . trim ( )
596+ if ( ! text ) return { error : 'name a variable' }
597+ const eq = text . indexOf ( '=' )
598+ const name = ( eq === - 1 ? text : text . slice ( 0 , eq ) ) . trim ( )
599+ if ( ! name ) return { error : 'name a variable' }
600+ // The sandbox exports these into a shell, so the name has to be a legal
601+ // shell identifier or the export silently does nothing.
602+ if ( ! / ^ [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ ] * $ / . test ( name ) ) {
603+ return { error : `"${ name } " is not a valid variable name` }
604+ }
605+ return { name, value : eq === - 1 ? null : text . slice ( eq + 1 ) }
606+ }
607+
608+ // Which rungs of the defaults ladder point at one environment: the account
609+ // rung, and every repository rung. An environment can hold several at once, so
610+ // this is a list — "account default, default for acme/api".
611+ export function environmentDefaultRungs (
612+ ladder : EnvironmentDefaults | null ,
613+ id : string ,
614+ ) : string [ ] {
615+ if ( ! ladder ) return [ ]
616+ return [
617+ ...( ladder . account === id ? [ 'account default' ] : [ ] ) ,
618+ ...Object . entries ( ladder . repositories )
619+ . filter ( ( [ , envId ] ) => envId === id )
620+ . map ( ( [ repo ] ) => `default for ${ repo } ` ) ,
621+ ]
622+ }
623+
624+ // The Environment row's options: every saved environment, each labelled with
625+ // the default rungs it holds, then the built-in "[empty]". `picked` is the row
626+ // checked while the row is untouched — the environment the ladder resolves for
627+ // the repo you are standing in, so the launcher can SEND what it shows instead
628+ // of leaving the server to resolve something else.
629+ //
630+ // A "Default" row appears only when no rung resolves at all: then there is no
631+ // name to show and the server's own resolution is the honest answer.
632+ export function environmentOptions (
633+ environments : readonly { id : string ; name : string } [ ] ,
634+ ladder : EnvironmentDefaults | null ,
635+ detectedRepo : string | null ,
636+ ) : { options : { id : string | null ; label : string } [ ] ; picked : number } {
637+ const resolved = ladder ? effectiveEnvironmentDefault ( ladder , detectedRepo ) ?. id : undefined
638+ const listed = environments . map ( ( e ) => {
639+ const rungs = environmentDefaultRungs ( ladder , e . id )
640+ return {
641+ id : e . id as string | null ,
642+ label : rungs . length > 0 ? `${ e . name } (${ rungs . join ( ', ' ) } )` : e . name ,
643+ }
644+ } )
645+ const empty = { id : EMPTY_ENVIRONMENT_ID as string | null , label : EMPTY_ENVIRONMENT_LABEL }
646+ const at = resolved ? environments . findIndex ( ( e ) => e . id === resolved ) : - 1
647+ if ( at !== - 1 ) return { options : [ ...listed , empty ] , picked : at }
648+ return {
649+ options : [ { id : null as string | null , label : 'Default' } , ...listed , empty ] ,
650+ picked : 0 ,
651+ }
652+ }
653+
654+ // The environment a config-less session in `repo` resolves to: the repo rung of
655+ // the defaults ladder, else the account rung, else null (the basic sandbox).
656+ // Repo names compare case-insensitively, the way GitHub treats them.
657+ export function effectiveEnvironmentDefault (
658+ ladder : EnvironmentDefaults ,
659+ repo : string | null ,
660+ ) : { id : string ; rung : 'repo' | 'account' } | null {
661+ const repoRung = repo
662+ ? Object . entries ( ladder . repositories ) . find (
663+ ( [ name ] ) => name . toLowerCase ( ) === repo . toLowerCase ( ) ,
664+ ) ?. [ 1 ]
665+ : undefined
666+ if ( repoRung ) return { id : repoRung , rung : 'repo' }
667+ if ( ladder . account ) return { id : ladder . account , rung : 'account' }
668+ return null
669+ }
670+
472671// ------------------------------- layout ---------------------------------
473672
474673// Which slice of the session cells renders when the list overflows the
0 commit comments