fix(web): run new-session slash actions (skills, /goal, /btw) from the empty composer - #1445
Conversation
The composer now lists workspace skills before a session exists, but activating one from the empty-session composer silently did nothing: activateSkill() short-circuits when there is no active session id, so the command was cleared with no turn started. Mirror the first-prompt path: when a slash skill is activated with no active session (but a workspace is active), create the session first and activate on the new session id. Extract the shared session-creation block out of startSessionAndSendPrompt into createDraftSession and add a startSessionAndActivateSkill counterpart; activateSkill now also accepts an explicit session id so a concurrent session switch can't redirect it. Unrecognized '/cmd' text still falls through to a plain-text message as before.
🦋 Changeset detectedLatest commit: 4edba6f The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 670bfd6fbd
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| try { | ||
| const sid = await createDraftSession(workspaceId); | ||
| if (!sid) return; | ||
| await modelProvider.activateSkill(skillName, args, sid); |
There was a problem hiding this comment.
Persist draft modes before activating a new-session skill
When the user enables /plan or /swarm in the draft composer and then selects a workspace skill, createDraftSession only updates the web client's local rawState.*ModeBySession maps; the /skills/:activate request carries only args and does not send the prompt-time planMode/swarmMode flags or a /profile update. In that scenario the first skill turn runs on the daemon with the default modes while the UI shows them as enabled, so persist the draft modes to the new session before this activation (or include them in session creation) rather than relying on local state alone.
Useful? React with 👍 / 👎.
The skill-activate request carries only `args`, so for a skill launched from the new-session composer createDraftSession's local plan/swarm maps never reached the daemon, and the first skill turn ran at default modes while the UI showed them enabled. Persist the draft plan/swarm modes to the new session's profile inside createDraftSession (by the new session id), and teach persistSessionProfile to take an explicit session id so a concurrent session switch during the snapshot load can't write the patch to the wrong session. Plain prompts already send planMode/swarmMode on the prompt request itself, so this is redundant but harmless on the first-prompt path. Goal mode is a one-shot flag consumed per send, not a profile field, so there is nothing to persist for it. Add coverage that the profile is persisted before activation when draft plan/swarm modes are enabled.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 822238f041
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // map writes above. Goal mode is a one-shot flag consumed per send, not a | ||
| // profile field, so there is nothing to persist for it. | ||
| if (draftModes.planMode || draftModes.swarmMode) { | ||
| persistSessionProfile( |
There was a problem hiding this comment.
Await draft mode persistence before skill activation
When the user enables draft plan/swarm mode and invokes a slash skill from the empty composer, this persistSessionProfile call starts POST /profile but returns immediately; startSessionAndActivateSkill then sends :activate without waiting. Since skill activation carries only args, the server can process the activation before applyAgentState from /profile finishes, so the first skill turn runs with plan/swarm disabled even though the UI shows it enabled. Make the profile update awaitable for this path, or pass the modes with the activation, before activating.
Useful? React with 👍 / 👎.
The previous fix wrote the draft plan/swarm modes to the new session's profile but fire-and-forget: persistSessionProfile returned immediately after starting POST /profile, so startSessionAndActivateSkill sent :activate without waiting. Since skill activation carries only args, the daemon could process :activate (and start the turn) before applyAgentState from /profile finished, running the first skill turn at default modes while the UI showed them enabled. Make persistSessionProfile return the update promise and await it inside createDraftSession, so any origin that follows (the skill activation, or a plain prompt) only starts after the profile is applied. Existing callers still fire-and-forget via `void persistSessionProfile(...)`. Test coverage now blocks activation behind a deferred profile POST and asserts it is issued only after the profile resolves.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fd82602fe9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| { | ||
| planMode: draftModes.planMode, | ||
| swarmMode: draftModes.swarmMode, | ||
| }, |
There was a problem hiding this comment.
Persist draft permission and thinking before skill activation
When the first action from the empty composer is a slash skill after the user changed draft controls such as /auto//yolo or the thinking selector, those settings are never sent to the new session. Normal first prompts pass permissionMode and thinking in submitPromptInternal, but this path goes straight to activateSkill, whose REST request only carries args; this profile patch only copies plan/swarm (and is skipped entirely when only permission/thinking changed), so the first skill turn runs with daemon defaults while the UI shows the selected controls.
Useful? React with 👍 / 👎.
Move the awaited plan/swarm profile write out of createDraftSession and into startSessionAndActivateSkill. The create path now only builds the session and mirrors draft modes into the per-session maps, matching the old first-prompt behavior: plain prompts already send planMode/swarmMode on the prompt request itself, so a /profile write there was redundant. The profile write stays on the one path that needs it: skill activation carries only args, so the draft modes must be stored on the new session and applied before :activate is sent.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8989661190
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const planMode = rawState.planModeBySession[sid] ?? false; | ||
| const swarmMode = rawState.swarmModeBySession[sid] ?? false; | ||
| if (planMode || swarmMode) { | ||
| await persistSessionProfile({ planMode, swarmMode }, sid); |
There was a problem hiding this comment.
Persist draft permission and thinking before activation
When the user starts from the new-session composer and first changes runtime controls such as /auto, /yolo, or /thinking, those values live only in rawState because there is no session for persistSessionProfile yet. A normal first prompt still honors them because submitPromptInternal sends permissionMode and thinking with the prompt, but this new skill path only persists plan/swarm before calling activateSkill, whose REST body carries only args. In that scenario the first skill turn runs with the daemon defaults instead of the controls the UI shows; include the current permission/thinking in this profile patch before activation.
Useful? React with 👍 / 👎.
/auto, /yolo, and /thinking on the new-session composer only update rawState (there is no session to persist to yet). A plain first prompt still honors them because submitPromptInternal sends permissionMode and thinking on the request, but skill activation carries only args, so the first skill turn was running at daemon defaults. Include permissionMode and thinking in the awaited profile patch alongside planMode/swarmMode before activating, so the skill turn matches the controls the UI shows.
createGoal() short-circuited when there was no active session, so `/goal <objective>` from the empty-session composer silently cleared and ran nothing — the same bug class as the slash-skill activation fixed earlier in this PR. Mirror startSessionAndSendPrompt: when no session exists but a workspace is active, create one first then target it with the goal profile update and the objective prompt. Send via submitPromptInternal with the explicit sid so a concurrent session switch during creation can't redirect it. Plain prompts already carry their own permissionMode / thinking / plan / swarm, so no profile fallback is needed here.
1 similar comment
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b04f4fb0bc
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const wsId = rawState.activeWorkspaceId; | ||
| if (!wsId) return; |
There was a problem hiding this comment.
Use the active workspace fallback when creating goals
When the app has registered workspaces but no sessions yet, load() never writes rawState.activeWorkspaceId because there is no mostRecent session; the UI still has a usable workspace through the activeWorkspaceId computed fallback. In that empty-composer state, /goal <objective> now reaches this new branch, sees rawState.activeWorkspaceId as null, and silently returns instead of creating the first session, while normal first prompts and skill activations use the computed workspace id from App.vue. Use the same fallback (or pass the workspace id into createGoal) so first-session goals work in empty workspaces.
Useful? React with 👍 / 👎.
On a fresh-booted empty workspace, load() never writes rawState.activeWorkspaceId (no most-recent session to anchor it). The UI still has a usable workspace via the client-wide activeWorkspaceId computed, which falls back to the first sidebar-visible workspace — but createGoal read the raw value directly and silently no-op'd when it was null. Normal first prompts and skill activations didn't hit this because App.vue passes the computed activeWorkspaceId in. Make createGoal use the same fallback so a first-session `/goal <objective>` works in empty workspaces too.
openSideChat() reads rawState.activeSessionId directly, so `/btw [<question>]` from the empty-session composer silently no-oped — it still set detailTarget to 'btw', leaving the side chat panel open but empty. Add startSessionAndOpenSideChat(workspaceId, prompt?) that creates the parent session first, then calls a new sideChat.openSideChatOn(sid, prompt) which targets the explicit parent session id (race-safe against a concurrent session switch, like the skill activation case). Route through it from the empty-composer branch in openSideChatTab. Side-chat prompts now also carry model / thinking / permissionMode / plan / swarm (via a shared sendSideChatPromptOn), so a BTW first turn matches the UI even when the parent /profile is still in flight. Unlike skill activation, this means the BTW path needs no profile fallback. Tests: startSessionAndOpenSideChat creates a session then opens BTW on the new id, works without an initial question, and is a no-op for an unknown workspace; sendSideChatPromptOn carries the runtime controls on the submitted prompt.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3b90fefc62
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // redirect the goal prompt to the wrong session. Plain prompts carry their | ||
| // own permissionMode / thinking / plan / swarm on the request, so we don't | ||
| // need to persist them onto the profile here. | ||
| await submitPromptInternal(sid, trimmed); |
There was a problem hiding this comment.
Preserve the prompt queue when creating goals
When /goal <objective> is run against an already-active session that is still running, the composer still emits the command, but this now bypasses sendPrompt's activity !== 'idle' || inFlightPromptSessions.has(sid) guard and posts immediately via submitPromptInternal. That helper is explicitly the queue-bypassing path and also stamps promptIdBySession, so a goal created while another turn is running can race with the active turn instead of being locally queued like normal sends were before this change.
Useful? React with 👍 / 👎.
Switching createGoal from sendPrompt to submitPromptInternal avoided the activeSessionId race during the empty-composer create window, but it also bypassed sendPrompt's queue guard: when a goal is created against an already-active session that is running another turn, the prompt posted immediately instead of being locally queued. Restore the guard for the overwhelmingly common case (the goal still targets the active session): route through sendPrompt when activeSessionId still matches the resolved sid, which enqueues when the session is running or a prompt is already in flight. Only fall back to the explicit- session submitPromptInternal(sid) when activeSessionId moved during the create window, so a concurrent session switch can't redirect the goal prompt. The newly-created session is idle+not-in-flight in that branch, so the explicit submit does not race another turn. Add a regression test: createGoal against a running existing session enqueues instead of submits.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5ac7fe1aad
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| ? raw | ||
| : (workspacesView.value[0]?.id ?? null); | ||
| if (!wsId) return; | ||
| sid = (await createDraftSession(wsId)) ?? undefined; |
There was a problem hiding this comment.
Handle session creation failures for empty goals
When /goal <objective> is run from the empty composer, createDraftSession() can reject (for example if createSession or the initial snapshot fails), but this await happens before the existing try/catch. Since App.vue invokes client.createGoal() fire-and-forget, that leaves an unhandled rejection and no operation failure is surfaced, unlike the new skill/BTW and first-prompt paths that wrap draft-session creation.
Useful? React with 👍 / 👎.
| content: [{ type: 'text', text: trimmed }], | ||
| agentId, | ||
| model, | ||
| thinking: rawState.thinking, |
There was a problem hiding this comment.
Coerce thinking before BTW prompts
When the stored/global thinking level is stale for the parent session's model (for example after switching from an effort-capable model to a session whose model does not support that effort), the composer displays the coerced level and normal prompts use coercePromptThinking(model), but this new BTW path submits rawState.thinking directly. That can make /btw <question> run with a different thinking setting than the UI shows and than an ordinary prompt in the same session would use.
Useful? React with 👍 / 👎.
| planMode, | ||
| swarmMode, | ||
| permissionMode: rawState.permission, | ||
| thinking: rawState.thinking, |
There was a problem hiding this comment.
Coerce thinking before activating new-session skills
When a slash skill is launched from the empty composer after rawState.thinking was carried over from a different/default model, the composer and first-prompt path coerce that value for the newly created session's model, but this profile update persists the raw value before activation. In that case the first skill turn can run with a thinking level that the UI would not send for the same model.
Useful? React with 👍 / 👎.
…ures Three follow-ups from review: - createGoal now wraps createDraftSession in a try/catch: App.vue invokes it fire-and-forget, so a rejection from session creation previously leaked as an unhandled rejection with no operation failure surfaced. Mirrors the skill / BTW / first-prompt paths that already wrap it. - startSessionAndActivateSkill coerces the draft thinking level against the new session's model before persisting the profile (via coercePromptThinking), matching what the first-prompt path submits. A value carried over from another/default model (e.g. 'max' from an effort model) would otherwise be persisted verbatim and the first skill turn would run at a level the UI wouldn't send for this model. - sendSideChatPromptOn coerces thinking against the parent session's model the same way normal prompts do (coerceThinkingForModel against the model catalog). Model catalog threaded in via a new 'models' dep on useSideChat. Same reasoning: stale rawState.thinking must not be submitted raw into a BTW first turn.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bca67b671f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (rawState.activeSessionId === sid) { | ||
| await sendPrompt(trimmed); |
There was a problem hiding this comment.
Clear staged goal mode before submitting explicit goals
When the empty composer already has goal mode staged (for example the user ran bare /goal and then types /goal ship it), createDraftSession() copies that draft flag into rawState.goalModeBySession[sid]. This sendPrompt(trimmed) then re-enters submitPromptInternal, which sees goal mode still enabled and sends a second goalObjective update after the explicit one above already created the goal; the daemon rejects that as an existing goal, so the objective prompt is never submitted. Clear the one-shot flag before this submit, or bypass the goal-mode branch for explicit /goal <objective>.
Useful? React with 👍 / 👎.
When the empty composer has goal mode staged (e.g. the user runs bare `/goal`, then `/goal <objective>`) and an explicit objective is then submitted, createDraftSession copies draftModes.goalMode into goalModeBySession[sid]. createGoal then updateSession(goalObjective) for the explicit goal, and sendPrompt(trimmed) re-enters submitPromptInternal which sees goalModeBySession[sid] still set and POSTs a second goalObjective. The daemon rejects that as an existing goal, the catch in submitPromptInternal rolls back the optimistic message, and the user's objective prompt never lands. Clear the staged goalModeBySession[sid] flag right after the explicit update, since `/goal <objective>` has exactly the same effect as the flag's consumption.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4edba6f881
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!client.activeSessionId.value && client.activeWorkspaceId.value) { | ||
| void client.startSessionAndActivateSkill(client.activeWorkspaceId.value, name, args); |
There was a problem hiding this comment.
Avoid creating sessions for unknown slash commands
When the empty composer receives a non-built-in slash typo such as /pre-changlog, this branch creates a real backend session before activateSkill can return skill.not_found; the optimistic message is removed on failure, but the blank session remains active/in the sidebar. That regresses the documented harmless stray-slash behavior for the new-session screen, so validate the name against the loaded workspace skills (or otherwise avoid creating a session) before taking this creation path.
Useful? React with 👍 / 👎.
Related Issue
No linked issue. Follow-up to #1392, which listed workspace skills in the composer before a session exists.
Problem
Several slash actions that run (or start) a turn silently do nothing from the new-session (onboarding) composer, because their implementations short-circuit when there is no active session id. The user types the command, the box clears, and nothing happens:
/<skill>— e.g./pre-changelog(no args) or/write-goal ship it(with args)./goal <objective>./btw [<question>]— opened an empty side-chat panel (the panel target was set even though the BTW sub-agent never started).Plain text first prompts were fine because
handleSubmitcreates the session first; the slash actions didn't.What changed
Mirror the existing first-prompt session-creation path for each affected action so the empty-composer case behaves like the first message:
startSessionAndActivateSkill(workspaceId, name, args?).Creates the session first, then persists the draft runtime controls the prompt would have carried (
planMode/swarmMode/permissionMode/thinking) and awaits that profile update before activating. Skill activation carries onlyargs, so without this the first skill turn runs at daemon defaults while the UI shows the controls enabled.activateSkillalso takes an explicit session id so a concurrent session switch can't redirect it./goal <objective>:createGoalnow creates the session first when none exists and a workspace is usable (via the same fallback as the sidebar), then sets the goal profile and submits the objective — targeted by explicit sid for the same race reason. Plain prompts carry their own permissionMode / thinking / plan / swarm, so the goal path doesn't need the profile fallback./btw [<question>]: newstartSessionAndOpenSideChat(workspaceId, prompt?)that creates the parent session then opens a BTW sub-agent on it viaopenSideChatOn(sid, prompt). Side-chat prompts now also carry model / thinking / permissionMode / plan / swarm (so a BTW first turn matches the UI even when the parent/profileis still in flight), so unlike skill activation no profile fallback is needed here.createDraftSessionso the model pick and draft modes are applied identically for prompts, goals, skills, and BTW. It stays pure (no profile persistence); the runtime controls that aren't on the turn-start request are persisted only on the paths that need them (skill activation).Checklist
gen-changesetsskill.gen-docsskill, or this PR needs no doc update.