# /continue-locally slash command — Tech Spec Pairs with `PRODUCT.md`. ## Context The cloud-to-local fork pipeline already exists. `WorkspaceAction::ForkAIConversation` (`app/src/workspace/action.rs:462-475`) — and its narrower sibling `WorkspaceAction::ContinueConversationLocally` (`app/src/workspace/action.rs:478-481`) — both land in `Workspace::fork_ai_conversation` (`app/src/workspace/view.rs:11460-11668`), which loads the source conversation, calls `BlocklistAIHistoryModel::fork_conversation`, opens the fork in the chosen `ForkedConversationDestination`, and ends with `Self::show_fork_toast` (`app/src/workspace/view.rs:11744-11772`) producing the `Forked ""` toast referenced by PRODUCT.md invariant 7. The two existing button entrypoints, both gated to Oz harness only, dispatch the narrower action: - Conversation details panel: `app/src/ai/conversation_details_panel.rs:512-520, 1981-1992`, with the harness gate at `continue_locally_conversation_id` (`app/src/ai/conversation_details_panel.rs:564-602`). - Conversation-ended tombstone: `app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs:485-520`, with the harness gate at `render_action_buttons` (`app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs:464-511`). Slash commands: definitions in `app/src/search/slash_command_menu/static_commands/commands.rs`, dynamic per-session filtering in `app/src/terminal/input/slash_commands/data_source/mod.rs:146-235`, and execution dispatch in `app/src/terminal/input/slash_commands/mod.rs:296-847`. `Availability` is a `u8` bitfield with all 8 bits already in use (`app/src/search/slash_command_menu/static_commands/mod.rs:18-37`), so the cloud-Oz availability constraint must be expressed as a runtime filter — `/orchestrate` and `/feedback` already use this pattern at lines 211-225 of the data source. `/fork`'s handler (`app/src/terminal/input/slash_commands/mod.rs:718-742`) is the closest template: it pulls `conversation_id` via `ai_context_model.selected_conversation_id`, picks the destination from `trigger.is_cmd_or_ctrl_enter()`, and dispatches `ForkAIConversation`. `AIConversation::task_id()` (`app/src/ai/agent/conversation.rs:718`) returns `Option<AmbientAgentTaskId>` for cloud-backed conversations. `AgentConversationsModel::get_task_data()` (`app/src/ai/agent_conversations_model.rs:1546-1548`) returns the in-memory `AmbientAgentTask`; the harness lives at `task.agent_config_snapshot.harness.harness_type` and defaults to `Harness::Oz` when the snapshot is present but no explicit harness is set (matching `enrich_from_task` in `conversation_ended_tombstone_view.rs:131-141`). ## Proposed changes ### 1. Static command definition `app/src/search/slash_command_menu/static_commands/commands.rs` Add a `CONTINUE_LOCALLY` `LazyLock<StaticCommand>` next to the rest of the fork family (`FORK`, `FORK_AND_COMPACT`, `FORK_FROM`): - `name: "/continue-locally"` - `description: "Continue this cloud conversation locally"` - `icon_path: "bundled/svg/arrow-split.svg"` (same as `FORK`) - `availability: Availability::AGENT_VIEW | Availability::ACTIVE_CONVERSATION | Availability::AI_ENABLED` - `auto_enter_ai_mode: true` - `argument: Some(Argument::optional().with_hint_text("<optional prompt to send in forked conversation>"))` Push it onto `all_commands()` inside the existing `if !cfg!(target_family = "wasm")` block (`commands.rs:572-578`) alongside `FORK` / `FORK_AND_COMPACT`. No feature flag — the underlying action and all gating are stable. ### 2. Cloud-Oz runtime filter in the data source `app/src/terminal/input/slash_commands/data_source/mod.rs` Express PRODUCT.md invariant 2 as a runtime filter, mirroring `/orchestrate` and `/feedback`: ```rust path=null start=null .filter(|(_, command)| { command.name != commands::CONTINUE_LOCALLY.name || self.active_conversation_is_cloud_oz(ctx) }) ``` Add a private helper `fn active_conversation_is_cloud_oz(&self, ctx: &AppContext) -> bool` that, in order: 1. Reads the active conversation id from `agent_view_controller.agent_view_state().active_conversation_id()` (returns false if `None`). 2. Looks up the `AIConversation` in `BlocklistAIHistoryModel`. Reads `conversation.task_id()`; returns false if `None` (local conversation). 3. Reads the task via `AgentConversationsModel::get_task_data(task_id)`. 4. Returns true iff the task's `agent_config_snapshot.harness.harness_type` is `Harness::Oz`, or the snapshot is absent / harness is unset (the same permissive default the tombstone uses, so we don't hide the command while the task fetch is still pending — `Some(Harness::Claude)` / `Some(Harness::Gemini)` are the only states that hide the command). Subscribe `recompute_active_commands` to: - `AgentConversationsModelEvent::TasksUpdated` — so the menu updates as the task fetch resolves and as task harness becomes known. - `BlocklistAIHistoryEvent::SetActiveConversation` — so the menu updates when the active conversation switches (e.g. user navigates between cloud and local conversations). Both event types are already routed to other consumers; the subscription pattern follows the existing `ctx.subscribe_to_model` calls at `data_source/mod.rs:72-127`. ### 3. Slash command handler `app/src/terminal/input/slash_commands/mod.rs` Add a handler arm in `Input::execute_slash_command_action` (next to the existing `/fork` arm at `slash_commands/mod.rs:718-742`): ```rust path=null start=null continue_locally if command.name == commands::CONTINUE_LOCALLY.name => { let Some(conversation_id) = self .ai_context_model .as_ref(ctx) .selected_conversation_id(ctx) else { show_error_toast("/continue-locally requires an active conversation".to_owned(), ctx); return true; }; if !active_conversation_is_cloud_oz(conversation_id, ctx) { show_error_toast( "/continue-locally requires an active cloud Oz conversation".to_owned(), ctx, ); return true; } let destination = if trigger.is_cmd_or_ctrl_enter() { ForkedConversationDestination::NewTab } else { ForkedConversationDestination::SplitPane }; send_telemetry_from_ctx!( AgentManagementTelemetryEvent::SlashCommandContinueLocally, ctx ); ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation { conversation_id, fork_from_exchange: None, summarize_after_fork: false, summarization_prompt: None, initial_prompt: argument.cloned(), destination, }); } ``` `active_conversation_is_cloud_oz(conversation_id, ctx)` is a small private free function in the same module that performs the same harness lookup as the data source helper, factored to take an explicit `conversation_id` (the data source uses the agent view's active id; the handler already has it from `selected_conversation_id`). It is the defensive runtime gate from PRODUCT.md invariant 8. The handler is gated `cfg(not(target_family = "wasm"))`. Empty/whitespace `argument` is normalized to `None` by `ForkAIConversation`'s downstream `fork_ai_conversation` (`app/src/workspace/view.rs:11483-11490`), so PRODUCT.md invariant 4 is satisfied without further work here. Success toasts (PRODUCT.md invariant 7) and error toasts on fork failure (invariant 8) are produced by `Workspace::show_fork_toast` and the existing failure paths inside `fork_ai_conversation` — no toast plumbing in the slash command handler itself. ### 4. Footer tip `app/src/ai/blocklist/agent_view/agent_message_bar.rs` Extend `ForkSlashCommandMessageProducer::produce_message` (lines 727-777) to also match `commands::CONTINUE_LOCALLY.name`. Add it to the existing equality check and reuse the `/fork` label branch (`(" new pane", " new tab")`), since `/continue-locally` follows the same `Enter → split pane`, `Cmd/Ctrl+Enter → new tab` mapping. ### 5. Telemetry `app/src/ai/agent_management/telemetry.rs` Add a new variant `SlashCommandContinueLocally` to `AgentManagementTelemetryEvent` (`telemetry.rs:108-113`), wasm-gated like the existing `TombstoneContinueLocally` / `DetailsPanelContinueLocally`, with empty payload, `EnablementState::Always`, and the discriminant entries: - Name: `"AgentManagement.SlashCommandContinueLocally"` - Description: `"User invoked /continue-locally to fork a cloud conversation locally"` Fire it from the slash command handler immediately before dispatching `ForkAIConversation` (see §3). The existing `ConversationForked` event is fired downstream by the fork pipeline and does not need to be touched. ## Testing and validation References below are to PRODUCT.md numbered invariants. Unit tests, colocated with the modules being changed: - `app/src/search/slash_command_menu/static_commands/mod_test.rs`: add a registration test for `/continue-locally` covering name, icon, optional argument with hint text, `auto_enter_ai_mode: true`, and the static availability set. (Inv. 1) - `app/src/terminal/input/slash_commands/data_source/` tests (matching the existing layout): cover the four cloud-Oz filter outcomes — local conversation (no `task_id`) hides the command (Inv. 2); cloud Oz task shows it (Inv. 2, 3); cloud Claude/Gemini task hides it (Inv. 2); cloud task whose data hasn't been fetched yet shows it permissively and recomputes on `TasksUpdated` (Inv. 2). Plus AI-disabled hides it via the static `AI_ENABLED` requirement (Inv. 3). - `app/src/ai/agent_management/telemetry.rs` discriminant tests: assert the new variant maps to the expected name and description, mirroring the existing test pattern for `DetailsPanelContinueLocally`. (Inv. 9) Manual: - Cloud Oz run, in-progress, agent input visible: `/continue-locally` appears in the slash menu; Enter forks into a split pane and shows `Forked "<title>"`; Cmd-Enter forks into a new tab. (Inv. 2, 3, 5, 6, 7) - Same run, type `/continue-locally do X next`, press Enter: forked conversation receives `do X next` as its first user query. (Inv. 4, 5, 6) - Source-load failure (simulate by clearing the conversation from history before pressing Enter): error toast surfaces with the existing copy. (Inv. 8) - Cloud Oz run viewed via shared session, completed but input still visible: command works as above. (Inv. 3) - Cloud Oz run viewed via completed transcript viewer: input is hidden; command is unreachable, existing tombstone button still works. (Inv. 2, Non-goals) - Cloud Claude run: command absent from menu. (Inv. 2) - Local conversation (no `task_id`): command absent from menu. (Inv. 2) Run `./script/presubmit` before opening the PR. ## Follow-ups - If the menu visibility lag during the initial task fetch becomes noticeable in practice, switch the permissive `None` harness branch to fetch on demand via `AgentConversationsModel::get_or_async_fetch_task_data` (`app/src/ai/agent_conversations_model.rs:1564-1645`) and rely on the `TasksUpdated` recompute. We default to permissive for parity with the tombstone today.