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:
Mohamed Boudra
2026-08-07 14:08:22 +08:00
committed by GitHub
parent 292a9c4c8c
commit c352632b74
36 changed files with 2929 additions and 154 deletions
+18 -1
View File
@@ -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: {
+18 -1
View File
@@ -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: {
+18 -1
View File
@@ -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: {
+18 -1
View File
@@ -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: {
+18 -1
View File
@@ -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: {
+18 -1
View File
@@ -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: {
+18 -1
View File
@@ -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: {
+18 -1
View File
@@ -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: {
+18 -1
View File
@@ -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: {