mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* Unify dedicated agent launch choices and argument badges
* Enable action model and effort menus with argument migration
* Bound argument choice edit range processing
* Preserve independent action effort through launch and migration
* Preserve literal config arguments and explicit launch permissions
* Respect repository action argument groups and share launch controls
* Remove duplicate action launch command coverage
* Add agent setup search and controls to the new tab menu
* Show inherited choices beside picker reset options
* Keep terminal Arguments separate from shared launch choices
* Launch default agents from rows and select resolved setup values
* Keep terminal settings available while Chat defaults load
* Reuse existing launch setup controls and Arguments editor
* Unify Source Control action choices and repository overrides
* Use precise types in action choice contracts
* Consolidate action choice test imports
* Correct action choice types and integration test boundaries
* Complete prepared terminal test result contract
* Align action integration expectations with canonical choices
* Keep launch defaults authoritative and couple CLI controls
* Assert production Chat preparation runs in launch contract
* Preserve alternate Chat launches and bound picker argument derivation
* Publish committed picker priority and satisfy control contracts
* test(mobile): type the file preview retry test helpers so the test typecheck passes (#35431)
(cherry picked from commit 0774fc9373)
* fix: keep picker integration tests within compiler boundaries
* fix: revalidate completed picker defaults on reopen
* test: give exported launch fixture mocks portable signatures
* fix: keep fallback launch carriers out of action mode controls
* Preserve Fast argument badges outside dialog mode controls
* Project existing Fast carriers independently of model capabilities
* Keep setup focus during launch row pointer transitions
* Allow declared tier choices without an inherited selection
* fix: place agent setup beside the launch menu
* Commit edited action arguments before planning and launch
Reconcile deliberate raw edits once into the submitted action recipe so replacement model and effort flags reach Save and Start together. Preserve captured sources and untouched load conflicts.
* Name portable Source Control dialog fixture results
* Keep agent setup contained and preserve known argument policy
* Use checked style properties in popup geometry fixture
* Keep setup scrolling inside a plain content wrapper
* fix(agent-picker): match the setup panel to the approved layout
Flush model search and list, one settings section with label-left rows
(Effort, compact Permissions menu, Fast mode), a fixed Arguments
disclosure row with its hint, and a summary footer with Launch chat /
Launch terminal. Settled defaults with nothing chosen keep the model
list and Effort pickable. Hovering another menu row no longer closes
the open panel.
* fix(agent-picker): use primitive-owned spacing and update launch labels in tests
* fix(agent-picker): add panel labels to localization catalogs
* Keep one launch setup open and align picker spacing
* Tighten launch model rows and clarify terminal hint
* Keep keyboard focus in replacement launch setup
* fix(tab-bar): keep a long agent setup result's chevron inside the menu
* test(agent-picker): wait for outgoing focus cleanup event
* Show empty model search feedback and name setup dialog
* test: register selection catalog in launch guard fixture
* fix(agent-picker): retain setup drafts and contain Arguments
* fix(agent-picker): retain authored setup route after alternate launch
* style(agent-picker): use a ghost setup chevron with divider
* style(agent-picker): apply muted color to setup glyph
* test(agent-picker): declare retained catalog default
272 lines
9.7 KiB
TypeScript
272 lines
9.7 KiB
TypeScript
import { useId, useLayoutEffect, useRef } from 'react'
|
||
import { cn } from '@/lib/utils'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||
import { sortNativeChatSessionOptions } from '../../../../shared/native-chat/input/native-chat-session-option-snapshot'
|
||
import {
|
||
isReadOnlySessionOptionValue,
|
||
type SessionOptionDescriptor,
|
||
type SessionOptionSelectChoice,
|
||
type SessionOptionValue
|
||
} from '../../../../shared/native-chat/input/native-chat-session-options'
|
||
import {
|
||
nativeChatSessionChoiceLabel,
|
||
nativeChatSessionChoiceShortLabel,
|
||
nativeChatSessionOptionActionLabel,
|
||
nativeChatSessionOptionDisabledReason,
|
||
nativeChatSessionOptionLabel
|
||
} from '../../../../shared/native-chat-session-option-labels'
|
||
import { sayNativeChatSessionOptionTranslated as say } from './native-chat-session-option-labels-text'
|
||
import { NativeChatSessionOptionWriteGroup } from './NativeChatSessionOptionWriteGroup'
|
||
|
||
type NativeChatSessionOptionRowsProps = {
|
||
/** The current model's options, in display order. */
|
||
options: readonly SessionOptionDescriptor[]
|
||
/** A reason already shown above the list, so a row does not repeat it. */
|
||
shownReason: string | null
|
||
/** Each pending descriptor holds only its own controls. */
|
||
pendingIds: ReadonlySet<string>
|
||
onSet: (descriptor: SessionOptionDescriptor, value: SessionOptionValue) => void
|
||
/** A value-less action; it has nothing to show in place, so it closes the picker. */
|
||
onAction: (descriptor: SessionOptionDescriptor) => void
|
||
/** False when the caller already separates these rows from what sits above them. */
|
||
divided?: boolean
|
||
/** Settings grids provide their own inset and spacing between option blocks. */
|
||
layout?: 'menu' | 'settings'
|
||
}
|
||
|
||
// Retain missing rows disabled for this visit so model changes cannot slide the picker under the pointer.
|
||
function withEarlierRows(
|
||
earlier: readonly SessionOptionDescriptor[],
|
||
current: readonly SessionOptionDescriptor[]
|
||
): readonly SessionOptionDescriptor[] {
|
||
const gone = earlier.filter((row) => !current.some((option) => option.id === row.id))
|
||
return gone.length === 0 ? current : sortNativeChatSessionOptions([...current, ...gone])
|
||
}
|
||
|
||
function LevelSegment(props: {
|
||
optionId: string
|
||
choice: SessionOptionSelectChoice
|
||
}): React.JSX.Element {
|
||
const { optionId, choice } = props
|
||
const detailId = useId()
|
||
const label = nativeChatSessionChoiceLabel(choice, say, optionId)
|
||
const shortLabel = nativeChatSessionChoiceShortLabel(choice, say, optionId)
|
||
// What the segment's text leaves out: the full name behind an abbreviation, and the description.
|
||
const detail = [label === shortLabel ? null : label, choice.description].filter(Boolean).join(' ')
|
||
if (!detail) {
|
||
return <ToggleGroupItem value={choice.value}>{shortLabel}</ToggleGroupItem>
|
||
}
|
||
return (
|
||
<Tooltip>
|
||
{/* Wrap the tooltip trigger so its data-state cannot replace the segment’s on/off state. */}
|
||
<TooltipTrigger asChild>
|
||
<span className="inline-flex">
|
||
{/* Link the full description directly; the tooltip links only its wrapper while open. */}
|
||
<ToggleGroupItem value={choice.value} aria-describedby={detailId}>
|
||
{shortLabel}
|
||
</ToggleGroupItem>
|
||
<span id={detailId} className="sr-only">
|
||
{detail}
|
||
</span>
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top" sideOffset={4}>
|
||
<div>{label}</div>
|
||
{choice.description ? <div>{choice.description}</div> : null}
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
)
|
||
}
|
||
|
||
function OptionRow(props: {
|
||
descriptor: SessionOptionDescriptor
|
||
layout: 'menu' | 'settings'
|
||
/** Kept from an earlier model in this visit; the current one does not offer it. */
|
||
gone: boolean
|
||
onSet: (value: SessionOptionValue) => void
|
||
onAction: () => void
|
||
}): React.JSX.Element {
|
||
const { descriptor, layout, gone, onSet, onAction } = props
|
||
const labelId = useId()
|
||
const label = nativeChatSessionOptionLabel(descriptor, say)
|
||
const off = gone || !descriptor.settable
|
||
if (descriptor.action?.type === 'toggle-command') {
|
||
return (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="xs"
|
||
disabled={off}
|
||
className="w-full justify-start"
|
||
onClick={onAction}
|
||
>
|
||
{nativeChatSessionOptionActionLabel(descriptor, say)}
|
||
</Button>
|
||
)
|
||
}
|
||
if (descriptor.action) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex min-h-7 items-center justify-between gap-3',
|
||
layout === 'menu' && 'pl-2'
|
||
)}
|
||
>
|
||
<span className="text-xs text-muted-foreground">{label}</span>
|
||
<Button type="button" variant="ghost" size="xs" disabled={off} onClick={onAction}>
|
||
{nativeChatSessionOptionActionLabel(descriptor, say)}
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
if (descriptor.kind.type === 'boolean') {
|
||
return (
|
||
<label
|
||
className={cn(
|
||
'flex min-h-7 items-center justify-between gap-3 text-xs text-muted-foreground',
|
||
layout === 'menu' && 'px-2'
|
||
)}
|
||
>
|
||
{label}
|
||
<Switch
|
||
checked={!gone && descriptor.kind.currentValue}
|
||
disabled={off}
|
||
onCheckedChange={onSet}
|
||
/>
|
||
</label>
|
||
)
|
||
}
|
||
const current = descriptor.kind.currentValue
|
||
const only = descriptor.kind.choices[0]
|
||
if (only && isReadOnlySessionOptionValue(descriptor)) {
|
||
// A row kept from the previous model names no value under this one, as a switched-off row.
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex min-h-7 items-center justify-between gap-3 text-xs',
|
||
layout === 'menu' && 'px-2'
|
||
)}
|
||
>
|
||
<span className="text-muted-foreground">{label}</span>
|
||
{gone ? null : (
|
||
<span className="text-foreground">
|
||
{nativeChatSessionChoiceLabel(only, say, descriptor.id)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex min-h-7 flex-wrap items-center justify-between gap-x-3 gap-y-1',
|
||
layout === 'menu' && 'pl-2'
|
||
)}
|
||
>
|
||
<span id={labelId} className="text-xs text-muted-foreground">
|
||
{label}
|
||
</span>
|
||
<ToggleGroup
|
||
type="single"
|
||
variant="segmented"
|
||
size="xs"
|
||
spacing={0.5}
|
||
role="radiogroup"
|
||
aria-labelledby={labelId}
|
||
value={gone ? '' : (current ?? '')}
|
||
disabled={off}
|
||
onValueChange={(value) => {
|
||
// Radix reports a press on the level in use as an empty value; it is still a press.
|
||
const pressed = value || current
|
||
if (pressed) {
|
||
onSet(pressed)
|
||
}
|
||
}}
|
||
// Why -ml-2: a wrapped group lines its first label up under the row's.
|
||
className="-ml-2 flex-wrap"
|
||
>
|
||
{descriptor.kind.choices.map((choice) => (
|
||
<LevelSegment key={choice.value} optionId={descriptor.id} choice={choice} />
|
||
))}
|
||
</ToggleGroup>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function NativeChatSessionOptionRows({
|
||
options,
|
||
shownReason,
|
||
pendingIds,
|
||
onSet,
|
||
onAction,
|
||
divided = true,
|
||
layout = 'menu'
|
||
}: NativeChatSessionOptionRowsProps): React.JSX.Element {
|
||
const shown = useRef(options)
|
||
const rows = withEarlierRows(shown.current, options)
|
||
// Why after commit: a render React throws away must not leave a row behind.
|
||
useLayoutEffect(() => {
|
||
shown.current = rows
|
||
})
|
||
const frame = useRef<HTMLDivElement>(null)
|
||
const content = useRef<HTMLDivElement>(null)
|
||
// Animate measured height as model options change, matching the model list’s size transition.
|
||
useLayoutEffect(() => {
|
||
const frameElement = frame.current
|
||
const contentElement = content.current
|
||
if (!frameElement || !contentElement) {
|
||
return
|
||
}
|
||
const publish = (): void => {
|
||
frameElement.style.setProperty('--option-rows-height', `${contentElement.offsetHeight}px`)
|
||
}
|
||
publish()
|
||
const observer = new ResizeObserver(publish)
|
||
observer.observe(contentElement)
|
||
return () => observer.disconnect()
|
||
}, [])
|
||
return (
|
||
<div
|
||
ref={frame}
|
||
className={cn(
|
||
'h-(--option-rows-height) shrink-0 overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none',
|
||
layout === 'settings' && rows.length === 0 && 'hidden'
|
||
)}
|
||
>
|
||
<div ref={content}>
|
||
{rows.length > 0 ? (
|
||
<div className={cn(divided && 'border-t border-border', layout === 'menu' && 'p-1')}>
|
||
<div className={cn('flex flex-col', layout === 'menu' ? 'gap-0.5' : 'gap-2.5')}>
|
||
{rows.map((descriptor) => {
|
||
const reason = nativeChatSessionOptionDisabledReason(descriptor.disabledReason, say)
|
||
return (
|
||
<NativeChatSessionOptionWriteGroup
|
||
key={descriptor.id}
|
||
busy={pendingIds.has(descriptor.id)}
|
||
>
|
||
<div data-option-id={descriptor.id}>
|
||
<OptionRow
|
||
descriptor={descriptor}
|
||
layout={layout}
|
||
gone={!options.some((option) => option.id === descriptor.id)}
|
||
onSet={(value) => onSet(descriptor, value)}
|
||
onAction={() => onAction(descriptor)}
|
||
/>
|
||
{reason && !descriptor.settable && reason !== shownReason ? (
|
||
<div className="px-2 pb-1 text-xs text-muted-foreground">{reason}</div>
|
||
) : null}
|
||
</div>
|
||
</NativeChatSessionOptionWriteGroup>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|