Launch a terminal instead of a chat from New workspace (#2941)
* feat(app): launch a terminal from the New workspace screen
New workspace only ever started a chat agent. Empty workspaces existed but
were reachable only by submitting an empty composer, which nothing advertised,
and terminals could not be started from this screen at all. People concluded
Paseo did not support terminals.
Adds a launch chip to the meta row, shaped from the same ComboboxTrigger as
its neighbours, that chooses between Chat and a terminal. Terminal profiles
mark where a typed prompt goes with a {{{prompt}}} sentinel in their command
or args, substituted client-side before create_terminal_request, so the daemon
learns nothing new and the protocol is untouched. A profile without the
sentinel takes no input: the composer goes read-only and shows the exact
command, so there is nothing to type wrong.
Prompt forms come from each CLI's own --help. claude, codex, and pi take a
trailing positional; opencode's positional is the project directory, so it
uses --prompt=. Profiles predating the sentinel adopt the right form on read,
keyed on the command's base name rather than the profile id, because profiles
created in settings get generated ids.
* fix(app): stop existing launchers passing the prompt sentinel literally
Adoption made every caller of resolveTerminalProfiles see sentinel-bearing
args, but only New workspace substituted them. Pinned targets and the
workspace terminal menu forwarded profile.args straight through, so launching
Claude from an existing workspace ran `claude "{{{prompt}}}"` with that text as
the prompt.
Every launcher now goes through resolveTerminalProfileLaunch. Surfaces with
nowhere to type pass an empty prompt, which drops the prompt-only arg and
gives back the bare command they had before.
Also from the same review:
- Adoption skipped when args lead with a subcommand. `opencode run` takes a
positional message rather than --prompt, and `codex login` takes no prompt at
all. Only the first arg is checked, so `pi --thinking high` still adopts.
- Settings edits the persisted list again, not the adopted view. It writes the
whole list back on any change, so resolving first baked read-time adoption
into the user's config the moment they reordered a row.
- A hidden draft no longer names a workspace. Text typed for a prompt-taking
profile stayed in state after switching to a read-only one and still seeded
the generated title and branch.
- Windows .cmd escaping now has a round-trip test: a real cmd.exe runs a real
.cmd shim that echoes its argv, covering metacharacters, an injection
attempt, and newlines. The existing tests only asserted the command line we
generate.
- Fixed an ENOTEMPTY flake in the terminal suite. kill() only signals, so the
shell was still writing into $ZDOTDIR while its directory was being removed.
Cleanup waits for exit instead.
* fix(app): keep the host and isolation accessible names stable
Wiring the new tooltips also replaced these two accessible names with the
tooltip copy, which broke `sidebar-project-grouping` at the host picker: its
selector is `getByRole("button", { name: "Host", exact: true })` and the button
had become "Choose the host". It timed out on both attempts in CI.
An accessible name identifies a control; a tooltip explains it. Conflating them
renamed a control that other surfaces address by name. The names go back to
what they were and the tooltips keep the "Choose the ..." copy.
* test(server): stop temp-dir teardown failing unrelated terminal tests
The ENOTEMPTY flake survived both earlier attempts. Waiting for the session to
exit shrank the window; retrying the remove did not close it either. The writes
come from processes zsh forks on the way out, which outlive the pty and answer
to nothing the test holds.
Teardown is now best-effort and logs what it left behind. A temp directory we
cannot remove is not a defect in the terminal code under test, and failing on it
reports the race against whichever assertion ran last, which is exactly how this
surfaced: as a flake in an unrelated zsh title test. 10 consecutive runs green.
Also replaces a cast in the Windows .cmd helper with a real narrow, so a change
to ResolvedTerminalCommand fails loudly there. Test files are excluded from
typecheck (tsconfig.server.json), so a cast in one is never checked at all.
* fix(app): keep terminal launch policy in its owning workflows
Profile adoption now parses known global option values before deciding whether argv already contains a positional. Workspace naming is derived inside the terminal workflow so hidden launch-only drafts cannot affect branch or title generation.
* fix(server): preserve cmd shim arguments through both parses
npm command shims forward %*, so metacharacters lose one escape layer before the target process receives argv. Exercise the production node-pty string command line against a forwarding shim, and only tolerate the known ENOTEMPTY teardown race.
* fix(server): stabilize Windows subprocess tests
* test(server): isolate Windows argv recorder output
* test(server): allow native watcher integration to settle
This commit is contained in:
@@ -89,6 +89,7 @@ import {
|
||||
persistAttachmentFromFileUri,
|
||||
} from "@/attachments/service";
|
||||
import { resolveAgentControlsMode } from "@/composer/agent-controls/mode";
|
||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
@@ -186,7 +187,21 @@ function resolveCompactLayout(override: boolean | undefined, formFactor: boolean
|
||||
return override ?? formFactor;
|
||||
}
|
||||
|
||||
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean, t: TFunction): string {
|
||||
function resolveMessagePlaceholder(
|
||||
inputMode: ComposerInputMode,
|
||||
isDesktopWebBreakpoint: boolean,
|
||||
t: TFunction,
|
||||
override: string | undefined,
|
||||
): string {
|
||||
// A terminal placeholder names what it launches ("Prompt Codex", "Run a
|
||||
// command"), which depends on the selected profile. Only the caller knows
|
||||
// that, so it wins when supplied.
|
||||
if (override !== undefined) {
|
||||
return override;
|
||||
}
|
||||
if (inputMode === "terminal") {
|
||||
return t("composer.placeholders.terminal");
|
||||
}
|
||||
return isDesktopWebBreakpoint
|
||||
? t("composer.placeholders.desktop")
|
||||
: t("composer.placeholders.mobile");
|
||||
@@ -280,10 +295,12 @@ interface RenderLeftContentArgs {
|
||||
focusInput: () => void;
|
||||
isCompactLayout: boolean;
|
||||
isPaneFocused: boolean;
|
||||
showAgentControls: boolean;
|
||||
}
|
||||
|
||||
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
||||
function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null {
|
||||
const { agentControls, agentId, serverId, focusInput, isCompactLayout, isPaneFocused } = args;
|
||||
if (!args.showAgentControls) return null;
|
||||
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
|
||||
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
|
||||
}
|
||||
@@ -858,6 +875,18 @@ interface ComposerProps {
|
||||
externalKeyboardShift?: boolean;
|
||||
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
|
||||
isCompactLayout?: boolean;
|
||||
/**
|
||||
* What this composer is for. Terminal drops the chat-agent affordances and
|
||||
* uses the terminal font; see `@/composer/input-mode`. Callers set the mode
|
||||
* and nothing else — never branch on it at the call site.
|
||||
*/
|
||||
inputMode?: ComposerInputMode;
|
||||
/** Renders `value` as static text on the same surface, for content there is nothing to type into. */
|
||||
readOnly?: boolean;
|
||||
/** Replaces the submit icon with this label, still inside the composer's own toolbar row. */
|
||||
submitLabel?: string;
|
||||
/** Overrides the mode's default placeholder, for text only the caller can build. */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
|
||||
@@ -942,6 +971,7 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
|
||||
isAgentRunning: boolean;
|
||||
hasSendableContent: boolean;
|
||||
isCompact: boolean;
|
||||
showVoice: boolean;
|
||||
}
|
||||
|
||||
function ComposerRightControlsSlot({
|
||||
@@ -950,11 +980,12 @@ function ComposerRightControlsSlot({
|
||||
isAgentRunning,
|
||||
hasSendableContent,
|
||||
isCompact,
|
||||
showVoice,
|
||||
...voiceProps
|
||||
}: ComposerRightControlsSlotProps) {
|
||||
const hideVoiceForCompactInput = isCompact && hasSendableContent;
|
||||
const showVoiceModeButton =
|
||||
!isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
|
||||
showVoice && !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
|
||||
if (!showVoiceModeButton) return null;
|
||||
return (
|
||||
<View style={styles.rightControls}>
|
||||
@@ -1043,7 +1074,12 @@ export function Composer({
|
||||
inputWrapperStyle,
|
||||
externalKeyboardShift,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
inputMode = "chat",
|
||||
readOnly = false,
|
||||
submitLabel,
|
||||
placeholder,
|
||||
}: ComposerProps) {
|
||||
const mode = resolveComposerInputMode(inputMode);
|
||||
const { t } = useTranslation();
|
||||
const buttonIconSize = resolveComposerButtonIconSize();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
@@ -1076,7 +1112,7 @@ export function Composer({
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder);
|
||||
const userInput = value;
|
||||
const setUserInput = onChangeText;
|
||||
const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys);
|
||||
@@ -1743,6 +1779,7 @@ export function Composer({
|
||||
isAgentRunning={isAgentRunning}
|
||||
hasSendableContent={hasSendableContent}
|
||||
isCompact={isCompactLayout}
|
||||
showVoice={mode.showVoice}
|
||||
buttonIconSize={buttonIconSize}
|
||||
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
|
||||
isConnected={isConnected}
|
||||
@@ -1762,6 +1799,7 @@ export function Composer({
|
||||
isCompactLayout,
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitching,
|
||||
mode.showVoice,
|
||||
realtimeVoiceButtonStyle,
|
||||
t,
|
||||
voiceToggleKeys,
|
||||
@@ -1925,8 +1963,17 @@ export function Composer({
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
isPaneFocused,
|
||||
showAgentControls: mode.showAgentControls,
|
||||
}),
|
||||
[agentControls, agentId, focusInput, isCompactLayout, isPaneFocused, serverId],
|
||||
[
|
||||
agentControls,
|
||||
agentId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
isPaneFocused,
|
||||
mode.showAgentControls,
|
||||
serverId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
@@ -2051,7 +2098,7 @@ export function Composer({
|
||||
const githubEmptyText = githubSearchResultsQuery.isFetching
|
||||
? t("composer.github.searching")
|
||||
: t("composer.github.noResults");
|
||||
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
|
||||
const autocompleteVisible = autocomplete.isVisible && isPaneFocused && mode.showAutocomplete;
|
||||
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
@@ -2118,6 +2165,9 @@ export function Composer({
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
inputMode={inputMode}
|
||||
readOnly={readOnly}
|
||||
submitLabel={submitLabel}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* What a composer is being used for. `Composer` and `MessageInput` are one
|
||||
* component with two jobs, not two components: a terminal prompt still wants
|
||||
* the same bordered surface, grow-on-newline, submit button, focus handling,
|
||||
* and keyboard shift that a chat message gets.
|
||||
*
|
||||
* Callers pick a mode and nothing else. What a mode implies lives here, so the
|
||||
* two presentations cannot drift apart one prop at a time.
|
||||
*/
|
||||
export type ComposerInputMode = "chat" | "terminal";
|
||||
|
||||
export interface ComposerInputModePresentation {
|
||||
/** Attachments, dictation/voice, slash-and-@ autocomplete, and the model/mode
|
||||
* chips are all chat-agent affordances. A terminal prompt becomes argv. */
|
||||
showAttachments: boolean;
|
||||
showVoice: boolean;
|
||||
showAutocomplete: boolean;
|
||||
showAgentControls: boolean;
|
||||
/** Argv is read character by character, so it gets the terminal's own font. */
|
||||
isMonospace: boolean;
|
||||
/** i18n key for the text input's accessible name. */
|
||||
accessibilityLabelKey: string;
|
||||
}
|
||||
|
||||
const PRESENTATION_BY_MODE: Record<ComposerInputMode, ComposerInputModePresentation> = {
|
||||
chat: {
|
||||
showAttachments: true,
|
||||
showVoice: true,
|
||||
showAutocomplete: true,
|
||||
showAgentControls: true,
|
||||
isMonospace: false,
|
||||
accessibilityLabelKey: "composer.input.accessibilityLabel",
|
||||
},
|
||||
terminal: {
|
||||
showAttachments: false,
|
||||
showVoice: false,
|
||||
showAutocomplete: false,
|
||||
showAgentControls: false,
|
||||
isMonospace: true,
|
||||
accessibilityLabelKey: "composer.input.terminalAccessibilityLabel",
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveComposerInputMode(mode: ComposerInputMode): ComposerInputModePresentation {
|
||||
return PRESENTATION_BY_MODE[mode];
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useComposerHeightMirror } from "./height-mirror";
|
||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||
import {
|
||||
resolveSendTooltipLabel,
|
||||
resolveSubmitAccessibilityLabel,
|
||||
@@ -142,6 +143,12 @@ export interface MessageInputProps {
|
||||
inputWrapperStyle?: import("react-native").ViewStyle;
|
||||
/** Content rendered inside the bordered input surface, above the text input (e.g. attachment pills). */
|
||||
attachmentSlot?: React.ReactNode;
|
||||
/** What this composer is for. See `@/composer/input-mode` for what each mode implies. */
|
||||
inputMode?: ComposerInputMode;
|
||||
/** Renders `value` as static text on the same surface, for content there is nothing to type into. */
|
||||
readOnly?: boolean;
|
||||
/** Replaces the submit icon with this label, still inside the composer's own toolbar row. */
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export interface MessageInputRef {
|
||||
@@ -220,6 +227,7 @@ function AttachmentMenuList({ items }: { items: AttachmentMenuItem[] }) {
|
||||
}
|
||||
|
||||
function AttachmentDropdown({
|
||||
visible,
|
||||
isConnected,
|
||||
disabled,
|
||||
attachButtonStyle,
|
||||
@@ -227,6 +235,7 @@ function AttachmentDropdown({
|
||||
attachmentMenuItems,
|
||||
addAttachmentLabel,
|
||||
}: {
|
||||
visible: boolean;
|
||||
isConnected: boolean;
|
||||
disabled: boolean;
|
||||
attachButtonStyle: React.ComponentProps<typeof DropdownMenuTrigger>["style"];
|
||||
@@ -235,6 +244,7 @@ function AttachmentDropdown({
|
||||
addAttachmentLabel: string;
|
||||
}) {
|
||||
const isButtonDisabled = !isConnected || disabled;
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -323,15 +333,20 @@ function SendTooltipBody({
|
||||
function SendButtonContent({
|
||||
isSubmitLoading,
|
||||
submitIcon,
|
||||
submitLabel,
|
||||
buttonIconSize,
|
||||
}: {
|
||||
isSubmitLoading: boolean;
|
||||
submitIcon: "arrow" | "return";
|
||||
submitLabel: string | undefined;
|
||||
buttonIconSize: number;
|
||||
}) {
|
||||
if (isSubmitLoading) {
|
||||
return <ThemedLoadingSpinner size="small" uniProps={iconAccentForegroundMapping} />;
|
||||
}
|
||||
if (submitLabel) {
|
||||
return <Text style={styles.sendButtonLabel}>{submitLabel}</Text>;
|
||||
}
|
||||
if (submitIcon === "return") {
|
||||
return <ThemedCornerDownLeft size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
|
||||
}
|
||||
@@ -570,7 +585,75 @@ function FocusHint({
|
||||
);
|
||||
}
|
||||
|
||||
interface ComposerTextSurfaceProps {
|
||||
readOnly: boolean;
|
||||
value: string;
|
||||
textInputRef: React.Ref<TextInput>;
|
||||
textInputStyle: React.ComponentProps<typeof ThemedTextInput>["style"];
|
||||
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
|
||||
placeholder: string;
|
||||
accessibilityLabel: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
editable: boolean;
|
||||
scrollEnabled: boolean;
|
||||
autoFocus: boolean;
|
||||
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
|
||||
onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined;
|
||||
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
|
||||
focusHintVisible: boolean;
|
||||
focusInputKeys: ShortcutChord | null | undefined;
|
||||
focusHintLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer's content: an editable input, or static text when there is
|
||||
* nothing to type. Both sit in the same bordered surface, so read-only is a
|
||||
* state of this composer rather than a second one.
|
||||
*/
|
||||
function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement {
|
||||
if (props.readOnly) {
|
||||
return (
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
<Text style={props.readOnlyTextStyle} testID="composer-readonly-content">
|
||||
{props.value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
<ThemedTextInput
|
||||
ref={props.textInputRef}
|
||||
dataSet={COMPOSER_INPUT_DATASET}
|
||||
value={props.value}
|
||||
onChangeText={props.onChangeText}
|
||||
placeholder={props.placeholder}
|
||||
uniProps={textInputPlaceholderColorMapping}
|
||||
accessibilityLabel={props.accessibilityLabel}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
style={props.textInputStyle}
|
||||
multiline
|
||||
scrollEnabled={props.scrollEnabled}
|
||||
onContentSizeChange={props.onContentSizeChange}
|
||||
editable={props.editable}
|
||||
onKeyPress={props.onKeyPress}
|
||||
onSelectionChange={props.onSelectionChange}
|
||||
autoFocus={props.autoFocus}
|
||||
/>
|
||||
<FocusHint
|
||||
visible={props.focusHintVisible}
|
||||
focusInputKeys={props.focusInputKeys}
|
||||
label={props.focusHintLabel}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceButtonTooltip({
|
||||
visible,
|
||||
onVoicePress,
|
||||
isDictationStartEnabled,
|
||||
voiceButtonAccessibilityLabel,
|
||||
@@ -581,6 +664,7 @@ function VoiceButtonTooltip({
|
||||
voiceMuteToggleKeys,
|
||||
dictationToggleKeys,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onVoicePress: () => void;
|
||||
isDictationStartEnabled: boolean;
|
||||
voiceButtonAccessibilityLabel: string;
|
||||
@@ -592,6 +676,7 @@ function VoiceButtonTooltip({
|
||||
dictationToggleKeys: ShortcutChord | null | undefined;
|
||||
}) {
|
||||
const shortcut = isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys;
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
@@ -620,6 +705,7 @@ function SendButtonTooltip({
|
||||
sendButtonCombinedStyle,
|
||||
isSubmitLoading,
|
||||
submitIcon,
|
||||
submitLabel,
|
||||
submitButtonTestID,
|
||||
buttonIconSize,
|
||||
sendKeys,
|
||||
@@ -634,6 +720,7 @@ function SendButtonTooltip({
|
||||
sendButtonCombinedStyle: React.ComponentProps<typeof TooltipTrigger>["style"];
|
||||
isSubmitLoading: boolean;
|
||||
submitIcon: "arrow" | "return";
|
||||
submitLabel: string | undefined;
|
||||
submitButtonTestID: string | undefined;
|
||||
buttonIconSize: number;
|
||||
sendKeys: ShortcutChord | null | undefined;
|
||||
@@ -653,6 +740,7 @@ function SendButtonTooltip({
|
||||
<SendButtonContent
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
submitLabel={submitLabel}
|
||||
buttonIconSize={buttonIconSize}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
@@ -954,6 +1042,9 @@ interface ResolvedMessageInputProps {
|
||||
onHeightChange: ((height: number) => void) | undefined;
|
||||
inputWrapperStyle: import("react-native").ViewStyle | undefined;
|
||||
attachmentSlot: React.ReactNode;
|
||||
inputMode: ComposerInputMode;
|
||||
readOnly: boolean;
|
||||
submitLabel: string | undefined;
|
||||
}
|
||||
|
||||
function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInputProps {
|
||||
@@ -997,6 +1088,9 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
onHeightChange: props.onHeightChange,
|
||||
inputWrapperStyle: props.inputWrapperStyle,
|
||||
attachmentSlot: props.attachmentSlot,
|
||||
inputMode: props.inputMode ?? "chat",
|
||||
readOnly: props.readOnly ?? false,
|
||||
submitLabel: props.submitLabel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1048,7 +1142,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onHeightChange,
|
||||
inputWrapperStyle,
|
||||
attachmentSlot,
|
||||
inputMode,
|
||||
readOnly,
|
||||
submitLabel,
|
||||
} = resolveMessageInputProps(props);
|
||||
const mode = resolveComposerInputMode(inputMode);
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
@@ -1546,18 +1644,38 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const inputWrapperCombinedStyle = useMemo(
|
||||
() => [
|
||||
styles.inputWrapper,
|
||||
readOnly && styles.inputWrapperReadOnly,
|
||||
inputWrapperStyle,
|
||||
{ opacity: surfacePresentation.input.opacity },
|
||||
],
|
||||
[inputWrapperStyle, surfacePresentation.input.opacity],
|
||||
[inputWrapperStyle, readOnly, surfacePresentation.input.opacity],
|
||||
);
|
||||
// `withUnistyles` maps this component's `style` into a `.hash > *` child
|
||||
// rule, which ties on specificity with react-native-web's own
|
||||
// `.css-textinput-*` class and loses on source order — so a themed
|
||||
// `fontFamily` here is silently dropped while every other property lands.
|
||||
// An inline style outranks both classes. See docs/unistyles.md.
|
||||
const textInputStyle = useMemo(
|
||||
() => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)],
|
||||
[inputHeight, maxInputHeight],
|
||||
() => [
|
||||
styles.textInput,
|
||||
mode.isMonospace && styles.textInputMonospace,
|
||||
computeTextInputHeightStyle(inputHeight, maxInputHeight),
|
||||
],
|
||||
[inputHeight, maxInputHeight, mode.isMonospace],
|
||||
);
|
||||
// Static content has no textarea to mirror, so it grows with its own text
|
||||
// instead of the measured input height.
|
||||
const readOnlyTextStyle = useMemo(
|
||||
() => [styles.textInput, mode.isMonospace && styles.textInputMonospace, styles.readOnlyText],
|
||||
[mode.isMonospace],
|
||||
);
|
||||
const sendButtonCombinedStyle = useMemo(
|
||||
() => [styles.sendButton, isSendButtonDisabled && styles.buttonDisabled],
|
||||
[isSendButtonDisabled],
|
||||
() => [
|
||||
styles.sendButton,
|
||||
submitLabel ? styles.sendButtonLabeled : undefined,
|
||||
isSendButtonDisabled && styles.buttonDisabled,
|
||||
],
|
||||
[isSendButtonDisabled, submitLabel],
|
||||
);
|
||||
const overlayContainerStyle = useMemo(
|
||||
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
|
||||
@@ -1597,40 +1715,36 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
>
|
||||
{attachmentSlot}
|
||||
{/* Text input */}
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
<ThemedTextInput
|
||||
ref={textInputRef}
|
||||
dataSet={COMPOSER_INPUT_DATASET}
|
||||
value={value}
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder ?? t("composer.placeholders.fallback")}
|
||||
uniProps={textInputPlaceholderColorMapping}
|
||||
accessibilityLabel={t("composer.input.accessibilityLabel")}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
style={textInputStyle}
|
||||
multiline
|
||||
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
autoFocus={isWeb && autoFocus}
|
||||
/>
|
||||
<FocusHint
|
||||
visible={isWeb && isPaneFocused && !isInputFocused && !value}
|
||||
focusInputKeys={focusInputKeys}
|
||||
label={t("composer.input.focusHint", {
|
||||
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
<ComposerTextSurface
|
||||
readOnly={readOnly}
|
||||
value={value}
|
||||
textInputRef={textInputRef}
|
||||
textInputStyle={textInputStyle}
|
||||
readOnlyTextStyle={readOnlyTextStyle}
|
||||
placeholder={placeholder ?? t("composer.placeholders.fallback")}
|
||||
accessibilityLabel={t(mode.accessibilityLabelKey)}
|
||||
onChangeText={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
scrollEnabled={isWeb ? inputHeight >= maxInputHeight : true}
|
||||
autoFocus={isWeb && autoFocus}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value}
|
||||
focusInputKeys={focusInputKeys}
|
||||
focusHintLabel={t("composer.input.focusHint", {
|
||||
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Button row */}
|
||||
<View style={styles.buttonRow}>
|
||||
{/* Toolbar left: attachment button + agent controls */}
|
||||
<View style={styles.leftButtonGroup}>
|
||||
<AttachmentDropdown
|
||||
visible={mode.showAttachments}
|
||||
isConnected={isConnected}
|
||||
disabled={disabled}
|
||||
attachButtonStyle={attachButtonStyle}
|
||||
@@ -1645,6 +1759,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
<View style={styles.rightButtonGroup}>
|
||||
{beforeVoiceContent}
|
||||
<VoiceButtonTooltip
|
||||
visible={mode.showVoice}
|
||||
onVoicePress={handleVoicePress}
|
||||
isDictationStartEnabled={isDictationStartEnabled}
|
||||
voiceButtonAccessibilityLabel={voiceButtonAccessibilityLabel}
|
||||
@@ -1668,6 +1783,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
sendButtonCombinedStyle={sendButtonCombinedStyle}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
submitLabel={submitLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
buttonIconSize={buttonIconSize}
|
||||
sendKeys={DEFAULT_SEND_KEYS}
|
||||
@@ -1731,6 +1847,11 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
// Dotted says "this surface is the same box, but there is nothing to type
|
||||
// into it" without swapping the border colour, which reads as an error state.
|
||||
inputWrapperReadOnly: {
|
||||
borderStyle: "dotted",
|
||||
},
|
||||
textInputScrollWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
@@ -1756,6 +1877,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
} as object)
|
||||
: {}),
|
||||
},
|
||||
textInputMonospace: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
readOnlyText: {
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
@@ -1808,6 +1936,17 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
justifyContent: "center",
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sendButtonLabeled: {
|
||||
width: "auto",
|
||||
minWidth: 28,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
sendButtonLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.accentForeground,
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -159,4 +159,33 @@ describe("create agent preferences", () => {
|
||||
it("rejects an unknown isolation value as invalid stored preferences", () => {
|
||||
expect(parseFormPreferences({ provider: "codex", isolation: "sandbox" })).toEqual({});
|
||||
});
|
||||
|
||||
it("persists and reloads a terminal launch target", async () => {
|
||||
const storage = new FakeCreateAgentPreferenceStorage();
|
||||
const preferences = new CreateAgentPreferencesService(storage);
|
||||
|
||||
const save = preferences.update({ launchTarget: { kind: "terminal", profileId: "claude" } });
|
||||
await storage.nextWrite();
|
||||
storage.finishOldestWrite();
|
||||
await save;
|
||||
|
||||
expect(storage.savedPreferences()).toEqual({
|
||||
launchTarget: { kind: "terminal", profileId: "claude" },
|
||||
});
|
||||
expect(await new CreateAgentPreferencesService(storage).load()).toEqual({
|
||||
launchTarget: { kind: "terminal", profileId: "claude" },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats stored preferences without a launch target as undefined, defaulting to chat", () => {
|
||||
expect(parseFormPreferences({ provider: "codex" }).launchTarget).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a terminal launch target missing a profileId as invalid stored preferences", () => {
|
||||
expect(parseFormPreferences({ launchTarget: { kind: "terminal" } })).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects an unknown launch target kind as invalid stored preferences", () => {
|
||||
expect(parseFormPreferences({ launchTarget: { kind: "shell" } })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,11 @@ const providerPreferencesSchema = z.object({
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const launchTargetSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("chat") }),
|
||||
z.object({ kind: z.literal("terminal"), profileId: z.string() }),
|
||||
]);
|
||||
|
||||
const formPreferencesSchema = z.object({
|
||||
provider: z.string().optional(),
|
||||
providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(),
|
||||
@@ -34,10 +39,14 @@ const formPreferencesSchema = z.object({
|
||||
)
|
||||
.optional(),
|
||||
isolation: z.enum(["local", "worktree"]).optional(),
|
||||
// What the New workspace composer submits to: the chat agent (default) or a
|
||||
// terminal profile. See `@/new-workspace-launch` for resolution/fallback.
|
||||
launchTarget: launchTargetSchema.optional(),
|
||||
});
|
||||
|
||||
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
|
||||
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
|
||||
export type LaunchTarget = z.infer<typeof launchTargetSchema>;
|
||||
|
||||
export const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
|
||||
|
||||
|
||||
@@ -81,9 +81,11 @@ export const ar: TranslationResources = {
|
||||
desktop: "أرسل رسالة إلى الوكيل أو ضع علامة على @files أو استخدم /commands و /skills",
|
||||
mobile: "الرسالة، @files ، /commands",
|
||||
fallback: "رسالة...",
|
||||
terminal: "Prompt",
|
||||
},
|
||||
input: {
|
||||
accessibilityLabel: "وكيل الرسائل...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
focusHint: "{{shortcut}}للتركيز",
|
||||
addAttachment: "إضافة مرفق",
|
||||
interruptAgent: "عامل المقاطعة",
|
||||
@@ -1052,15 +1054,30 @@ export const ar: TranslationResources = {
|
||||
composerStateRequired: "حالة الملحن مطلوبة",
|
||||
selectModel: "اختر نموذجا",
|
||||
},
|
||||
tooltips: {
|
||||
project: "Choose the project",
|
||||
host: "Choose the host",
|
||||
isolation: "Choose the isolation level",
|
||||
startingRef: "اختر من أين تبدأ",
|
||||
launch: "Choose what to launch",
|
||||
},
|
||||
refPicker: {
|
||||
startingRef: "بدء المرجع",
|
||||
chooseStart: "اختر من أين تبدأ",
|
||||
intoBase: "إلى {{baseRef}}",
|
||||
searching: "جارٍ البحث...",
|
||||
noMatchingRefs: "لا توجد مراجع مطابقة.",
|
||||
searchPlaceholder: "بحث الفروع والعلاقات العامة",
|
||||
title: "ابدأ من",
|
||||
},
|
||||
launch: {
|
||||
title: "What to launch",
|
||||
chat: "Chat",
|
||||
terminal: "Terminal",
|
||||
manageProfiles: "Manage profiles",
|
||||
submit: "Launch",
|
||||
promptPlaceholder: "Prompt {{name}}",
|
||||
commandPlaceholder: "Run a command, or leave empty for a blank terminal",
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
quitting: {
|
||||
|
||||
@@ -79,9 +79,11 @@ export const en = {
|
||||
desktop: "Message the agent, tag @files, or use /commands and /skills",
|
||||
mobile: "Message, @files, /commands",
|
||||
fallback: "Message...",
|
||||
terminal: "Prompt",
|
||||
},
|
||||
input: {
|
||||
accessibilityLabel: "Message agent...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
focusHint: "{{shortcut}} to focus",
|
||||
addAttachment: "Add attachment",
|
||||
interruptAgent: "Interrupt agent",
|
||||
@@ -1062,15 +1064,30 @@ export const en = {
|
||||
composerStateRequired: "Composer state is required",
|
||||
selectModel: "Select a model",
|
||||
},
|
||||
tooltips: {
|
||||
project: "Choose the project",
|
||||
host: "Choose the host",
|
||||
isolation: "Choose the isolation level",
|
||||
startingRef: "Choose where to start from",
|
||||
launch: "Choose what to launch",
|
||||
},
|
||||
refPicker: {
|
||||
startingRef: "Starting ref",
|
||||
chooseStart: "Choose where to start from",
|
||||
intoBase: "into {{baseRef}}",
|
||||
searching: "Searching...",
|
||||
noMatchingRefs: "No matching refs.",
|
||||
searchPlaceholder: "Search branches and PRs",
|
||||
title: "Start from",
|
||||
},
|
||||
launch: {
|
||||
title: "What to launch",
|
||||
chat: "Chat",
|
||||
terminal: "Terminal",
|
||||
manageProfiles: "Manage profiles",
|
||||
submit: "Launch",
|
||||
promptPlaceholder: "Prompt {{name}}",
|
||||
commandPlaceholder: "Run a command, or leave empty for a blank terminal",
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
quitting: {
|
||||
|
||||
@@ -81,9 +81,11 @@ export const es: TranslationResources = {
|
||||
desktop: "Envíe un mensaje al agente, etiquete@fileso use/commandsy/skills",
|
||||
mobile: "Mensaje,@files,/commands",
|
||||
fallback: "Mensaje...",
|
||||
terminal: "Prompt",
|
||||
},
|
||||
input: {
|
||||
accessibilityLabel: "Agente de mensajes...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
focusHint: "{{shortcut}}para enfocar",
|
||||
addAttachment: "Agregar archivo adjunto",
|
||||
interruptAgent: "agente de interrupción",
|
||||
@@ -1083,15 +1085,30 @@ export const es: TranslationResources = {
|
||||
composerStateRequired: "Se requiere el estado del compositor",
|
||||
selectModel: "Selecciona un modelo",
|
||||
},
|
||||
tooltips: {
|
||||
project: "Choose the project",
|
||||
host: "Choose the host",
|
||||
isolation: "Choose the isolation level",
|
||||
startingRef: "Elige por dónde empezar",
|
||||
launch: "Choose what to launch",
|
||||
},
|
||||
refPicker: {
|
||||
startingRef: "Árbitro inicial",
|
||||
chooseStart: "Elige por dónde empezar",
|
||||
intoBase: "en {{baseRef}}",
|
||||
searching: "Búsqueda...",
|
||||
noMatchingRefs: "No hay árbitros coincidentes.",
|
||||
searchPlaceholder: "Buscar sucursales y relaciones públicas",
|
||||
title: "Empezar desde",
|
||||
},
|
||||
launch: {
|
||||
title: "What to launch",
|
||||
chat: "Chat",
|
||||
terminal: "Terminal",
|
||||
manageProfiles: "Manage profiles",
|
||||
submit: "Launch",
|
||||
promptPlaceholder: "Prompt {{name}}",
|
||||
commandPlaceholder: "Run a command, or leave empty for a blank terminal",
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
quitting: {
|
||||
|
||||
@@ -83,9 +83,11 @@ export const fr: TranslationResources = {
|
||||
desktop: "Envoyez un message à l'agent, marquez@filesou utilisez/commandset/skills",
|
||||
mobile: "Message,@files,/commands",
|
||||
fallback: "Message...",
|
||||
terminal: "Prompt",
|
||||
},
|
||||
input: {
|
||||
accessibilityLabel: "Agent de messagerie...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
focusHint: "{{shortcut}}pour se concentrer",
|
||||
addAttachment: "Ajouter une pièce jointe",
|
||||
interruptAgent: "Agent d'interruption",
|
||||
@@ -1082,15 +1084,30 @@ export const fr: TranslationResources = {
|
||||
composerStateRequired: "L'état du compositeur est requis",
|
||||
selectModel: "Sélectionnez un modèle",
|
||||
},
|
||||
tooltips: {
|
||||
project: "Choose the project",
|
||||
host: "Choose the host",
|
||||
isolation: "Choose the isolation level",
|
||||
startingRef: "Choisissez par où commencer",
|
||||
launch: "Choose what to launch",
|
||||
},
|
||||
refPicker: {
|
||||
startingRef: "Réf de départ",
|
||||
chooseStart: "Choisissez par où commencer",
|
||||
intoBase: "dans {{baseRef}}",
|
||||
searching: "Recherche...",
|
||||
noMatchingRefs: "Aucune référence correspondante.",
|
||||
searchPlaceholder: "Rechercher des succursales et des PR",
|
||||
title: "Commencer à partir de",
|
||||
},
|
||||
launch: {
|
||||
title: "What to launch",
|
||||
chat: "Chat",
|
||||
terminal: "Terminal",
|
||||
manageProfiles: "Manage profiles",
|
||||
submit: "Launch",
|
||||
promptPlaceholder: "Prompt {{name}}",
|
||||
commandPlaceholder: "Run a command, or leave empty for a blank terminal",
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
quitting: {
|
||||
|
||||
@@ -81,9 +81,11 @@ export const ja: TranslationResources = {
|
||||
desktop: "エージェントにメッセージ、@ファイル、/コマンドや/スキルを入力",
|
||||
mobile: "メッセージ、@ファイル、/コマンド",
|
||||
fallback: "メッセージ...",
|
||||
terminal: "Prompt",
|
||||
},
|
||||
input: {
|
||||
accessibilityLabel: "エージェントにメッセージ...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
focusHint: "{{shortcut}}でフォーカス",
|
||||
addAttachment: "添付ファイルを追加",
|
||||
interruptAgent: "エージェントを中断",
|
||||
@@ -1063,15 +1065,30 @@ export const ja: TranslationResources = {
|
||||
composerStateRequired: "コンポーザーの状態が必要です",
|
||||
selectModel: "モデルを選択してください",
|
||||
},
|
||||
tooltips: {
|
||||
project: "Choose the project",
|
||||
host: "Choose the host",
|
||||
isolation: "Choose the isolation level",
|
||||
startingRef: "開始点を選択",
|
||||
launch: "Choose what to launch",
|
||||
},
|
||||
refPicker: {
|
||||
startingRef: "開始Ref",
|
||||
chooseStart: "開始点を選択",
|
||||
intoBase: "{{baseRef}}に",
|
||||
searching: "検索中...",
|
||||
noMatchingRefs: "一致するRefがありません。",
|
||||
searchPlaceholder: "ブランチとPRを検索",
|
||||
title: "開始点",
|
||||
},
|
||||
launch: {
|
||||
title: "What to launch",
|
||||
chat: "Chat",
|
||||
terminal: "Terminal",
|
||||
manageProfiles: "Manage profiles",
|
||||
submit: "Launch",
|
||||
promptPlaceholder: "Prompt {{name}}",
|
||||
commandPlaceholder: "Run a command, or leave empty for a blank terminal",
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
quitting: {
|
||||
|
||||
@@ -81,9 +81,11 @@ export const ko: TranslationResources = {
|
||||
desktop: "에이전트에게 메시지를 보내거나 @files 태그, /commands, /skills를 사용하세요",
|
||||
mobile: "메시지, @files, /commands",
|
||||
fallback: "메시지...",
|
||||
terminal: "Prompt",
|
||||
},
|
||||
input: {
|
||||
accessibilityLabel: "에이전트에게 메시지...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
focusHint: "{{shortcut}}로 포커스",
|
||||
addAttachment: "첨부 추가",
|
||||
interruptAgent: "에이전트 중단",
|
||||
@@ -1059,15 +1061,30 @@ export const ko: TranslationResources = {
|
||||
composerStateRequired: "작성기 상태가 필요합니다",
|
||||
selectModel: "모델을 선택하세요",
|
||||
},
|
||||
tooltips: {
|
||||
project: "Choose the project",
|
||||
host: "Choose the host",
|
||||
isolation: "Choose the isolation level",
|
||||
startingRef: "시작 위치를 선택하세요",
|
||||
launch: "Choose what to launch",
|
||||
},
|
||||
refPicker: {
|
||||
startingRef: "시작 ref",
|
||||
chooseStart: "시작 위치를 선택하세요",
|
||||
intoBase: "{{baseRef}}(으)로",
|
||||
searching: "검색 중...",
|
||||
noMatchingRefs: "일치하는 ref가 없습니다.",
|
||||
searchPlaceholder: "브랜치와 PR 검색",
|
||||
title: "시작 위치",
|
||||
},
|
||||
launch: {
|
||||
title: "What to launch",
|
||||
chat: "Chat",
|
||||
terminal: "Terminal",
|
||||
manageProfiles: "Manage profiles",
|
||||
submit: "Launch",
|
||||
promptPlaceholder: "Prompt {{name}}",
|
||||
commandPlaceholder: "Run a command, or leave empty for a blank terminal",
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
quitting: {
|
||||
|
||||
@@ -81,9 +81,11 @@ export const ptBR: TranslationResources = {
|
||||
desktop: "Envie uma mensagem ao agente, marque @files ou use /commands e /skills",
|
||||
mobile: "Mensagem, @files, /commands",
|
||||
fallback: "Mensagem...",
|
||||
terminal: "Prompt",
|
||||
},
|
||||
input: {
|
||||
accessibilityLabel: "Enviar mensagem ao agente...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
focusHint: "{{shortcut}} para focar",
|
||||
addAttachment: "Adicionar anexo",
|
||||
interruptAgent: "Interromper agente",
|
||||
@@ -1074,15 +1076,30 @@ export const ptBR: TranslationResources = {
|
||||
composerStateRequired: "O estado do composer é obrigatório",
|
||||
selectModel: "Selecione um modelo",
|
||||
},
|
||||
tooltips: {
|
||||
project: "Choose the project",
|
||||
host: "Choose the host",
|
||||
isolation: "Choose the isolation level",
|
||||
startingRef: "Escolha de onde começar",
|
||||
launch: "Choose what to launch",
|
||||
},
|
||||
refPicker: {
|
||||
startingRef: "Ref inicial",
|
||||
chooseStart: "Escolha de onde começar",
|
||||
intoBase: "em {{baseRef}}",
|
||||
searching: "Buscando...",
|
||||
noMatchingRefs: "Nenhuma ref correspondente.",
|
||||
searchPlaceholder: "Buscar branches e PRs",
|
||||
title: "Começar de",
|
||||
},
|
||||
launch: {
|
||||
title: "What to launch",
|
||||
chat: "Chat",
|
||||
terminal: "Terminal",
|
||||
manageProfiles: "Manage profiles",
|
||||
submit: "Launch",
|
||||
promptPlaceholder: "Prompt {{name}}",
|
||||
commandPlaceholder: "Run a command, or leave empty for a blank terminal",
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
quitting: {
|
||||
|
||||
@@ -81,9 +81,11 @@ export const ru: TranslationResources = {
|
||||
desktop: "Напишите агенту сообщение, отметьте @files или используйте /commands и /skills.",
|
||||
mobile: "Сообщение,@files,/commands",
|
||||
fallback: "Сообщение...",
|
||||
terminal: "Prompt",
|
||||
},
|
||||
input: {
|
||||
accessibilityLabel: "Агент сообщений...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
focusHint: "{{shortcut}}, чтобы сосредоточиться",
|
||||
addAttachment: "Добавить вложение",
|
||||
interruptAgent: "Агент прерываний",
|
||||
@@ -1074,15 +1076,30 @@ export const ru: TranslationResources = {
|
||||
composerStateRequired: "Требуется состояние композитора.",
|
||||
selectModel: "Выберите модель",
|
||||
},
|
||||
tooltips: {
|
||||
project: "Choose the project",
|
||||
host: "Choose the host",
|
||||
isolation: "Choose the isolation level",
|
||||
startingRef: "Выберите, с чего начать",
|
||||
launch: "Choose what to launch",
|
||||
},
|
||||
refPicker: {
|
||||
startingRef: "Начальная ссылка",
|
||||
chooseStart: "Выберите, с чего начать",
|
||||
intoBase: "в {{baseRef}}",
|
||||
searching: "Идет поиск...",
|
||||
noMatchingRefs: "Нет подходящих ссылок.",
|
||||
searchPlaceholder: "Поиск филиалов и PR",
|
||||
title: "Начать с",
|
||||
},
|
||||
launch: {
|
||||
title: "What to launch",
|
||||
chat: "Chat",
|
||||
terminal: "Terminal",
|
||||
manageProfiles: "Manage profiles",
|
||||
submit: "Launch",
|
||||
promptPlaceholder: "Prompt {{name}}",
|
||||
commandPlaceholder: "Run a command, or leave empty for a blank terminal",
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
quitting: {
|
||||
|
||||
@@ -81,9 +81,11 @@ export const zhCN: TranslationResources = {
|
||||
desktop: "给 Agent 发消息,标记 @files,或使用 /commands 和 /skills",
|
||||
mobile: "发消息,@files,/commands",
|
||||
fallback: "输入消息...",
|
||||
terminal: "Prompt",
|
||||
},
|
||||
input: {
|
||||
accessibilityLabel: "给 Agent 发消息...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
focusHint: "{{shortcut}} 聚焦",
|
||||
addAttachment: "添加附件",
|
||||
interruptAgent: "中断 Agent",
|
||||
@@ -1041,15 +1043,30 @@ export const zhCN: TranslationResources = {
|
||||
composerStateRequired: "Composer 状态必填",
|
||||
selectModel: "请选择模型",
|
||||
},
|
||||
tooltips: {
|
||||
project: "Choose the project",
|
||||
host: "Choose the host",
|
||||
isolation: "Choose the isolation level",
|
||||
startingRef: "选择起始位置",
|
||||
launch: "Choose what to launch",
|
||||
},
|
||||
refPicker: {
|
||||
startingRef: "起始 ref",
|
||||
chooseStart: "选择起始位置",
|
||||
intoBase: "进入 {{baseRef}}",
|
||||
searching: "正在搜索...",
|
||||
noMatchingRefs: "没有匹配的 refs。",
|
||||
searchPlaceholder: "搜索分支和 PR",
|
||||
title: "起始位置",
|
||||
},
|
||||
launch: {
|
||||
title: "What to launch",
|
||||
chat: "Chat",
|
||||
terminal: "Terminal",
|
||||
manageProfiles: "Manage profiles",
|
||||
submit: "Launch",
|
||||
promptPlaceholder: "Prompt {{name}}",
|
||||
commandPlaceholder: "Run a command, or leave empty for a blank terminal",
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
quitting: {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatResolvedCommand,
|
||||
profileTakesPrompt,
|
||||
substitutePrompt,
|
||||
} from "@getpaseo/protocol/terminal-profiles";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import { resolveLaunchProfile, type LaunchTarget } from "./target";
|
||||
|
||||
export interface TerminalComposerState {
|
||||
isTerminalLaunch: boolean;
|
||||
/** Null for a blank terminal: no command, just the daemon's default shell. */
|
||||
selectedTerminalProfile: TerminalProfile | null;
|
||||
/** False when there is nothing to type, which puts the composer in read-only. */
|
||||
terminalTakesPrompt: boolean;
|
||||
/** The composer's content: the typed text, or the resolved command when read-only. */
|
||||
terminalComposerValue: string;
|
||||
/** Placeholder for the editable cases. Empty when read-only, which shows a command instead. */
|
||||
terminalPlaceholder: string;
|
||||
/**
|
||||
* Only the read-only case needs a word: there is nothing to type, so the
|
||||
* button is the whole interaction. When you can type, the icon matches chat.
|
||||
*/
|
||||
terminalSubmitLabel: string | undefined;
|
||||
/**
|
||||
* Changes whenever the target does, which is what asks the composer to take
|
||||
* focus again on web. Picking from the menu moves focus into the menu, and
|
||||
* you almost always want to type next, so hand it back.
|
||||
*/
|
||||
launchFocusKey: string;
|
||||
}
|
||||
|
||||
export interface ResolveTerminalComposerStateInput {
|
||||
launchTarget: LaunchTarget;
|
||||
terminalProfiles: readonly TerminalProfile[];
|
||||
terminalPromptText: string;
|
||||
commandPlaceholder: string;
|
||||
promptPlaceholder: (profileName: string) => string;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
function launchKey(target: LaunchTarget): string {
|
||||
return target.kind === "chat" ? "chat" : `terminal:${target.profileId}`;
|
||||
}
|
||||
|
||||
export function resolveTerminalComposerState(
|
||||
input: ResolveTerminalComposerStateInput,
|
||||
): TerminalComposerState {
|
||||
const { launchTarget, terminalProfiles, terminalPromptText } = input;
|
||||
const selectedTerminalProfile = resolveLaunchProfile(launchTarget, terminalProfiles);
|
||||
const isTerminalLaunch = launchTarget.kind === "terminal";
|
||||
|
||||
// A blank terminal has no argv to substitute into, so what you type is a
|
||||
// command for the shell rather than a prompt. Editable, always: typing a
|
||||
// command at a shell prompt is the whole point of a shell.
|
||||
if (!selectedTerminalProfile) {
|
||||
return {
|
||||
isTerminalLaunch,
|
||||
selectedTerminalProfile: null,
|
||||
terminalTakesPrompt: true,
|
||||
terminalComposerValue: terminalPromptText,
|
||||
terminalPlaceholder: input.commandPlaceholder,
|
||||
terminalSubmitLabel: undefined,
|
||||
launchFocusKey: launchKey(launchTarget),
|
||||
};
|
||||
}
|
||||
|
||||
const takesPrompt = profileTakesPrompt(selectedTerminalProfile);
|
||||
return {
|
||||
isTerminalLaunch,
|
||||
selectedTerminalProfile,
|
||||
terminalTakesPrompt: takesPrompt,
|
||||
terminalComposerValue: takesPrompt
|
||||
? terminalPromptText
|
||||
: formatResolvedCommand(substitutePrompt(selectedTerminalProfile, terminalPromptText)),
|
||||
terminalPlaceholder: takesPrompt ? input.promptPlaceholder(selectedTerminalProfile.name) : "",
|
||||
terminalSubmitLabel: takesPrompt ? undefined : input.submitLabel,
|
||||
launchFocusKey: launchKey(launchTarget),
|
||||
};
|
||||
}
|
||||
|
||||
/** Owns the copy so the screen passes state, not strings. */
|
||||
export function useTerminalComposerState(input: {
|
||||
launchTarget: LaunchTarget;
|
||||
terminalProfiles: readonly TerminalProfile[];
|
||||
terminalPromptText: string;
|
||||
}): TerminalComposerState {
|
||||
const { t } = useTranslation();
|
||||
const { launchTarget, terminalProfiles, terminalPromptText } = input;
|
||||
return useMemo(
|
||||
() =>
|
||||
resolveTerminalComposerState({
|
||||
launchTarget,
|
||||
terminalProfiles,
|
||||
terminalPromptText,
|
||||
commandPlaceholder: t("newWorkspace.launch.commandPlaceholder"),
|
||||
promptPlaceholder: (name) => t("newWorkspace.launch.promptPlaceholder", { name }),
|
||||
submitLabel: t("newWorkspace.launch.submit"),
|
||||
}),
|
||||
[launchTarget, terminalProfiles, terminalPromptText, t],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "expo-router";
|
||||
import { ChevronDown, MessageCircle, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ProfileIcon } from "@/workspace-pins/launch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import {
|
||||
formatResolvedCommand,
|
||||
getTerminalProfileIcon,
|
||||
substitutePrompt,
|
||||
} from "@getpaseo/protocol/terminal-profiles";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
BLANK_TERMINAL_PROFILE_ID,
|
||||
CHAT_LAUNCH_TARGET,
|
||||
isBlankTerminalTarget,
|
||||
resolveLaunchProfile,
|
||||
terminalLaunchTarget,
|
||||
type LaunchTarget,
|
||||
} from "./target";
|
||||
|
||||
const ThemedMessageCircle = withUnistyles(MessageCircle);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
|
||||
|
||||
const chatIcon = <ThemedMessageCircle size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
const blankTerminalIcon = <ThemedSquareTerminal size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
|
||||
/** Owns its own leading icon and select callback so neither is rebuilt per render of the menu. */
|
||||
function LaunchProfileMenuItem({
|
||||
profile,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
profile: TerminalProfile;
|
||||
selected: boolean;
|
||||
onSelect: (profileId: string) => void;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(() => onSelect(profile.id), [onSelect, profile.id]);
|
||||
const leading = useMemo(
|
||||
() => <ProfileIcon iconKey={getTerminalProfileIcon(profile)} />,
|
||||
[profile],
|
||||
);
|
||||
// What the profile runs before any prompt is typed, so the row says what it
|
||||
// actually launches rather than making you open settings to find out. Nested
|
||||
// in the label rather than passed as `description` so it sits inline right
|
||||
// after the name and truncates with it on one line.
|
||||
const commandPreview = useMemo(
|
||||
() => formatResolvedCommand(substitutePrompt(profile, "")),
|
||||
[profile],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={`new-workspace-launch-option-${profile.id}`}
|
||||
onSelect={handleSelect}
|
||||
selected={selected}
|
||||
leading={leading}
|
||||
>
|
||||
{profile.name} <Text style={styles.command}>{commandPreview}</Text>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export interface LaunchControlProps {
|
||||
serverId: string;
|
||||
target: LaunchTarget;
|
||||
onChange: (target: LaunchTarget) => void;
|
||||
profiles: readonly TerminalProfile[];
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* The meta row's shared badge style. Passed in rather than redeclared so this
|
||||
* trigger stays pixel-identical to the project, host, and branch triggers it
|
||||
* sits beside; a local copy drifts the moment one of them changes.
|
||||
*/
|
||||
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
|
||||
}
|
||||
|
||||
function TriggerIcon({
|
||||
target,
|
||||
profile,
|
||||
}: {
|
||||
target: LaunchTarget;
|
||||
profile: TerminalProfile | null;
|
||||
}): ReactElement {
|
||||
if (target.kind === "chat") {
|
||||
return <ThemedMessageCircle size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
}
|
||||
if (profile) {
|
||||
return <ProfileIcon iconKey={getTerminalProfileIcon(profile)} />;
|
||||
}
|
||||
return <ThemedSquareTerminal size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The meta-row control choosing what New workspace submits to: the chat agent,
|
||||
* or a terminal. One trigger shaped like every other chip on that row, opening
|
||||
* a menu whose Chat and Terminal groups are what keep a terminal profile named
|
||||
* "Codex" from reading as a chat Codex agent.
|
||||
*/
|
||||
export function LaunchControl({
|
||||
serverId,
|
||||
target,
|
||||
onChange,
|
||||
profiles,
|
||||
disabled = false,
|
||||
badgePressableStyle,
|
||||
}: LaunchControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedProfile = useMemo(() => resolveLaunchProfile(target, profiles), [target, profiles]);
|
||||
|
||||
// A profile's own name and logo say nothing about where it runs: a row
|
||||
// reading "(openai logo) Codex" is indistinguishable from a chat Codex agent.
|
||||
// Leading with the noun fixes that, and de-emphasising the profile name keeps
|
||||
// "Terminal" the thing you read first.
|
||||
const triggerLabel =
|
||||
target.kind === "chat" ? t("newWorkspace.launch.chat") : t("newWorkspace.launch.terminal");
|
||||
|
||||
const selectChat = useCallback(() => onChange(CHAT_LAUNCH_TARGET), [onChange]);
|
||||
const selectBlankTerminal = useCallback(
|
||||
() => onChange(terminalLaunchTarget(BLANK_TERMINAL_PROFILE_ID)),
|
||||
[onChange],
|
||||
);
|
||||
const selectTerminalProfile = useCallback(
|
||||
(profileId: string) => onChange(terminalLaunchTarget(profileId)),
|
||||
[onChange],
|
||||
);
|
||||
const openProfileSettings = useCallback(() => {
|
||||
router.push(buildSettingsHostSectionRoute(serverId, "terminals"));
|
||||
}, [router, serverId]);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} compactMode="sheet">
|
||||
{/* The trigger is the Pressable itself, styled by the meta row's shared
|
||||
badge style, so it lands pixel-identical to the project, host, and
|
||||
branch chips it sits beside. */}
|
||||
{/* MenuTrigger is not a forwardRef component and pins its own ref, so the
|
||||
tooltip anchors to a wrapping View instead. Suppressed while the menu
|
||||
is open so the two surfaces never overlap. */}
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
testID="new-workspace-launch-trigger"
|
||||
disabled={disabled}
|
||||
style={badgePressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("newWorkspace.tooltips.launch")}
|
||||
>
|
||||
<View style={styles.iconBox}>
|
||||
<TriggerIcon target={target} profile={selectedProfile} />
|
||||
</View>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{triggerLabel}
|
||||
{selectedProfile ? (
|
||||
<Text style={styles.profileName}> {selectedProfile.name}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("newWorkspace.tooltips.launch")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
offset={8}
|
||||
width={260}
|
||||
sheetTitle={t("newWorkspace.launch.title")}
|
||||
testID="new-workspace-launch-menu"
|
||||
>
|
||||
{/* Chat leads with no label of its own: a group label over a single row
|
||||
is noise, and the sheet title already says what the menu is on
|
||||
compact. Terminal keeps its label because that grouping is what
|
||||
stops a profile named Codex reading as a chat Codex agent. */}
|
||||
<DropdownMenuItem
|
||||
testID="new-workspace-launch-option-chat"
|
||||
onSelect={selectChat}
|
||||
selected={target.kind === "chat"}
|
||||
leading={chatIcon}
|
||||
>
|
||||
{t("newWorkspace.launch.chat")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuLabel>{t("newWorkspace.launch.terminal")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
testID="new-workspace-launch-option-blank"
|
||||
onSelect={selectBlankTerminal}
|
||||
selected={isBlankTerminalTarget(target)}
|
||||
leading={blankTerminalIcon}
|
||||
>
|
||||
{t("newWorkspace.launch.terminal")}
|
||||
</DropdownMenuItem>
|
||||
{profiles.map((profile) => (
|
||||
<LaunchProfileMenuItem
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
selected={selectedProfile?.id === profile.id}
|
||||
onSelect={selectTerminalProfile}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
testID="new-workspace-launch-manage-profiles"
|
||||
onSelect={openProfileSettings}
|
||||
muted
|
||||
>
|
||||
{t("newWorkspace.launch.manageProfiles")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
iconBox: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
label: {
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
profileName: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
command: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
BLANK_TERMINAL_PROFILE_ID,
|
||||
CHAT_LAUNCH_TARGET,
|
||||
isBlankTerminalTarget,
|
||||
resolveLaunchProfile,
|
||||
resolveLaunchTarget,
|
||||
terminalLaunchTarget,
|
||||
} from "./target";
|
||||
|
||||
const claude: TerminalProfile = {
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
command: "claude",
|
||||
args: ["{{{prompt}}}"],
|
||||
};
|
||||
const codex: TerminalProfile = {
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
command: "codex",
|
||||
args: ["{{{prompt}}}"],
|
||||
};
|
||||
const profiles = [claude, codex];
|
||||
|
||||
describe("resolveLaunchTarget", () => {
|
||||
it("defaults to chat when nothing is persisted", () => {
|
||||
expect(resolveLaunchTarget(undefined, profiles)).toEqual(CHAT_LAUNCH_TARGET);
|
||||
});
|
||||
|
||||
it("keeps chat as chat", () => {
|
||||
expect(resolveLaunchTarget(CHAT_LAUNCH_TARGET, profiles)).toEqual(CHAT_LAUNCH_TARGET);
|
||||
});
|
||||
|
||||
it("keeps a terminal target whose profile still resolves", () => {
|
||||
const target = terminalLaunchTarget("codex");
|
||||
expect(resolveLaunchTarget(target, profiles)).toEqual(target);
|
||||
});
|
||||
|
||||
it("keeps the blank terminal, which is never resolved against profiles", () => {
|
||||
const target = terminalLaunchTarget(BLANK_TERMINAL_PROFILE_ID);
|
||||
expect(resolveLaunchTarget(target, [])).toEqual(target);
|
||||
});
|
||||
|
||||
it("falls back to chat when the persisted profile no longer exists", () => {
|
||||
expect(resolveLaunchTarget(terminalLaunchTarget("removed-profile"), profiles)).toEqual(
|
||||
CHAT_LAUNCH_TARGET,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to chat when every profile has been removed", () => {
|
||||
expect(resolveLaunchTarget(terminalLaunchTarget("codex"), [])).toEqual(CHAT_LAUNCH_TARGET);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLaunchProfile", () => {
|
||||
it("returns null for chat", () => {
|
||||
expect(resolveLaunchProfile(CHAT_LAUNCH_TARGET, profiles)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for the blank terminal, which runs no command", () => {
|
||||
expect(
|
||||
resolveLaunchProfile(terminalLaunchTarget(BLANK_TERMINAL_PROFILE_ID), profiles),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the matching profile", () => {
|
||||
expect(resolveLaunchProfile(terminalLaunchTarget("claude"), profiles)).toBe(claude);
|
||||
});
|
||||
|
||||
it("returns null when the profile id matches nothing", () => {
|
||||
expect(resolveLaunchProfile(terminalLaunchTarget("missing"), profiles)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBlankTerminalTarget", () => {
|
||||
it("is true only for a terminal target carrying the blank id", () => {
|
||||
expect(isBlankTerminalTarget(terminalLaunchTarget(BLANK_TERMINAL_PROFILE_ID))).toBe(true);
|
||||
expect(isBlankTerminalTarget(terminalLaunchTarget("claude"))).toBe(false);
|
||||
expect(isBlankTerminalTarget(CHAT_LAUNCH_TARGET)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import type { LaunchTarget } from "@/create-agent-preferences/preferences";
|
||||
|
||||
export type { LaunchTarget };
|
||||
|
||||
// A blank terminal isn't a configured TerminalProfile, it's "no command", i.e.
|
||||
// the daemon's default shell. It still needs a stable id to round-trip through
|
||||
// FormPreferences (`launchTarget.profileId` is a required string) and to sit
|
||||
// alongside real profiles in the picker.
|
||||
export const BLANK_TERMINAL_PROFILE_ID = "blank";
|
||||
|
||||
export const CHAT_LAUNCH_TARGET: LaunchTarget = { kind: "chat" };
|
||||
|
||||
export function isBlankTerminalTarget(target: LaunchTarget): boolean {
|
||||
return target.kind === "terminal" && target.profileId === BLANK_TERMINAL_PROFILE_ID;
|
||||
}
|
||||
|
||||
export function terminalLaunchTarget(profileId: string): LaunchTarget {
|
||||
return { kind: "terminal", profileId };
|
||||
}
|
||||
|
||||
/** The selected profile for a terminal target, or null for Chat and blank terminals. */
|
||||
export function resolveLaunchProfile(
|
||||
target: LaunchTarget,
|
||||
profiles: readonly TerminalProfile[],
|
||||
): TerminalProfile | null {
|
||||
if (target.kind !== "terminal" || isBlankTerminalTarget(target)) {
|
||||
return null;
|
||||
}
|
||||
return profiles.find((profile) => profile.id === target.profileId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a target against the currently available profiles, falling back to
|
||||
* Chat when it names a profile that no longer exists (removed since it was
|
||||
* picked or persisted).
|
||||
*/
|
||||
export function resolveLaunchTarget(
|
||||
target: LaunchTarget | undefined,
|
||||
profiles: readonly TerminalProfile[],
|
||||
): LaunchTarget {
|
||||
if (!target || target.kind === "chat") {
|
||||
return CHAT_LAUNCH_TARGET;
|
||||
}
|
||||
if (isBlankTerminalTarget(target)) {
|
||||
return target;
|
||||
}
|
||||
return resolveLaunchProfile(target, profiles) ? target : CHAT_LAUNCH_TARGET;
|
||||
}
|
||||
@@ -33,6 +33,13 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { ensureCheckoutStatus } from "@/git/checkout-status-cache";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { resolveTerminalProfiles } from "@getpaseo/protocol/terminal-profiles";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import { LaunchControl } from "@/new-workspace-launch/launch-control";
|
||||
import { resolveLaunchTarget, type LaunchTarget } from "@/new-workspace-launch/target";
|
||||
import { useTerminalComposerState } from "@/new-workspace-launch/composer-state";
|
||||
import { runCreateTerminalWorkspace } from "./new-workspace-terminal";
|
||||
import {
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeConnectionStatuses,
|
||||
@@ -79,6 +86,7 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import type { MessagePayload } from "@/composer/types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
@@ -141,7 +149,7 @@ function resolveVisibleDraftContextScopeKeys(input: {
|
||||
}
|
||||
|
||||
function isNewWorkspacePending(input: {
|
||||
pendingAction: "chat" | "empty" | null;
|
||||
pendingAction: "chat" | "empty" | "terminal" | null;
|
||||
isDraftHandoffActive: boolean;
|
||||
}): boolean {
|
||||
return input.pendingAction !== null || input.isDraftHandoffActive;
|
||||
@@ -170,7 +178,29 @@ interface NewWorkspaceScreenProps {
|
||||
draftId?: string;
|
||||
}
|
||||
|
||||
// A terminal launch sends argv, not a message: there is nothing to attach and
|
||||
// no draft to persist, so the composer's attachment and draft seams are inert.
|
||||
const NO_TERMINAL_ATTACHMENTS: UserComposerAttachment[] = [];
|
||||
function noopChangeAttachments() {}
|
||||
function noopClearDraft() {}
|
||||
|
||||
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const chevronExtraMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
|
||||
|
||||
// Every picker chip on this screen shares one chevron so they stay a single
|
||||
// visual family. Extra-muted: the chevron is an affordance, not information,
|
||||
// and it should sit behind the label it belongs to.
|
||||
function MetaChevron(): ReactElement {
|
||||
return (
|
||||
<View style={styles.chevronContainer}>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={chevronExtraMutedMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const metaChevron = <MetaChevron />;
|
||||
|
||||
// Stable reference so the keyboard-action handler doesn't re-register each render.
|
||||
const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"];
|
||||
// Height of a single picker-trigger badge. The Base-row spacer reserves exactly
|
||||
@@ -231,6 +261,7 @@ function RefPickerTrigger({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<ComboboxTrigger
|
||||
chevron={metaChevron}
|
||||
ref={pickerAnchorRef}
|
||||
testID="new-workspace-ref-picker-trigger"
|
||||
onPress={onPress}
|
||||
@@ -260,6 +291,7 @@ function ProjectPickerTrigger({
|
||||
disabled,
|
||||
badgePressableStyle,
|
||||
label,
|
||||
tooltipLabel,
|
||||
projectViewKey,
|
||||
iconDataUri,
|
||||
iconColor,
|
||||
@@ -270,6 +302,7 @@ function ProjectPickerTrigger({
|
||||
disabled: boolean;
|
||||
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
|
||||
label: string;
|
||||
tooltipLabel: string;
|
||||
projectViewKey: string | null;
|
||||
iconDataUri: string | null;
|
||||
iconColor: string;
|
||||
@@ -281,6 +314,7 @@ function ProjectPickerTrigger({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<ComboboxTrigger
|
||||
chevron={metaChevron}
|
||||
ref={pickerAnchorRef}
|
||||
testID="new-workspace-project-picker-trigger"
|
||||
onPress={onPress}
|
||||
@@ -308,7 +342,7 @@ function ProjectPickerTrigger({
|
||||
</ComboboxTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Choose project</Text>
|
||||
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -592,6 +626,7 @@ function IsolationPickerTrigger({
|
||||
badgePressableStyle,
|
||||
isolation,
|
||||
label,
|
||||
tooltipLabel,
|
||||
iconColor,
|
||||
iconSize,
|
||||
}: {
|
||||
@@ -601,30 +636,39 @@ function IsolationPickerTrigger({
|
||||
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
|
||||
isolation: "local" | "worktree";
|
||||
label: string;
|
||||
tooltipLabel: string;
|
||||
iconColor: string;
|
||||
iconSize: number;
|
||||
}) {
|
||||
return (
|
||||
<ComboboxTrigger
|
||||
ref={pickerAnchorRef}
|
||||
testID="workspace-create-isolation-trigger"
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={badgePressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace isolation"
|
||||
>
|
||||
<View style={styles.badgeIconBox}>
|
||||
{isolation === "worktree" ? (
|
||||
<GitBranch size={iconSize} color={iconColor} />
|
||||
) : (
|
||||
<Folder size={iconSize} color={iconColor} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</ComboboxTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<ComboboxTrigger
|
||||
chevron={metaChevron}
|
||||
ref={pickerAnchorRef}
|
||||
testID="workspace-create-isolation-trigger"
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={badgePressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace isolation"
|
||||
>
|
||||
<View style={styles.badgeIconBox}>
|
||||
{isolation === "worktree" ? (
|
||||
<GitBranch size={iconSize} color={iconColor} />
|
||||
) : (
|
||||
<Folder size={iconSize} color={iconColor} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</ComboboxTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1273,12 +1317,19 @@ interface NewWorkspaceFormStackInput {
|
||||
renderOption: RefPickerRenderOption;
|
||||
showRefPicker: boolean;
|
||||
};
|
||||
launch: {
|
||||
serverId: string;
|
||||
target: LaunchTarget;
|
||||
onChange: (target: LaunchTarget) => void;
|
||||
profiles: readonly TerminalProfile[];
|
||||
disabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { isCompact, isPending, project, host, isolation, base } = input;
|
||||
const { isCompact, isPending, project, host, isolation, base, launch } = input;
|
||||
|
||||
const selectedHostLabel =
|
||||
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
|
||||
@@ -1307,6 +1358,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
disabled={isPending}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
label={project.triggerLabel}
|
||||
tooltipLabel={t("newWorkspace.tooltips.project")}
|
||||
projectViewKey={project.selectedProject?.viewKey ?? null}
|
||||
iconDataUri={
|
||||
project.selectedProject
|
||||
@@ -1350,23 +1402,30 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
desktopMinWidth={200}
|
||||
hostOptionTestID={newWorkspaceHostOptionTestID}
|
||||
>
|
||||
<Pressable
|
||||
ref={host.anchorRef}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Host"
|
||||
onPress={host.open}
|
||||
disabled={isPending || host.allHosts.length === 0}
|
||||
style={badgePressableStyle}
|
||||
testID="host-picker-trigger"
|
||||
>
|
||||
<View style={styles.badgeIconBox}>
|
||||
<HostStatusDot serverId={host.selectedServerId} />
|
||||
</View>
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
{selectedHostLabel}
|
||||
</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={host.anchorRef}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Host"
|
||||
onPress={host.open}
|
||||
disabled={isPending || host.allHosts.length === 0}
|
||||
style={badgePressableStyle}
|
||||
testID="host-picker-trigger"
|
||||
>
|
||||
<View style={styles.badgeIconBox}>
|
||||
<HostStatusDot serverId={host.selectedServerId} />
|
||||
</View>
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
{selectedHostLabel}
|
||||
</Text>
|
||||
{metaChevron}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("newWorkspace.tooltips.host")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</HostPicker>
|
||||
</View>
|
||||
) : null;
|
||||
@@ -1380,6 +1439,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
isolation={isolation.effectiveIsolation}
|
||||
label={isolationTriggerLabel}
|
||||
tooltipLabel={t("newWorkspace.tooltips.isolation")}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
@@ -1407,7 +1467,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
selectedItem={base.selectedItem}
|
||||
triggerLabel={base.triggerLabel}
|
||||
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
|
||||
tooltipLabel={t("newWorkspace.refPicker.chooseStart")}
|
||||
tooltipLabel={t("newWorkspace.tooltips.startingRef")}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
@@ -1429,6 +1489,17 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const launchControl = (
|
||||
<LaunchControl
|
||||
serverId={launch.serverId}
|
||||
target={launch.target}
|
||||
onChange={launch.onChange}
|
||||
profiles={launch.profiles}
|
||||
disabled={launch.disabled}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
return isCompact ? (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.formStack}>
|
||||
<FormRow>{projectControl}</FormRow>
|
||||
@@ -1440,6 +1511,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
<View style={styles.baseSpacer} />
|
||||
)}
|
||||
{baseControl ? <FormRow>{baseControl}</FormRow> : <View style={styles.baseSpacer} />}
|
||||
<FormRow>{launchControl}</FormRow>
|
||||
</View>
|
||||
) : (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.formStackDesktop}>
|
||||
@@ -1447,6 +1519,8 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
{hostControl}
|
||||
{isolationControl}
|
||||
{baseControl}
|
||||
<View style={styles.launchSpacer} />
|
||||
{launchControl}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1488,7 +1562,7 @@ export function NewWorkspaceScreen({
|
||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||
typeof normalizeWorkspaceDescriptor
|
||||
> | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "terminal" | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
||||
const openAddProjectPicker = useOpenAddProject();
|
||||
@@ -1501,6 +1575,38 @@ export function NewWorkspaceScreen({
|
||||
const hostPickerAnchorRef = useRef<View | null>(null);
|
||||
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
|
||||
|
||||
// Launch target: what the composer submits to (chat agent, or a terminal
|
||||
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
|
||||
// value reads live from preferences until the user manually picks
|
||||
// something in this screen, so the async preferences load doesn't race a
|
||||
// frozen useState initializer.
|
||||
const { preferences: formPreferences, updatePreferences: updateFormPreferences } =
|
||||
useFormPreferences();
|
||||
const { config: daemonConfig } = useDaemonConfig(selectedServerId);
|
||||
const terminalProfiles: readonly TerminalProfile[] = useMemo(
|
||||
() => resolveTerminalProfiles(daemonConfig?.terminalProfiles),
|
||||
[daemonConfig?.terminalProfiles],
|
||||
);
|
||||
// Manual selection wins once the user picks something; until then the target
|
||||
// reads live from preferences so the async load can't race a frozen
|
||||
// initializer. Both go through `resolveLaunchTarget`, so a profile deleted
|
||||
// daemon-side falls back to chat rather than leaving a dead selection.
|
||||
const [manualLaunchTarget, setManualLaunchTarget] = useState<LaunchTarget | null>(null);
|
||||
const launchTarget = useMemo(
|
||||
() => resolveLaunchTarget(manualLaunchTarget ?? formPreferences.launchTarget, terminalProfiles),
|
||||
[manualLaunchTarget, formPreferences.launchTarget, terminalProfiles],
|
||||
);
|
||||
const [terminalPromptText, setTerminalPromptText] = useState("");
|
||||
const {
|
||||
isTerminalLaunch,
|
||||
selectedTerminalProfile,
|
||||
terminalTakesPrompt,
|
||||
terminalComposerValue,
|
||||
terminalPlaceholder,
|
||||
terminalSubmitLabel,
|
||||
launchFocusKey,
|
||||
} = useTerminalComposerState({ launchTarget, terminalProfiles, terminalPromptText });
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = pickerSearchQuery.trim();
|
||||
const timer = setTimeout(() => setDebouncedPickerSearchQuery(trimmed), 180);
|
||||
@@ -1930,6 +2036,7 @@ export function NewWorkspaceScreen({
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
await composerState?.persistFormPreferences();
|
||||
await updateFormPreferences({ launchTarget });
|
||||
if (isEmptyWorkspaceSubmission(payload)) {
|
||||
setPendingAction("empty");
|
||||
await runCreateEmptyWorkspace({
|
||||
@@ -1970,13 +2077,65 @@ export function NewWorkspaceScreen({
|
||||
draftKey,
|
||||
ensureWorkspace,
|
||||
forkDraftSetup,
|
||||
launchTarget,
|
||||
selectedServerId,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
toast,
|
||||
updateFormPreferences,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSubmitTerminalLaunch = useCallback(async () => {
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
await updateFormPreferences({ launchTarget });
|
||||
setPendingAction("terminal");
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: selectedSourceDirectory ?? "",
|
||||
prompt: terminalPromptText,
|
||||
profile: selectedTerminalProfile,
|
||||
profileName: selectedTerminalProfile?.name,
|
||||
ensureWorkspace,
|
||||
createTerminal: async (input) => {
|
||||
const connectedClient = withConnectedClient();
|
||||
const createdTerminal = await connectedClient.createTerminal(
|
||||
input.workspaceDirectory,
|
||||
input.name,
|
||||
undefined,
|
||||
{ command: input.command, args: input.args, workspaceId: input.workspaceId },
|
||||
);
|
||||
if (!createdTerminal.terminal) {
|
||||
throw new Error(createdTerminal.error ?? t("newWorkspace.errors.createWorktreeFailed"));
|
||||
}
|
||||
return { terminalId: createdTerminal.terminal.id };
|
||||
},
|
||||
sendTerminalInput: (terminalId, data) => {
|
||||
withConnectedClient().sendTerminalInput(terminalId, { type: "input", data });
|
||||
},
|
||||
serverId: selectedServerId,
|
||||
navigate: (targetServerId, workspaceId, target) =>
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId, target }),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setPendingAction(null);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
}
|
||||
}, [
|
||||
ensureWorkspace,
|
||||
launchTarget,
|
||||
selectedServerId,
|
||||
selectedSourceDirectory,
|
||||
selectedTerminalProfile,
|
||||
t,
|
||||
terminalPromptText,
|
||||
toast,
|
||||
updateFormPreferences,
|
||||
withConnectedClient,
|
||||
]);
|
||||
|
||||
const renderPickerOption = useCallback(
|
||||
(props: {
|
||||
option: ComboboxOptionType;
|
||||
@@ -2095,6 +2254,13 @@ export function NewWorkspaceScreen({
|
||||
renderOption: renderPickerOption,
|
||||
showRefPicker,
|
||||
},
|
||||
launch: {
|
||||
serverId: selectedServerId,
|
||||
target: launchTarget,
|
||||
onChange: setManualLaunchTarget,
|
||||
profiles: terminalProfiles,
|
||||
disabled: isPending,
|
||||
},
|
||||
});
|
||||
|
||||
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
||||
@@ -2109,33 +2275,62 @@ export function NewWorkspaceScreen({
|
||||
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
|
||||
</View>
|
||||
{formStack}
|
||||
<Composer
|
||||
externalKeyboardShift
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||
submitButtonTestID="workspace-create-submit"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={isPending}
|
||||
waitForGithubAutoAttachOnSubmit
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
attachments={chatDraft.attachments}
|
||||
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
onGithubPrDetected={handleGithubPrDetected}
|
||||
onGithubPrAutoAttach={handleGithubPrAutoAttach}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
/>
|
||||
{isTerminalLaunch ? (
|
||||
<Composer
|
||||
externalKeyboardShift
|
||||
inputMode="terminal"
|
||||
readOnly={!terminalTakesPrompt}
|
||||
placeholder={terminalPlaceholder}
|
||||
submitLabel={terminalSubmitLabel}
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitTerminalLaunch}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
|
||||
submitButtonTestID="new-workspace-launch-submit"
|
||||
isSubmitLoading={isPending}
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={terminalComposerValue}
|
||||
onChangeText={setTerminalPromptText}
|
||||
attachments={NO_TERMINAL_ATTACHMENTS}
|
||||
onChangeAttachments={noopChangeAttachments}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={noopClearDraft}
|
||||
autoFocus={terminalTakesPrompt}
|
||||
autoFocusKey={launchFocusKey}
|
||||
/>
|
||||
) : (
|
||||
<Composer
|
||||
externalKeyboardShift
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||
submitButtonTestID="workspace-create-submit"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={isPending}
|
||||
waitForGithubAutoAttachOnSubmit
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
attachments={chatDraft.attachments}
|
||||
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
onGithubPrDetected={handleGithubPrDetected}
|
||||
onGithubPrAutoAttach={handleGithubPrAutoAttach}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
autoFocusKey={launchFocusKey}
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
/>
|
||||
)}
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
@@ -2192,8 +2387,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
marginBottom: theme.spacing[8],
|
||||
// The badge adds its own left padding; offset it so the project icon's left
|
||||
// edge lands exactly on the "New workspace" title's left edge.
|
||||
// edge lands exactly on the "New workspace" title's left edge. The trailing
|
||||
// inset mirrors it so the launch chip stops on the composer's inner content
|
||||
// rather than running out to the composer's border.
|
||||
paddingLeft: theme.spacing[4],
|
||||
paddingRight: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
// The row's left inset matches the heading's text x (composerTitleContainer
|
||||
@@ -2208,6 +2406,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
baseSpacer: {
|
||||
height: BADGE_HEIGHT,
|
||||
},
|
||||
// Pushes the launch control to the trailing edge of the desktop meta row,
|
||||
// next to project/host/branch. The row's own right inset (formStackDesktop)
|
||||
// lands it on the composer's inner content, matching the left chips.
|
||||
launchSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -2242,6 +2446,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
chevronContainer: {
|
||||
flexShrink: 0,
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
badgeIconBox: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runCreateTerminalWorkspace } from "./new-workspace-terminal";
|
||||
|
||||
function createRecordingNavigate() {
|
||||
const recorded: Array<{ serverId: string; workspaceId: string; target: unknown }> = [];
|
||||
return {
|
||||
recorded,
|
||||
navigate: (serverId: string, workspaceId: string, target: unknown) => {
|
||||
recorded.push({ serverId, workspaceId, target });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("runCreateTerminalWorkspace", () => {
|
||||
it("creates the workspace without a chat agent, substitutes the prompt into the profile, and navigates to the terminal tab", async () => {
|
||||
const workspace = { id: "workspace-123", workspaceDirectory: "/repo/workspace-123" };
|
||||
const ensureWorkspace = vi.fn().mockResolvedValue(workspace);
|
||||
const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-1" });
|
||||
const sendTerminalInput = vi.fn();
|
||||
const { navigate, recorded } = createRecordingNavigate();
|
||||
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: "/repo",
|
||||
prompt: "fix the bug",
|
||||
profile: { command: "claude", args: ["{{{prompt}}}"] },
|
||||
profileName: "Claude Code",
|
||||
ensureWorkspace,
|
||||
createTerminal,
|
||||
sendTerminalInput,
|
||||
serverId: "server-abc",
|
||||
navigate,
|
||||
});
|
||||
|
||||
// Seeds the generated title and branch name without creating a chat agent.
|
||||
expect(ensureWorkspace).toHaveBeenCalledWith({
|
||||
cwd: "/repo",
|
||||
prompt: "fix the bug",
|
||||
attachments: [],
|
||||
withInitialAgent: false,
|
||||
});
|
||||
expect(createTerminal).toHaveBeenCalledWith({
|
||||
workspaceDirectory: "/repo/workspace-123",
|
||||
workspaceId: "workspace-123",
|
||||
name: "Claude Code",
|
||||
command: "claude",
|
||||
args: ["fix the bug"],
|
||||
});
|
||||
expect(recorded).toEqual([
|
||||
{
|
||||
serverId: "server-abc",
|
||||
workspaceId: "workspace-123",
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a sentinel-only arg when the prompt is empty, so a bare command still launches", async () => {
|
||||
const workspace = { id: "ws-1", workspaceDirectory: "/repo/ws-1" };
|
||||
const ensureWorkspace = vi.fn().mockResolvedValue(workspace);
|
||||
const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-2" });
|
||||
const sendTerminalInput = vi.fn();
|
||||
const { navigate } = createRecordingNavigate();
|
||||
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: "/repo",
|
||||
prompt: "",
|
||||
profile: { command: "claude", args: ["{{{prompt}}}"] },
|
||||
profileName: "Claude Code",
|
||||
ensureWorkspace,
|
||||
createTerminal,
|
||||
sendTerminalInput,
|
||||
serverId: "server-abc",
|
||||
navigate,
|
||||
});
|
||||
|
||||
expect(createTerminal).toHaveBeenCalledWith({
|
||||
workspaceDirectory: "/repo/ws-1",
|
||||
workspaceId: "ws-1",
|
||||
name: "Claude Code",
|
||||
command: "claude",
|
||||
args: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not seed workspace naming from a hidden draft for a launch-only profile", async () => {
|
||||
const workspace = { id: "ws-1", workspaceDirectory: "/repo/ws-1" };
|
||||
const ensureWorkspace = vi.fn().mockResolvedValue(workspace);
|
||||
const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-2" });
|
||||
const sendTerminalInput = vi.fn();
|
||||
const { navigate } = createRecordingNavigate();
|
||||
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: "/repo",
|
||||
prompt: "hidden draft from the previous profile",
|
||||
profile: { command: "npm", args: ["run", "dev"] },
|
||||
profileName: "Dev server",
|
||||
ensureWorkspace,
|
||||
createTerminal,
|
||||
sendTerminalInput,
|
||||
serverId: "server-abc",
|
||||
navigate,
|
||||
});
|
||||
|
||||
expect(ensureWorkspace).toHaveBeenCalledWith({
|
||||
cwd: "/repo",
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
withInitialAgent: false,
|
||||
});
|
||||
expect(createTerminal).toHaveBeenCalledWith({
|
||||
workspaceDirectory: "/repo/ws-1",
|
||||
workspaceId: "ws-1",
|
||||
name: "Dev server",
|
||||
command: "npm",
|
||||
args: ["run", "dev"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not type into the terminal when a profile took the prompt as argv", async () => {
|
||||
const ensureWorkspace = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "ws-9", workspaceDirectory: "/repo/ws-9" });
|
||||
const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-9" });
|
||||
const sendTerminalInput = vi.fn();
|
||||
const { navigate } = createRecordingNavigate();
|
||||
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: "/repo",
|
||||
prompt: "fix the bug",
|
||||
profile: { command: "claude", args: ["{{{prompt}}}"] },
|
||||
profileName: "Claude Code",
|
||||
ensureWorkspace,
|
||||
createTerminal,
|
||||
sendTerminalInput,
|
||||
serverId: "server-abc",
|
||||
navigate,
|
||||
});
|
||||
|
||||
expect(sendTerminalInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seeds workspace naming from a blank terminal's command too", async () => {
|
||||
const ensureWorkspace = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "ws-7", workspaceDirectory: "/repo/ws-7" });
|
||||
const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-7" });
|
||||
const sendTerminalInput = vi.fn();
|
||||
const { navigate } = createRecordingNavigate();
|
||||
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: "/repo",
|
||||
prompt: "npm run dev",
|
||||
profile: null,
|
||||
profileName: undefined,
|
||||
ensureWorkspace,
|
||||
createTerminal,
|
||||
sendTerminalInput,
|
||||
serverId: "server-abc",
|
||||
navigate,
|
||||
});
|
||||
|
||||
expect(ensureWorkspace).toHaveBeenCalledWith({
|
||||
cwd: "/repo",
|
||||
prompt: "npm run dev",
|
||||
attachments: [],
|
||||
withInitialAgent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("types the command into the shell, with a carriage return, for a blank terminal", async () => {
|
||||
const ensureWorkspace = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "ws-5", workspaceDirectory: "/repo/ws-5" });
|
||||
const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-5" });
|
||||
const sendTerminalInput = vi.fn();
|
||||
const { navigate } = createRecordingNavigate();
|
||||
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: "/repo",
|
||||
prompt: " npm run dev ",
|
||||
profile: null,
|
||||
profileName: undefined,
|
||||
ensureWorkspace,
|
||||
createTerminal,
|
||||
sendTerminalInput,
|
||||
serverId: "server-abc",
|
||||
navigate,
|
||||
});
|
||||
|
||||
// The shell parses it, so it is typed rather than turned into argv.
|
||||
expect(createTerminal).toHaveBeenCalledWith({
|
||||
workspaceDirectory: "/repo/ws-5",
|
||||
workspaceId: "ws-5",
|
||||
name: undefined,
|
||||
});
|
||||
expect(sendTerminalInput).toHaveBeenCalledWith("term-5", "npm run dev\r");
|
||||
});
|
||||
|
||||
it("launches a blank terminal without typing anything when no command was entered", async () => {
|
||||
const ensureWorkspace = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "ws-6", workspaceDirectory: "/repo/ws-6" });
|
||||
const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-6" });
|
||||
const sendTerminalInput = vi.fn();
|
||||
const { navigate } = createRecordingNavigate();
|
||||
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: "/repo",
|
||||
prompt: " ",
|
||||
profile: null,
|
||||
profileName: undefined,
|
||||
ensureWorkspace,
|
||||
createTerminal,
|
||||
sendTerminalInput,
|
||||
serverId: "server-abc",
|
||||
navigate,
|
||||
});
|
||||
|
||||
expect(sendTerminalInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("launches the default shell with no command/args and no name when profile is null", async () => {
|
||||
const workspace = { id: "ws-1", workspaceDirectory: "/repo/ws-1" };
|
||||
const ensureWorkspace = vi.fn().mockResolvedValue(workspace);
|
||||
const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-3" });
|
||||
const sendTerminalInput = vi.fn();
|
||||
const { navigate } = createRecordingNavigate();
|
||||
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: "/repo",
|
||||
prompt: "ls -la",
|
||||
profile: null,
|
||||
profileName: undefined,
|
||||
ensureWorkspace,
|
||||
createTerminal,
|
||||
sendTerminalInput,
|
||||
serverId: "server-abc",
|
||||
navigate,
|
||||
});
|
||||
|
||||
expect(createTerminal).toHaveBeenCalledWith({
|
||||
workspaceDirectory: "/repo/ws-1",
|
||||
workspaceId: "ws-1",
|
||||
name: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { normalizeWorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import {
|
||||
profileTakesPrompt,
|
||||
substitutePrompt,
|
||||
type SubstitutableCommand,
|
||||
} from "@getpaseo/protocol/terminal-profiles";
|
||||
|
||||
export interface CreatedTerminal {
|
||||
terminalId: string;
|
||||
}
|
||||
|
||||
export interface CreateTerminalWorkspaceInput {
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
/** The selected profile's command/args, or null for the default shell. */
|
||||
profile: SubstitutableCommand | null;
|
||||
/** Terminal tab name: the profile's display name, or undefined for shell. */
|
||||
profileName: string | undefined;
|
||||
ensureWorkspace: (input: {
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
attachments: AgentAttachment[];
|
||||
withInitialAgent: boolean;
|
||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||
createTerminal: (input: {
|
||||
workspaceDirectory: string;
|
||||
workspaceId: string;
|
||||
name?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
}) => Promise<CreatedTerminal>;
|
||||
/** Types text into a live terminal. Used for the shell path, where the typed command is a command, not argv. */
|
||||
sendTerminalInput: (terminalId: string, data: string) => void;
|
||||
serverId: string;
|
||||
navigate: (serverId: string, workspaceId: string, target: WorkspaceTabTarget) => void;
|
||||
}
|
||||
|
||||
export async function runCreateTerminalWorkspace(
|
||||
input: CreateTerminalWorkspaceInput,
|
||||
): Promise<void> {
|
||||
const {
|
||||
cwd,
|
||||
prompt,
|
||||
profile,
|
||||
profileName,
|
||||
ensureWorkspace,
|
||||
createTerminal,
|
||||
sendTerminalInput,
|
||||
serverId,
|
||||
navigate,
|
||||
} = input;
|
||||
// Seeds the generated workspace title and branch name. `withInitialAgent`
|
||||
// stays false, so this hands the daemon something to name the workspace after
|
||||
// without creating a chat agent. Worth passing even for a blank terminal,
|
||||
// where the text is a command: a branch called `npm-run-dev` beats a random
|
||||
// slug.
|
||||
const namingPrompt = profile && !profileTakesPrompt(profile) ? "" : prompt;
|
||||
const ensuredWorkspace = await ensureWorkspace({
|
||||
cwd,
|
||||
prompt: namingPrompt,
|
||||
attachments: [],
|
||||
withInitialAgent: false,
|
||||
});
|
||||
const resolved = profile ? substitutePrompt(profile, prompt) : null;
|
||||
const createdTerminal = await createTerminal({
|
||||
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||
workspaceId: ensuredWorkspace.id,
|
||||
name: profileName,
|
||||
...(resolved ? { command: resolved.command, args: resolved.args } : {}),
|
||||
});
|
||||
|
||||
// A profile's prompt is argv, substituted into command/args above. A blank
|
||||
// terminal has no argv to substitute into, so the typed text is a command and
|
||||
// has to reach the shell the way any command does: typed at its prompt. That
|
||||
// also means the user's own shell parses it, which is the only thing that
|
||||
// gets quoting, pipes, and globs right on every platform.
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (!profile && trimmedPrompt) {
|
||||
sendTerminalInput(createdTerminal.terminalId, `${trimmedPrompt}\r`);
|
||||
}
|
||||
|
||||
navigate(serverId, ensuredWorkspace.id, {
|
||||
kind: "terminal",
|
||||
terminalId: createdTerminal.terminalId,
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
getTerminalProfileIcon,
|
||||
resolveTerminalProfiles,
|
||||
DEFAULT_TERMINAL_PROFILES,
|
||||
} from "@getpaseo/protocol/terminal-profiles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
@@ -1547,8 +1547,11 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
|
||||
} | null>(null);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
// Settings edits what is persisted, not the adopted view. Any save here
|
||||
// writes the whole list back, so resolving first would bake read-time prompt
|
||||
// adoption into the user's config the first time they reorder a row.
|
||||
const profiles = useMemo(
|
||||
() => (config ? resolveTerminalProfiles(config.terminalProfiles) : null),
|
||||
() => (config ? (config.terminalProfiles ?? DEFAULT_TERMINAL_PROFILES) : null),
|
||||
[config],
|
||||
);
|
||||
|
||||
|
||||
@@ -193,6 +193,7 @@ import {
|
||||
} from "@/screens/workspace/terminals/use-workspace-terminals";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import {
|
||||
resolveTerminalProfileLaunch,
|
||||
getTerminalProfileIcon,
|
||||
resolveTerminalProfiles,
|
||||
} from "@getpaseo/protocol/terminal-profiles";
|
||||
@@ -1019,11 +1020,7 @@ function HeaderMenuProfileItem({
|
||||
onCreateTerminalWithProfile,
|
||||
}: HeaderMenuProfileItemProps) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onCreateTerminalWithProfile({
|
||||
name: profile.name,
|
||||
command: profile.command,
|
||||
args: profile.args,
|
||||
});
|
||||
onCreateTerminalWithProfile(resolveTerminalProfileLaunch(profile, ""));
|
||||
}, [onCreateTerminalWithProfile, profile]);
|
||||
|
||||
const icon = getTerminalProfileIcon(profile);
|
||||
|
||||
@@ -46,7 +46,21 @@ describe("runPinnedTabTarget", () => {
|
||||
const { launches, handlers } = recordingHandlers();
|
||||
runPinnedTabTarget({ kind: "profile", profileId: "claude" }, PROFILES, handlers);
|
||||
expect(launches).toEqual([
|
||||
{ action: "profile", profile: { name: "Claude Code", command: "claude" } },
|
||||
{ action: "profile", profile: { name: "Claude Code", command: "claude", args: [] } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("launches a prompt-taking profile bare, never with the sentinel as a literal argument", () => {
|
||||
const { launches, handlers } = recordingHandlers();
|
||||
runPinnedTabTarget(
|
||||
{ kind: "profile", profileId: "codex" },
|
||||
[{ id: "codex", name: "Codex", command: "codex", args: ["--yolo", "{{{prompt}}}"] }],
|
||||
handlers,
|
||||
);
|
||||
// A pin has nowhere to type, so the prompt-only arg drops instead of
|
||||
// reaching the CLI as the literal text `{{{prompt}}}`.
|
||||
expect(launches).toEqual([
|
||||
{ action: "profile", profile: { name: "Codex", command: "codex", args: ["--yolo"] } },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveTerminalProfileLaunch } from "@getpaseo/protocol/terminal-profiles";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals";
|
||||
import type { PinnedTabTarget } from "@/workspace-pins/target";
|
||||
@@ -30,9 +31,7 @@ export function runPinnedTabTarget(
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
handlers.createTerminalWithProfile({
|
||||
name: profile.name,
|
||||
command: profile.command,
|
||||
args: profile.args,
|
||||
});
|
||||
// Nothing to type from a pin, so the prompt is empty and any sentinel arg
|
||||
// drops out.
|
||||
handlers.createTerminalWithProfile(resolveTerminalProfileLaunch(profile, ""));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user