Repository: iOfficeAI/AionUi (2026-03-27T23:54:31Z)

Issue:
[Feature]: add /btw side question flow for Claude Code
## Feature Description

Add a `/btw` side question flow for Claude Code conversations, including overlay entry, sendbox integration, and side-question execution handling.

## User Flow

1. Open a Claude Code conversation
2. Trigger the `/btw` command from the chat input flow
3. Enter a side question in the BTW overlay
4. Submit the side question
5. Observe the side question execute without disrupting the main conversation flow

## Scope

The `/btw` flow is only intended for Claude Code at this stage. It should not be exposed for non-Claude-Code conversation paths.

## Implementation Notes

The change adds the BTW overlay and command wiring in the renderer, connects the execution path through the conversation bridge, and introduces a `toolsRequired` result state for cases where tools run but no final text is produced.

## Affected Files (fixed in #1818)

- `src/common/adapter/ipcBridge.ts`
- `src/common/chat/sideQuestion.ts`
- `src/common/chat/slash/types.ts`
- `src/process/bridge/conversationBridge.ts`
- `src/process/bridge/services/ConversationSideQuestionService.ts`
- `src/renderer/components/chat/BtwOverlay/*`
- `src/renderer/components/chat/sendbox.tsx`
- `src/renderer/hooks/chat/useSlashCommandController.ts`
- `src/renderer/pages/conversation/platforms/*SendBox.tsx`
- `src/renderer/services/i18n/locales/*/conversation.json`
- `tests/unit/btwOverlay.dom.test.tsx`
- `tests/unit/process/bridge/conversationSideQuestionService.test.ts`
- `tests/unit/sendboxBtw.dom.test.tsx`
- `tests/unit/slashCommandController.test.tsx`

## Diff

`+1620 −20` across 28 files


Discussion:

Patch:
diff --git a/src/common/adapter/ipcBridge.ts b/src/common/adapter/ipcBridge.ts
index a628857310..01a181f91d 100644
--- a/src/common/adapter/ipcBridge.ts
+++ b/src/common/adapter/ipcBridge.ts
@@ -51,6 +51,10 @@ export const conversation = {
     IBridgeResponse<{ commands: SlashCommandItem[] }>,
     { conversation_id: string }
   >('conversation.get-slash-commands'),
+  askSideQuestion: bridge.buildProvider<
+    IBridgeResponse<ConversationSideQuestionResult>,
+    { conversation_id: string; question: string }
+  >('conversation.ask-side-question'),
   confirmMessage: bridge.buildProvider<IBridgeResponse, IConfirmMessageParams>('conversation.confirm.message'), // 通用确认消息
   responseStream: bridge.buildEmitter<IResponseMessage>('chat.response.stream'), // 接收消息（统一接口）
   turnCompleted: bridge.buildEmitter<IConversationTurnCompletedEvent>('conversation.turn.completed'),
@@ -895,6 +899,26 @@ export interface IConversationListChangedEvent {
   action: 'created' | 'updated' | 'deleted';
   source?: string;
 }
+
+export type ConversationSideQuestionResult =
+  | {
+      status: 'ok';
+      answer: string;
+    }
+  | {
+      status: 'noAnswer';
+    }
+  | {
+      status: 'unsupported';
+    }
+  | {
+      status: 'invalid';
+      reason: 'emptyQuestion';
+    }
+  | {
+      status: 'toolsRequired';
+    };
+
 interface IBridgeResponse<D = {}> {
   success: boolean;
   data?: D;
diff --git a/src/common/chat/sideQuestion.ts b/src/common/chat/sideQuestion.ts
new file mode 100644
index 0000000000..41e9c940b5
--- /dev/null
+++ b/src/common/chat/sideQuestion.ts
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright 2025 AionUi (aionui.com)
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { TChatConversation } from '@/common/config/storage';
+import type { AcpBackend } from '@/common/types/acpTypes';
+
+type SideQuestionConversationType = TChatConversation['type'];
+
+export type SideQuestionEligibilityTarget = {
+  backend?: AcpBackend;
+  type: SideQuestionConversationType;
+};
+
+export function isSideQuestionSupported(target: SideQuestionEligibilityTarget): boolean {
+  return target.type === 'acp' && target.backend === 'claude';
+}
diff --git a/src/common/chat/slash/types.ts b/src/common/chat/slash/types.ts
index 41a0b60c5a..f22d4619ec 100644
--- a/src/common/chat/slash/types.ts
+++ b/src/common/chat/slash/types.ts
@@ -11,6 +11,13 @@
  */
 export type SlashCommandKind = 'template' | 'builtin';
 
+/**
+ * Defines what happens when the user selects a slash command from the menu.
+ * - `execute`: run the command immediately
+ * - `insert`: insert `/<name> ` into the input
+ */
+export type SlashCommandSelectionBehavior = 'execute' | 'insert';
+
 /**
  * Indicates where the slash command originates from.
  * - `acp`: Provided by the ACP agent (e.g., Claude)
@@ -32,4 +39,6 @@ export interface SlashCommandItem {
   source: SlashCommandSource;
   /** Optional keyboard hint (e.g., "⌘O") */
   hint?: string;
+  /** Optional override for how selection behaves in the slash menu */
+  selectionBehavior?: SlashCommandSelectionBehavior;
 }
diff --git a/src/process/bridge/conversationBridge.ts b/src/process/bridge/conversationBridge.ts
index 283ed25d06..b75b608510 100644
--- a/src/process/bridge/conversationBridge.ts
+++ b/src/process/bridge/conversationBridge.ts
@@ -20,6 +20,7 @@ import { refreshTrayMenu } from '@process/utils/tray';
 import { copyFilesToDirectory, readDirectoryRecursive } from '@process/utils';
 import { computeOpenClawIdentityHash } from '@process/utils/openclawUtils';
 import { migrateConversationToDatabase } from './migrationUtils';
+import { ConversationSideQuestionService } from './services/ConversationSideQuestionService';
 
 const refreshTrayMenuSafely = async (): Promise<void> => {
   try {
@@ -33,6 +34,8 @@ export function initConversationBridge(
   conversationService: IConversationService,
   workerTaskManager: IWorkerTaskManager
 ): void {
+  const sideQuestionService = new ConversationSideQuestionService(conversationService);
+
   const emitConversationListChanged = (
     conversation: Pick<TChatConversation, 'id' | 'source'>,
     action: 'created' | 'updated' | 'deleted'
@@ -252,7 +255,7 @@ export function initConversationBridge(
         if (modelChanged) {
           try {
             workerTaskManager.kill(id);
-          } catch (killErr) {
+          } catch {
             // ignore kill error, will lazily rebuild later
           }
         }
@@ -392,6 +395,25 @@ export function initConversationBridge(
     }
   });
 
+  ipcBridge.conversation.askSideQuestion.provider(async ({ conversation_id, question }) => {
+    try {
+      const result = await sideQuestionService.ask(conversation_id, question);
+      return {
+        success: true,
+        data: result,
+      };
+    } catch (error) {
+      console.error('[conversationBridge] /btw request failed', {
+        conversationId: conversation_id,
+        error: error instanceof Error ? error.message : String(error),
+      });
+      return {
+        success: false,
+        msg: error instanceof Error ? error.message : String(error),
+      };
+    }
+  });
+
   // 通用 sendMessage 实现 - 统一调用 IAgentManager.sendMessage
   // Generic sendMessage - dispatches via IAgentManager.sendMessage interface
   ipcBridge.conversation.sendMessage.provider(async ({ conversation_id, files, ...other }) => {
diff --git a/src/process/bridge/services/ConversationSideQuestionService.ts b/src/process/bridge/services/ConversationSideQuestionService.ts
new file mode 100644
index 0000000000..05b196bf2b
--- /dev/null
+++ b/src/process/bridge/services/ConversationSideQuestionService.ts
@@ -0,0 +1,281 @@
+/**
+ * @license
+ * Copyright 2025 AionUi (aionui.com)
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { ConversationSideQuestionResult } from '@/common/adapter/ipcBridge';
+import { isSideQuestionSupported } from '@/common/chat/sideQuestion';
+import type { TChatConversation } from '@/common/config/storage';
+import type { AcpBackend, AcpPermissionRequest, AcpSessionUpdate } from '@/common/types/acpTypes';
+import { AcpConnection } from '@process/agent/acp/AcpConnection';
+import type { IConversationService } from '@process/services/IConversationService';
+import { ProcessConfig } from '@process/utils/initStorage';
+import { ACP_BACKENDS_ALL } from '@/common/types/acpTypes';
+const ACP_SIDE_QUESTION_TIMEOUT_MS = 30_000;
+const ACP_SIDE_QUESTION_PROMPT_TIMEOUT_SECONDS = 30;
+
+type ResolvedAcpContext = {
+  acpSessionId: string;
+  backend: AcpBackend;
+  cliPath?: string;
+  customEnv?: Record<string, string>;
+  customArgs?: string[];
+  workspace: string;
+};
+
+class AcpSideQuestionUnsupportedError extends Error {}
+class AcpSideQuestionFailedError extends Error {}
+
+export class ConversationSideQuestionService {
+  constructor(private readonly conversationService: IConversationService) {}
+
+  async ask(conversationId: string, question: string): Promise<ConversationSideQuestionResult> {
+    const trimmedQuestion = question.trim();
+    if (!trimmedQuestion) {
+      return { status: 'invalid', reason: 'emptyQuestion' };
+    }
+
+    const conversation = await this.conversationService.getConversation(conversationId);
+    if (!conversation) {
+      return { status: 'unsupported' };
+    }
+
+    const backend = conversation.type === 'acp' ? conversation.extra.backend : undefined;
+    if (!isSideQuestionSupported({ type: conversation.type, backend })) {
+      return { status: 'unsupported' };
+    }
+
+    const resolvedAcpContext = await this.resolveAcpSideQuestionContext(conversation);
+    if (resolvedAcpContext) {
+      try {
+        const result = await this.askWithAcpFork(conversationId, trimmedQuestion, resolvedAcpContext);
+        if (result.toolsAttempted && !result.answer) {
+          return { status: 'toolsRequired' };
+        }
+        if (!result.answer) {
+          return { status: 'noAnswer' };
+        }
+        return {
+          status: 'ok',
+          answer: result.answer,
+        };
+      } catch (caughtError) {
+        if (caughtError instanceof AcpSideQuestionUnsupportedError) {
+          return { status: 'unsupported' };
+        }
+        throw caughtError;
+      }
+    }
+
+    return { status: 'unsupported' };
+  }
+
+  private async askWithAcpFork(
+    conversationId: string,
+    question: string,
+    context: ResolvedAcpContext
+  ): Promise<{ answer: string; toolsAttempted: boolean }> {
+    const connection = new AcpConnection();
+    connection.setPromptTimeout(ACP_SIDE_QUESTION_PROMPT_TIMEOUT_SECONDS);
+
+    const completion = this.createAcpCompletionPromise(connection, conversationId, context.backend);
+
+    try {
+      await this.runWithTimeout(
+        (async () => {
+          await connection.connect(
+            context.backend,
+            context.cliPath,
+            context.workspace,
+            context.customArgs,
+            context.customEnv
+          );
+
+          try {
+            await connection.newSession(context.workspace, {
+              resumeSessionId: context.acpSessionId,
+              forkSession: true,
+              mcpServers: [],
+            });
+          } catch {
+            throw new AcpSideQuestionUnsupportedError('ACP forked side questions are not supported for this backend.');
+          }
+
+          await Promise.all([completion.promise, connection.sendPrompt(this.buildAcpSideQuestionPrompt(question))]);
+        })(),
+        ACP_SIDE_QUESTION_TIMEOUT_MS
+      );
+
+      return { answer: completion.getAnswer(), toolsAttempted: completion.toolsAttempted() };
+    } finally {
+      completion.dispose();
+      await connection.disconnect().catch((error: unknown) => {
+        console.warn('[ConversationSideQuestionService] Failed to disconnect ACP /btw runner', {
+          backend: context.backend,
+          conversationId,
+          error: error instanceof Error ? error.message : String(error),
+        });
+      });
+    }
+  }
+
+  private async resolveAcpSideQuestionContext(conversation: TChatConversation): Promise<ResolvedAcpContext | null> {
+    if (conversation.type !== 'acp') {
+      return null;
+    }
+
+    const extra = conversation.extra;
+    if (!extra?.backend || !extra.acpSessionId || !extra.workspace) {
+      return null;
+    }
+
+    if (extra.backend !== 'claude') {
+      return null;
+    }
+
+    const acpConfig = await ProcessConfig.get('acp.config');
+    const backendConfig = ACP_BACKENDS_ALL[extra.backend];
+    const cliPath = extra.cliPath || acpConfig?.[extra.backend]?.cliPath || backendConfig?.cliCommand;
+    if (!cliPath?.trim()) {
+      return null;
+    }
+
+    return {
+      acpSessionId: extra.acpSessionId,
+      backend: extra.backend,
+      cliPath: cliPath.trim(),
+      customArgs: backendConfig?.acpArgs,
+      workspace: extra.workspace,
+    };
+  }
+
+  private buildAcpSideQuestionPrompt(question: string): string {
+    return [
+      'Answer this brief side question using the current session context.',
+      'Do not use tools.',
+      'Do not ask follow-up questions.',
+      'Return one concise answer only.',
+      '',
+      `Side question: ${question}`,
+    ].join('\n');
+  }
+
+  private createAcpCompletionPromise(
+    connection: AcpConnection,
+    conversationId: string,
+    backend: AcpBackend
+  ): {
+    dispose: () => void;
+    getAnswer: () => string;
+    promise: Promise<void>;
+    toolsAttempted: () => boolean;
+  } {
+    let settled = false;
+    let answer = '';
+    let toolsWereAttempted = false;
+
+    const fail = (error: Error) => {
+      if (settled) {
+        return;
+      }
+      settled = true;
+      reject(error);
+    };
+    const succeed = () => {
+      if (settled) {
+        return;
+      }
+      settled = true;
+      resolve();
+    };
+
+    let resolve!: () => void;
+    let reject!: (error: Error) => void;
+    const promise = new Promise<void>((innerResolve, innerReject) => {
+      resolve = innerResolve;
+      reject = innerReject;
+    });
+
+    const previousSessionUpdate = connection.onSessionUpdate;
+    const previousPermissionRequest = connection.onPermissionRequest;
+    const previousEndTurn = connection.onEndTurn;
+    const previousDisconnect = connection.onDisconnect;
+
+    connection.onSessionUpdate = (data: AcpSessionUpdate) => {
+      previousSessionUpdate(data);
+      if (data.update.sessionUpdate === 'agent_message_chunk' && data.update.content.type === 'text') {
+        answer += data.update.content.text || '';
+        return;
+      }
+      if (data.update.sessionUpdate === 'tool_call' || data.update.sessionUpdate === 'tool_call_update') {
+        console.warn('[ConversationSideQuestionService] ACP /btw cancelled due to tool activity', {
+          backend,
+          conversationId,
+          update: data.update.sessionUpdate,
+        });
+        toolsWereAttempted = true;
+        connection.cancelPrompt();
+        succeed();
+      }
+    };
+
+    connection.onPermissionRequest = async (data: AcpPermissionRequest) => {
+      console.warn('[ConversationSideQuestionService] ACP /btw rejected permission request', {
+        backend,
+        conversationId,
+        tool: data.toolCall.title,
+      });
+      toolsWereAttempted = true;
+      connection.cancelPrompt();
+      succeed();
+      return {
+        optionId: data.options.find((option) => option.kind.startsWith('reject'))?.optionId || 'reject_once',
+      };
+    };
+
+    connection.onEndTurn = () => {
+      previousEndTurn();
+      succeed();
+    };
+
+    connection.onDisconnect = (error) => {
+      previousDisconnect(error);
+      fail(
+        new AcpSideQuestionFailedError(
+          `ACP /btw runner disconnected unexpectedly (${error.code ?? 'unknown'}:${error.signal ?? 'none'}).`
+        )
+      );
+    };
+
+    return {
+      dispose: () => {
+        connection.onSessionUpdate = previousSessionUpdate;
+        connection.onPermissionRequest = previousPermissionRequest;
+        connection.onEndTurn = previousEndTurn;
+        connection.onDisconnect = previousDisconnect;
+      },
+      getAnswer: () => answer.trim(),
+      promise,
+      toolsAttempted: () => toolsWereAttempted,
+    };
+  }
+
+  private async runWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
+    let timeoutId: ReturnType<typeof setTimeout> | null = null;
+    try {
+      return await Promise.race([
+        promise,
+        new Promise<T>((_, reject) => {
+          timeoutId = setTimeout(() => {
+            reject(new AcpSideQuestionFailedError('ACP /btw timed out.'));
+          }, timeoutMs);
+        }),
+      ]);
+    } finally {
+      if (timeoutId) {
+        clearTimeout(timeoutId);
+      }
+    }
+  }
+}
diff --git a/src/renderer/components/chat/BtwOverlay/BtwOverlay.module.css b/src/renderer/components/chat/BtwOverlay/BtwOverlay.module.css
new file mode 100644
index 0000000000..9f620a6c5b
--- /dev/null
+++ b/src/renderer/components/chat/BtwOverlay/BtwOverlay.module.css
@@ -0,0 +1,45 @@
+.portalRoot {
+  inset: 0;
+  position: fixed;
+  z-index: 9999;
+}
+
+.backdrop {
+  background: color-mix(in srgb, var(--color-mask-bg) 36%, transparent);
+  inset: 0;
+  position: absolute;
+}
+
+.panelWrap {
+  height: 100%;
+  inset: 0;
+  pointer-events: none;
+  position: relative;
+}
+
+.overlay {
+  backdrop-filter: blur(18px);
+  background: var(--color-bg-2);
+  border: 1px solid var(--color-border-2);
+  box-shadow: var(--shadow3-center);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  pointer-events: auto;
+  position: fixed;
+}
+
+.answer {
+  max-height: min(40vh, 360px);
+  overflow: auto;
+}
+
+.questionBubble {
+  background: rgb(var(--primary-6));
+  color: var(--color-white-to-black);
+}
+
+.answerBubble {
+  background: var(--color-fill-1);
+  border: 1px solid var(--color-border-2);
+}
diff --git a/src/renderer/components/chat/BtwOverlay/index.tsx b/src/renderer/components/chat/BtwOverlay/index.tsx
new file mode 100644
index 0000000000..ae31d1b23a
--- /dev/null
+++ b/src/renderer/components/chat/BtwOverlay/index.tsx
@@ -0,0 +1,174 @@
+import MarkdownView from '@/renderer/components/Markdown';
+import { Spin } from '@arco-design/web-react';
+import React, { useEffect, useState } from 'react';
+import ReactDOM from 'react-dom';
+import { useTranslation } from 'react-i18next';
+import styles from './BtwOverlay.module.css';
+
+type BtwOverlayProps = {
+  answer: string;
+  anchorEl?: HTMLElement | null;
+  isLoading: boolean;
+  isOpen: boolean;
+  onDismiss: () => void;
+  parentTaskRunning?: boolean;
+  question: string;
+};
+
+const DISMISS_KEYS = new Set(['Escape', 'Enter', ' ']);
+const DISMISS_BIND_DELAY_MS = 200;
+const VIEWPORT_MARGIN_PX = 16;
+const OVERLAY_GAP_PX = 8;
+const MIN_OVERLAY_WIDTH_PX = 320;
+const MAX_OVERLAY_WIDTH_PX = 760;
+const MIN_OVERLAY_HEIGHT_PX = 180;
+const CHROME_RESERVE_HEIGHT_PX = 168;
+
+const BtwOverlay: React.FC<BtwOverlayProps> = ({
+  answer,
+  anchorEl,
+  isLoading,
+  isOpen,
+  onDismiss,
+  parentTaskRunning = false,
+  question,
+}) => {
+  const { t } = useTranslation();
+  const [position, setPosition] = useState({
+    left: VIEWPORT_MARGIN_PX,
+    maxHeight: Math.max(MIN_OVERLAY_HEIGHT_PX, window.innerHeight - VIEWPORT_MARGIN_PX * 2),
+    top: VIEWPORT_MARGIN_PX,
+    width: Math.min(MAX_OVERLAY_WIDTH_PX, window.innerWidth - VIEWPORT_MARGIN_PX * 2),
+  });
+
+  useEffect(() => {
+    if (!isOpen) {
+      return;
+    }
+
+    const updatePosition = () => {
+      const viewportWidth = window.innerWidth;
+      const header = document.querySelector('.chat-layout-header');
+      const headerBottom = header ? header.getBoundingClientRect().bottom : 60;
+      const anchorRect = anchorEl?.getBoundingClientRect();
+
+      const width = Math.max(
+        MIN_OVERLAY_WIDTH_PX,
+        Math.min(
+          MAX_OVERLAY_WIDTH_PX,
+          anchorRect ? anchorRect.width : viewportWidth - VIEWPORT_MARGIN_PX * 2,
+          viewportWidth - VIEWPORT_MARGIN_PX * 2
+        )
+      );
+
+      let left: number;
+      if (header) {
+        const headerRect = header.getBoundingClientRect();
+        left = headerRect.left + Math.round((headerRect.width - width) / 2);
+      } else {
+        left = Math.round((viewportWidth - width) / 2);
+      }
+      left = Math.max(VIEWPORT_MARGIN_PX, Math.min(left, viewportWidth - width - VIEWPORT_MARGIN_PX));
+
+      const top = headerBottom + OVERLAY_GAP_PX;
+      const bottomBound = anchorRect ? anchorRect.top - OVERLAY_GAP_PX : window.innerHeight - VIEWPORT_MARGIN_PX;
+      const maxHeight = Math.max(MIN_OVERLAY_HEIGHT_PX, bottomBound - top);
+
+      setPosition({ left, maxHeight, top, width });
+    };
+
+    updatePosition();
+    window.addEventListener('resize', updatePosition);
+    window.addEventListener('scroll', updatePosition, true);
+    return () => {
+      window.removeEventListener('resize', updatePosition);
+      window.removeEventListener('scroll', updatePosition, true);
+    };
+  }, [anchorEl, isOpen]);
+
+  useEffect(() => {
+    if (!isOpen) {
+      return;
+    }
+
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (!DISMISS_KEYS.has(event.key)) {
+        return;
+      }
+      event.preventDefault();
+      onDismiss();
+    };
+
+    // Delay keyboard dismissal binding so the Enter press used to submit `/btw`
+    // cannot immediately close the overlay before the response is shown.
+    const bindTimer = window.setTimeout(() => {
+      window.addEventListener('keydown', onKeyDown);
+    }, DISMISS_BIND_DELAY_MS);
+
+    return () => {
+      window.clearTimeout(bindTimer);
+      window.removeEventListener('keydown', onKeyDown);
+    };
+  }, [isOpen, onDismiss]);
+
+  if (!isOpen) {
+    return null;
+  }
+
+  const answerMaxHeight = Math.max(120, position.maxHeight - CHROME_RESERVE_HEIGHT_PX);
+  const overlayStyle: React.CSSProperties = {
+    left: `${position.left}px`,
+    maxHeight: `${position.maxHeight}px`,
+    top: `${position.top}px`,
+    width: `${position.width}px`,
+  };
+
+  return ReactDOM.createPortal(
+    <div className={styles.portalRoot}>
+      <div className={styles.backdrop} onClick={onDismiss} />
+      <div className={styles.panelWrap}>
+        <div className={`rd-16px p-16px ${styles.overlay}`} style={overlayStyle}>
+          <div className='flex flex-col gap-12px'>
+            <div className='flex flex-col gap-4px'>
+              <div className='text-12px text-t-secondary uppercase tracking-[0.08em]'>
+                {t('conversation.sideQuestion.title')}
+              </div>
+              {parentTaskRunning && (
+                <div className='text-12px text-t-secondary'>{t('conversation.sideQuestion.parentTaskRunning')}</div>
+              )}
+            </div>
+
+            <div className='flex justify-end'>
+              <div
+                className={`${styles.questionBubble} max-w-[85%] rd-14px px-14px py-10px text-14px whitespace-pre-wrap break-words`}
+              >
+                {question}
+              </div>
+            </div>
+
+            <div className='flex justify-start'>
+              <div
+                className={`${styles.answerBubble} ${styles.answer} min-h-48px max-w-[85%] rd-14px px-14px py-10px text-14px text-t-primary`}
+                style={{ maxHeight: `${answerMaxHeight}px` }}
+              >
+                {isLoading ? (
+                  <div className='flex items-center gap-8px text-t-secondary'>
+                    <Spin size={16} />
+                    <span>{t('conversation.sideQuestion.loading')}</span>
+                  </div>
+                ) : (
+                  <MarkdownView className='text-14px'>{answer}</MarkdownView>
+                )}
+              </div>
+            </div>
+
+            <div className='text-12px text-t-secondary'>{t('conversation.sideQuestion.dismissHint')}</div>
+          </div>
+        </div>
+      </div>
+    </div>,
+    document.body
+  );
+};
+
+export default BtwOverlay;
diff --git a/src/renderer/components/chat/BtwOverlay/useBtwCommand.ts b/src/renderer/components/chat/BtwOverlay/useBtwCommand.ts
new file mode 100644
index 0000000000..68d400a859
--- /dev/null
+++ b/src/renderer/components/chat/BtwOverlay/useBtwCommand.ts
@@ -0,0 +1,127 @@
+import { ipcBridge } from '@/common';
+import { Message } from '@arco-design/web-react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+type BtwCommandState = {
+  answer: string;
+  isLoading: boolean;
+  isOpen: boolean;
+  question: string;
+};
+
+const INITIAL_STATE: BtwCommandState = {
+  answer: '',
+  isLoading: false,
+  isOpen: false,
+  question: '',
+};
+
+export function useBtwCommand(conversationId?: string, enabled = true) {
+  const { t } = useTranslation();
+  const requestIdRef = useRef(0);
+  const previousConversationIdRef = useRef(conversationId);
+  const previousEnabledRef = useRef(enabled);
+  const [state, setState] = useState<BtwCommandState>(INITIAL_STATE);
+
+  const dismiss = useCallback(() => {
+    requestIdRef.current += 1;
+    setState(INITIAL_STATE);
+  }, []);
+
+  useEffect(() => {
+    const conversationChanged = previousConversationIdRef.current !== conversationId;
+    const eligibilityDisabled = previousEnabledRef.current && !enabled;
+
+    previousConversationIdRef.current = conversationId;
+    previousEnabledRef.current = enabled;
+
+    if ((conversationChanged || eligibilityDisabled) && state.isOpen) {
+      requestIdRef.current += 1;
+      setState(INITIAL_STATE);
+    }
+  }, [conversationId, enabled, state.isOpen]);
+
+  const ask = useCallback(
+    async (question: string) => {
+      const requestId = ++requestIdRef.current;
+      Message.info(t('conversation.sideQuestion.started'));
+      setState({
+        answer: '',
+        isLoading: true,
+        isOpen: true,
+        question,
+      });
+
+      if (!conversationId) {
+        Message.warning(t('conversation.sideQuestion.unsupported'));
+        setState({
+          answer: t('conversation.sideQuestion.unsupported'),
+          isLoading: false,
+          isOpen: true,
+          question,
+        });
+        return;
+      }
+
+      try {
+        const response = await ipcBridge.conversation.askSideQuestion.invoke({
+          conversation_id: conversationId,
+          question,
+        });
+
+        if (requestId !== requestIdRef.current) {
+          return;
+        }
+
+        if (!response.success || !response.data) {
+          Message.error(t('conversation.sideQuestion.error'));
+          setState({
+            answer: t('conversation.sideQuestion.error'),
+            isLoading: false,
+            isOpen: true,
+            question,
+          });
+          return;
+        }
+
+        const statusMap: Record<string, { toast: typeof Message.info; key: string }> = {
+          ok: { toast: Message.success, key: 'answered' },
+          noAnswer: { toast: Message.success, key: 'noAnswer' },
+          unsupported: { toast: Message.warning, key: 'unsupported' },
+          toolsRequired: { toast: Message.info, key: 'toolsRequired' },
+          invalid: { toast: Message.warning, key: 'emptyQuestion' },
+        };
+
+        const entry = statusMap[response.data.status];
+        if (entry) {
+          const text =
+            response.data.status === 'ok'
+              ? response.data.answer
+              : t(`conversation.sideQuestion.${entry.key}` as Parameters<typeof t>[0]);
+          entry.toast(response.data.status === 'ok' ? t('conversation.sideQuestion.answered') : text);
+          setState({ answer: text, isLoading: false, isOpen: true, question });
+          return;
+        }
+      } catch {
+        if (requestId !== requestIdRef.current) {
+          return;
+        }
+        Message.error(t('conversation.sideQuestion.error'));
+        setState({
+          answer: t('conversation.sideQuestion.error'),
+          isLoading: false,
+          isOpen: true,
+          question,
+        });
+      }
+    },
+    [conversationId, t]
+  );
+
+  return {
+    ask,
+    dismiss,
+    ...state,
+  };
+}
diff --git a/src/renderer/components/chat/sendbox.tsx b/src/renderer/components/chat/sendbox.tsx
index 03d9948c47..df34f46294 100644
--- a/src/renderer/components/chat/sendbox.tsx
+++ b/src/renderer/components/chat/sendbox.tsx
@@ -5,8 +5,10 @@
  */
 
 import { ipcBridge } from '@/common';
+import BtwOverlay from '@/renderer/components/chat/BtwOverlay';
 import { useInputFocusRing } from '@/renderer/hooks/chat/useInputFocusRing';
 import SlashCommandMenu, { type SlashCommandMenuItem } from '@/renderer/components/chat/SlashCommandMenu';
+import { useBtwCommand } from '@/renderer/components/chat/BtwOverlay/useBtwCommand';
 import { useSlashCommandController } from '@/renderer/hooks/chat/useSlashCommandController';
 import { useLayoutContext } from '@/renderer/hooks/context/LayoutContext';
 import { useConversationContextSafe } from '@/renderer/hooks/context/ConversationContext';
@@ -32,6 +34,12 @@ const constVoid = (): void => undefined;
 // 临界值：超过该字符数直接切换至多行模式，避免为超长文本做昂贵的宽度测量
 // Threshold: switch to multi-line mode directly when character count exceeds this value to avoid heavy layout work
 const MAX_SINGLE_LINE_CHARACTERS = 800;
+const BTW_COMMAND_RE = /^\/btw(?:\s+([\s\S]*))?$/i;
+
+function extractBtwQuestion(value: string): string | null {
+  const match = value.trim().match(BTW_COMMAND_RE);
+  return match ? match[1] || '' : null;
+}
 
 const SendBox: React.FC<{
   value?: string;
@@ -51,6 +59,8 @@ const SendBox: React.FC<{
   sendButtonPrefix?: React.ReactNode;
   slashCommands?: SlashCommandItem[];
   onSlashBuiltinCommand?: (name: string) => void;
+  hasPendingAttachments?: boolean;
+  enableBtw?: boolean;
 }> = ({
   onSend,
   onStop,
@@ -69,6 +79,8 @@ const SendBox: React.FC<{
   sendButtonPrefix,
   slashCommands = [],
   onSlashBuiltinCommand,
+  hasPendingAttachments = false,
+  enableBtw = false,
 }) => {
   const layout = useLayoutContext();
   const isMobile = layout?.isMobile ?? false;
@@ -215,9 +227,21 @@ const SendBox: React.FC<{
     t,
     messageApi: message,
   });
+  const btwCommand = useBtwCommand(conversationContext?.conversationId, enableBtw);
+  const btwQuestion = useMemo(() => extractBtwQuestion(input), [input]);
+  const isBtwInput = enableBtw && btwQuestion !== null;
 
   const builtinSlashCommands = useMemo<SlashCommandItem[]>(() => {
     const commands: SlashCommandItem[] = [];
+    if (enableBtw) {
+      commands.push({
+        name: 'btw',
+        description: t('conversation.sideQuestion.description'),
+        kind: 'builtin',
+        source: 'builtin',
+        selectionBehavior: 'insert',
+      });
+    }
     if (onSlashBuiltinCommand) {
       commands.push({
         name: 'open',
@@ -235,7 +259,7 @@ const SendBox: React.FC<{
       });
     }
     return commands;
-  }, [conversationContext?.conversationId, onSlashBuiltinCommand, t]);
+  }, [conversationContext?.conversationId, enableBtw, onSlashBuiltinCommand, t]);
 
   const mergedSlashCommands = useMemo(() => {
     const map = new Map<string, SlashCommandItem>();
@@ -277,7 +301,8 @@ const SendBox: React.FC<{
     [slashController.filteredCommands]
   );
 
-  const isOverlayOpen = conversationExport.isOpen || slashController.isOpen;
+  const isCommandMenuOpen = conversationExport.isOpen || slashController.isOpen;
+  const isOverlayOpen = isCommandMenuOpen || btwCommand.isOpen;
 
   const handleTextAreaChange = (value: string) => {
     if (conversationExport.isOpen && value) {
@@ -417,6 +442,25 @@ const SendBox: React.FC<{
   }, []);
 
   const sendMessageHandler = () => {
+    if (enableBtw && btwQuestion !== null) {
+      const normalizedQuestion = btwQuestion.trim();
+      if (!normalizedQuestion) {
+        message.warning(t('conversation.sideQuestion.emptyQuestion'));
+        return;
+      }
+      if (btwCommand.isLoading) {
+        message.warning(t('conversation.sideQuestion.alreadyRunning'));
+        return;
+      }
+      if (hasPendingAttachments || domSnippets.length > 0) {
+        message.warning(t('conversation.sideQuestion.attachmentsNotAllowed'));
+        return;
+      }
+      setInput('');
+      void btwCommand.ask(normalizedQuestion);
+      return;
+    }
+
     if (loading || isLoading) {
       message.warning(t('messages.conversationInProgress'));
       return;
@@ -494,7 +538,16 @@ const SendBox: React.FC<{
         }}
         {...dragHandlers}
       >
-        {isOverlayOpen && (
+        <BtwOverlay
+          answer={btwCommand.answer}
+          anchorEl={containerRef.current}
+          isLoading={btwCommand.isLoading}
+          isOpen={btwCommand.isOpen}
+          onDismiss={btwCommand.dismiss}
+          parentTaskRunning={Boolean(loading || isLoading)}
+          question={btwCommand.question}
+        />
+        {isCommandMenuOpen && (
           <div className='absolute left-12px right-12px bottom-[calc(100%+8px)] z-70'>
             {conversationExport.step === 'menu' ? (
               <SlashCommandMenu
@@ -597,7 +650,7 @@ const SendBox: React.FC<{
           {isSingleLine && (
             <div className='flex items-center gap-2'>
               {sendButtonPrefix}
-              {isLoading || loading ? (
+              {isLoading || (loading && !isBtwInput) ? (
                 <Button
                   shape='circle'
                   type='secondary'
@@ -616,7 +669,7 @@ const SendBox: React.FC<{
             <div className={isMobile ? 'sendbox-tools sendbox-tools-scroll-mobile' : 'sendbox-tools'}>{tools}</div>
             <div className='flex items-center gap-2'>
               {sendButtonPrefix}
-              {isLoading || loading ? (
+              {isLoading || (loading && !isBtwInput) ? (
                 <Button
                   shape='circle'
                   type='secondary'
diff --git a/src/renderer/hooks/chat/useSlashCommandController.ts b/src/renderer/hooks/chat/useSlashCommandController.ts
index 33e058e5e4..0eea98a295 100644
--- a/src/renderer/hooks/chat/useSlashCommandController.ts
+++ b/src/renderer/hooks/chat/useSlashCommandController.ts
@@ -38,6 +38,13 @@ export function getScrollTopForActiveItem(input: ActiveItemScrollInput): number
   return containerScrollTop;
 }
 
+function getSelectionBehavior(command: SlashCommandItem): 'execute' | 'insert' {
+  if (command.selectionBehavior) {
+    return command.selectionBehavior;
+  }
+  return command.kind === 'builtin' ? 'execute' : 'insert';
+}
+
 interface UseSlashCommandControllerOptions {
   input: string;
   commands: SlashCommandItem[];
@@ -78,7 +85,9 @@ export function useSlashCommandController(options: UseSlashCommandControllerOpti
       if (!command) {
         return false;
       }
-      if (command.kind === 'builtin') {
+      if (getSelectionBehavior(command) === 'insert') {
+        onSelectTemplate?.(command.name);
+      } else if (command.kind === 'builtin') {
         onExecuteBuiltin?.(command.name);
       } else {
         onSelectTemplate?.(command.name);
diff --git a/src/renderer/pages/conversation/platforms/acp/AcpSendBox.tsx b/src/renderer/pages/conversation/platforms/acp/AcpSendBox.tsx
index d0c7b9fbf2..77ba508e99 100644
--- a/src/renderer/pages/conversation/platforms/acp/AcpSendBox.tsx
+++ b/src/renderer/pages/conversation/platforms/acp/AcpSendBox.tsx
@@ -1,6 +1,7 @@
 import { ipcBridge } from '@/common';
 import type { AcpBackend } from '@/common/types/acpTypes';
 import type { TMessage } from '@/common/chat/chatLib';
+import { isSideQuestionSupported } from '@/common/chat/sideQuestion';
 import { uuid } from '@/common/utils';
 import SendBox from '@/renderer/components/chat/sendbox';
 import ThoughtDisplay from '@/renderer/components/chat/ThoughtDisplay';
@@ -239,6 +240,8 @@ const AcpSendBox: React.FC<{
         onStop={handleStop}
         className='z-10'
         onFilesAdded={handleFilesAdded}
+        hasPendingAttachments={uploadFile.length > 0 || atPath.length > 0}
+        enableBtw={isSideQuestionSupported({ type: 'acp', backend })}
         supportedExts={allSupportedExts}
         defaultMultiLine={true}
         lockMultiLine={true}
diff --git a/src/renderer/pages/conversation/platforms/codex/CodexSendBox.tsx b/src/renderer/pages/conversation/platforms/codex/CodexSendBox.tsx
index 8eb60f1e6e..f7c2dd878b 100644
--- a/src/renderer/pages/conversation/platforms/codex/CodexSendBox.tsx
+++ b/src/renderer/pages/conversation/platforms/codex/CodexSendBox.tsx
@@ -444,6 +444,7 @@ const CodexSendBox: React.FC<{ conversation_id: string }> = ({ conversation_id }
         }
         onStop={handleStop}
         onFilesAdded={handleFilesAdded}
+        hasPendingAttachments={uploadFile.length > 0 || atPath.length > 0}
         supportedExts={allSupportedExts}
         defaultMultiLine={true}
         lockMultiLine={true}
diff --git a/src/renderer/pages/conversation/platforms/gemini/GeminiSendBox.tsx b/src/renderer/pages/conversation/platforms/gemini/GeminiSendBox.tsx
index 873a995ae9..6f5cef9f01 100644
--- a/src/renderer/pages/conversation/platforms/gemini/GeminiSendBox.tsx
+++ b/src/renderer/pages/conversation/platforms/gemini/GeminiSendBox.tsx
@@ -311,6 +311,7 @@ const GeminiSendBox: React.FC<{
         onStop={handleStop}
         className='z-10'
         onFilesAdded={handleFilesAdded}
+        hasPendingAttachments={uploadFile.length > 0 || atPath.length > 0}
         supportedExts={allSupportedExts}
         defaultMultiLine={true}
         lockMultiLine={true}
diff --git a/src/renderer/pages/conversation/platforms/nanobot/NanobotSendBox.tsx b/src/renderer/pages/conversation/platforms/nanobot/NanobotSendBox.tsx
index 03bfc473a1..dabd9e2d5a 100644
--- a/src/renderer/pages/conversation/platforms/nanobot/NanobotSendBox.tsx
+++ b/src/renderer/pages/conversation/platforms/nanobot/NanobotSendBox.tsx
@@ -344,6 +344,7 @@ const NanobotSendBox: React.FC<{ conversation_id: string }> = ({ conversation_id
         }
         onStop={handleStop}
         onFilesAdded={handleFilesAdded}
+        hasPendingAttachments={uploadFile.length > 0 || atPath.length > 0}
         supportedExts={allSupportedExts}
         tools={<FileAttachButton openFileSelector={openFileSelector} onLocalFilesAdded={handleFilesAdded} />}
         prefix={
diff --git a/src/renderer/pages/conversation/platforms/openclaw/OpenClawSendBox.tsx b/src/renderer/pages/conversation/platforms/openclaw/OpenClawSendBox.tsx
index 967ddffcf8..a40fe9d66b 100644
--- a/src/renderer/pages/conversation/platforms/openclaw/OpenClawSendBox.tsx
+++ b/src/renderer/pages/conversation/platforms/openclaw/OpenClawSendBox.tsx
@@ -550,6 +550,7 @@ const OpenClawSendBox: React.FC<{ conversation_id: string }> = ({ conversation_i
         }
         onStop={handleStop}
         onFilesAdded={handleFilesAdded}
+        hasPendingAttachments={uploadFile.length > 0 || atPath.length > 0}
         supportedExts={allSupportedExts}
         defaultMultiLine={true}
         lockMultiLine={true}
diff --git a/src/renderer/services/i18n/i18n-keys.d.ts b/src/renderer/services/i18n/i18n-keys.d.ts
index 4383bb4920..bf5a7e846a 100644
--- a/src/renderer/services/i18n/i18n-keys.d.ts
+++ b/src/renderer/services/i18n/i18n-keys.d.ts
@@ -302,6 +302,20 @@ export type I18nKey =
   | 'conversation.minimap.searchHint'
   | 'conversation.noAgentsAvailable'
   | 'conversation.noModelConfigured'
+  | 'conversation.sideQuestion.alreadyRunning'
+  | 'conversation.sideQuestion.answered'
+  | 'conversation.sideQuestion.attachmentsNotAllowed'
+  | 'conversation.sideQuestion.description'
+  | 'conversation.sideQuestion.dismissHint'
+  | 'conversation.sideQuestion.emptyQuestion'
+  | 'conversation.sideQuestion.error'
+  | 'conversation.sideQuestion.loading'
+  | 'conversation.sideQuestion.noAnswer'
+  | 'conversation.sideQuestion.parentTaskRunning'
+  | 'conversation.sideQuestion.started'
+  | 'conversation.sideQuestion.title'
+  | 'conversation.sideQuestion.toolsRequired'
+  | 'conversation.sideQuestion.unsupported'
   | 'conversation.skill_generator.description'
   | 'conversation.skill_generator.failed'
   | 'conversation.skill_generator.generate'
diff --git a/src/renderer/services/i18n/locales/en-US/conversation.json b/src/renderer/services/i18n/locales/en-US/conversation.json
index ca82ae062b..db60134ce1 100644
--- a/src/renderer/services/i18n/locales/en-US/conversation.json
+++ b/src/renderer/services/i18n/locales/en-US/conversation.json
@@ -183,6 +183,22 @@
     "switchedToAgent": "Switched to {{agent}}",
     "switchAgentFailed": "Failed to switch agent"
   },
+  "sideQuestion": {
+    "title": "Side Question",
+    "description": "Ask a quick question about the current conversation",
+    "started": "Starting side question...",
+    "answered": "Side question answered.",
+    "noAnswer": "The side question did not have a clear answer in the current conversation.",
+    "loading": "Thinking...",
+    "parentTaskRunning": "Main task still running in the background.",
+    "dismissHint": "Press Escape, Enter, or Space to dismiss",
+    "unsupported": "Side questions are not available for this conversation.",
+    "alreadyRunning": "A side question is already running.",
+    "attachmentsNotAllowed": "Side questions cannot include files or DOM snippets.",
+    "emptyQuestion": "Please enter a side question after /btw.",
+    "toolsRequired": "/btw can only answer questions based on what's already in this conversation. It doesn't have access to search, browse, or fetch new information.",
+    "error": "Failed to answer the side question."
+  },
   "contextUsage": {
     "contextUsed": "context used"
   },
diff --git a/src/renderer/services/i18n/locales/ja-JP/conversation.json b/src/renderer/services/i18n/locales/ja-JP/conversation.json
index 058c38cddd..3b92ede1d1 100644
--- a/src/renderer/services/i18n/locales/ja-JP/conversation.json
+++ b/src/renderer/services/i18n/locales/ja-JP/conversation.json
@@ -183,6 +183,22 @@
     "switchedToAgent": "{{agent}}に切り替えました",
     "switchAgentFailed": "エージェントの切り替えに失敗しました"
   },
+  "sideQuestion": {
+    "title": "ちょっと質問",
+    "description": "現在の会話について短い補足質問をする",
+    "started": "補足質問を開始しています...",
+    "answered": "補足質問に回答しました。",
+    "noAnswer": "現在の会話コンテキストには明確な答えがありません。",
+    "loading": "考え中...",
+    "parentTaskRunning": "メインのタスクはバックグラウンドで実行中です。",
+    "dismissHint": "Escape、Enter、Space で閉じる",
+    "unsupported": "この会話では補足質問を利用できません。",
+    "alreadyRunning": "補足質問はすでに実行中です。",
+    "attachmentsNotAllowed": "補足質問にはファイルや DOM スニペットを含められません。",
+    "emptyQuestion": "/btw の後に質問を入力してください。",
+    "toolsRequired": "/btw はこの会話にすでにある内容に基づいてのみ回答できます。検索や閲覧、新しい情報の取得はできません。",
+    "error": "補足質問への回答に失敗しました。"
+  },
   "contextUsage": {
     "contextUsed": "コンテキスト使用量"
   },
diff --git a/src/renderer/services/i18n/locales/ko-KR/conversation.json b/src/renderer/services/i18n/locales/ko-KR/conversation.json
index a74435aa52..9b6a4be192 100644
--- a/src/renderer/services/i18n/locales/ko-KR/conversation.json
+++ b/src/renderer/services/i18n/locales/ko-KR/conversation.json
@@ -183,6 +183,22 @@
     "switchedToAgent": "{{agent}}(으)로 전환되었습니다",
     "switchAgentFailed": "에이전트 전환 실패"
   },
+  "sideQuestion": {
+    "title": "잠깐 질문",
+    "description": "현재 대화에 대해 짧은 보조 질문을 합니다",
+    "started": "보조 질문을 시작하는 중...",
+    "answered": "보조 질문에 답변했습니다.",
+    "noAnswer": "현재 대화 맥락에는 분명한 답이 없습니다.",
+    "loading": "생각 중...",
+    "parentTaskRunning": "메인 작업은 백그라운드에서 계속 실행 중입니다.",
+    "dismissHint": "Escape, Enter, Space로 닫기",
+    "unsupported": "이 대화에서는 보조 질문을 사용할 수 없습니다.",
+    "alreadyRunning": "보조 질문이 이미 실행 중입니다.",
+    "attachmentsNotAllowed": "보조 질문에는 파일이나 DOM 스니펫을 첨부할 수 없습니다.",
+    "emptyQuestion": "/btw 뒤에 질문을 입력해 주세요.",
+    "toolsRequired": "/btw는 이 대화에 이미 있는 내용만으로 답변할 수 있습니다. 검색, 탐색 또는 새로운 정보를 가져올 수 없습니다.",
+    "error": "보조 질문에 답하지 못했습니다."
+  },
   "contextUsage": {
     "contextUsed": "사용된 컨텍스트"
   },
diff --git a/src/renderer/services/i18n/locales/tr-TR/conversation.json b/src/renderer/services/i18n/locales/tr-TR/conversation.json
index 9f4c0d418e..706548f53d 100644
--- a/src/renderer/services/i18n/locales/tr-TR/conversation.json
+++ b/src/renderer/services/i18n/locales/tr-TR/conversation.json
@@ -178,6 +178,22 @@
     "switchedToAgent": "Switched to {{agent}}",
     "switchAgentFailed": "Failed to switch agent"
   },
+  "sideQuestion": {
+    "title": "Kısa Soru",
+    "description": "Mevcut konuşma hakkında hızlı bir yan soru sor",
+    "started": "Yan soru başlatılıyor...",
+    "answered": "Yan soru yanıtlandı.",
+    "noAnswer": "Geçerli konuşma bağlamında net bir yanıt yok.",
+    "loading": "Düşünüyor...",
+    "parentTaskRunning": "Ana görev arka planda çalışmaya devam ediyor.",
+    "dismissHint": "Kapatmak için Escape, Enter veya Space tuşuna basın",
+    "unsupported": "Bu konuşma için yan sorular kullanılamıyor.",
+    "alreadyRunning": "Zaten çalışan bir yan soru var.",
+    "attachmentsNotAllowed": "Yan sorular dosya veya DOM parçacıkları içeremez.",
+    "emptyQuestion": "/btw sonrasına bir soru yazın.",
+    "toolsRequired": "/btw yalnızca bu konuşmada zaten mevcut olan bilgilere dayanarak yanıt verebilir. Arama yapma, göz atma veya yeni bilgi getirme erişimi yoktur.",
+    "error": "Yan soru yanıtlanamadı."
+  },
   "contextUsage": {
     "contextUsed": "bağlam kullanıldı"
   },
diff --git a/src/renderer/services/i18n/locales/zh-CN/conversation.json b/src/renderer/services/i18n/locales/zh-CN/conversation.json
index 4f6f6189d8..d387136027 100644
--- a/src/renderer/services/i18n/locales/zh-CN/conversation.json
+++ b/src/renderer/services/i18n/locales/zh-CN/conversation.json
@@ -183,6 +183,22 @@
     "switchedToAgent": "已切换到 {{agent}}",
     "switchAgentFailed": "切换 Agent 失败"
   },
+  "sideQuestion": {
+    "title": "顺便问一下",
+    "description": "针对当前对话快速提一个小问题",
+    "started": "正在发起顺便提问...",
+    "answered": "顺便提问已返回结果。",
+    "noAnswer": "当前会话里没有明确的答案。",
+    "loading": "思考中...",
+    "parentTaskRunning": "主任务仍在后台继续运行。",
+    "dismissHint": "按 Escape、Enter 或 Space 关闭",
+    "unsupported": "当前会话暂不支持顺便提问。",
+    "alreadyRunning": "已有一个顺便提问正在进行中。",
+    "attachmentsNotAllowed": "顺便提问不能附带文件或 DOM 片段。",
+    "emptyQuestion": "请在 /btw 后输入问题。",
+    "toolsRequired": "/btw 只能根据当前对话中已有的内容作答，无法搜索、浏览或获取新信息。",
+    "error": "顺便提问失败。"
+  },
   "contextUsage": {
     "contextUsed": "上下文已使用"
   },
diff --git a/src/renderer/services/i18n/locales/zh-TW/conversation.json b/src/renderer/services/i18n/locales/zh-TW/conversation.json
index 8c96f73bfe..7df9c6f45f 100644
--- a/src/renderer/services/i18n/locales/zh-TW/conversation.json
+++ b/src/renderer/services/i18n/locales/zh-TW/conversation.json
@@ -183,6 +183,22 @@
     "switchedToAgent": "已切換到 {{agent}}",
     "switchAgentFailed": "切換 Agent 失敗"
   },
+  "sideQuestion": {
+    "title": "順便問一下",
+    "description": "針對目前對話快速提出一個小問題",
+    "started": "正在發起順便提問...",
+    "answered": "順便提問已返回結果。",
+    "noAnswer": "目前的會話內容裡沒有明確答案。",
+    "loading": "思考中...",
+    "parentTaskRunning": "主任務仍在背景持續執行。",
+    "dismissHint": "按 Escape、Enter 或 Space 關閉",
+    "unsupported": "目前會話暫不支援順便提問。",
+    "alreadyRunning": "已有一個順便提問正在進行中。",
+    "attachmentsNotAllowed": "順便提問不能附帶檔案或 DOM 片段。",
+    "emptyQuestion": "請在 /btw 後輸入問題。",
+    "toolsRequired": "/btw 只能根據目前對話中已有的內容作答，無法搜尋、瀏覽或取得新資訊。",
+    "error": "順便提問失敗。"
+  },
   "contextUsage": {
     "contextUsed": "上下文已使用"
   },


Test patch:
diff --git a/tests/unit/btwOverlay.dom.test.tsx b/tests/unit/btwOverlay.dom.test.tsx
new file mode 100644
index 0000000000..7b76102c75
--- /dev/null
+++ b/tests/unit/btwOverlay.dom.test.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/renderer/components/Markdown', () => ({
+  __esModule: true,
+  default: ({ children }: { children: React.ReactNode }) => React.createElement('div', {}, children),
+}));
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string) => key,
+  }),
+}));
+
+vi.mock('@arco-design/web-react', () => ({
+  Spin: () => React.createElement('div', {}, 'Spin'),
+}));
+
+import BtwOverlay from '@/renderer/components/chat/BtwOverlay';
+
+describe('BtwOverlay keyboard dismissal', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+  });
+
+  afterEach(() => {
+    vi.runOnlyPendingTimers();
+    vi.useRealTimers();
+  });
+
+  it('does not dismiss immediately on the submit keypress timing window', () => {
+    const onDismiss = vi.fn();
+
+    render(
+      <BtwOverlay answer='' isLoading isOpen onDismiss={onDismiss} parentTaskRunning question='what file did we use?' />
+    );
+
+    fireEvent.keyDown(window, { key: 'Enter' });
+    expect(onDismiss).not.toHaveBeenCalled();
+
+    vi.advanceTimersByTime(200);
+    fireEvent.keyDown(window, { key: 'Enter' });
+    expect(onDismiss).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/tests/unit/conversationBridge.test.ts b/tests/unit/conversationBridge.test.ts
index 637fbe07ee..c87a46f5b7 100644
--- a/tests/unit/conversationBridge.test.ts
+++ b/tests/unit/conversationBridge.test.ts
@@ -3,10 +3,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
 vi.mock('electron', () => ({ app: { isPackaged: false, getPath: vi.fn(() => '/tmp') } }));
 
 // Capture provider handlers so tests can invoke them directly
-const handlers: Record<string, (...args: any[]) => any> = {};
+type ProviderHandler = (...args: unknown[]) => unknown;
+
+const handlers: Record<string, ProviderHandler> = {};
 function makeChannel(name: string) {
   return {
-    provider: vi.fn((fn: (...args: any[]) => any) => {
+    provider: vi.fn((fn: ProviderHandler) => {
       handlers[name] = fn;
     }),
     emit: vi.fn(),
@@ -27,6 +29,7 @@ vi.mock('../../src/common', () => ({
       stop: makeChannel('stop'),
       sendMessage: makeChannel('sendMessage'),
       getSlashCommands: makeChannel('getSlashCommands'),
+      askSideQuestion: makeChannel('askSideQuestion'),
       reloadContext: makeChannel('reloadContext'),
       getWorkspace: makeChannel('getWorkspace'),
       responseSearchWorkSpace: makeChannel('responseSearchWorkSpace'),
@@ -49,6 +52,7 @@ vi.mock('../../src/common', () => ({
 vi.mock('../../src/process/utils/initStorage', () => ({
   ProcessChat: { get: vi.fn(async () => []) },
   getSkillsDir: vi.fn(() => '/skills'),
+  ProcessConfig: { get: vi.fn(async () => []) },
 }));
 
 vi.mock('../../src/process/bridge/migrationUtils', () => ({
@@ -256,8 +260,10 @@ describe('conversationBridge', () => {
 
     it('calls initAgent() when task type is "acp"', async () => {
       const initAgent = vi.fn();
-      const acpTask = { type: 'acp', initAgent };
-      vi.mocked(taskManager.getOrBuildTask).mockResolvedValue(acpTask as any);
+      const acpTask = { type: 'acp', initAgent } as unknown as Awaited<
+        ReturnType<IWorkerTaskManager['getOrBuildTask']>
+      >;
+      vi.mocked(taskManager.getOrBuildTask).mockResolvedValue(acpTask);
 
       const handler = handlers['warmup'];
       await handler({ conversation_id: 'acp-id' });
@@ -268,8 +274,11 @@ describe('conversationBridge', () => {
 
     it('does not call initAgent when task type is not "acp"', async () => {
       const initAgent = vi.fn();
-      const geminiTask = { type: 'gemini', initAgent };
-      vi.mocked(taskManager.getOrBuildTask).mockResolvedValue(geminiTask as any);
+      const geminiTask = {
+        type: 'gemini',
+        initAgent,
+      } as unknown as Awaited<ReturnType<IWorkerTaskManager['getOrBuildTask']>>;
+      vi.mocked(taskManager.getOrBuildTask).mockResolvedValue(geminiTask);
 
       const handler = handlers['warmup'];
       await handler({ conversation_id: 'gemini-id' });
diff --git a/tests/unit/conversationBridge.tray.test.ts b/tests/unit/conversationBridge.tray.test.ts
index 83902db119..374ab1273b 100644
--- a/tests/unit/conversationBridge.tray.test.ts
+++ b/tests/unit/conversationBridge.tray.test.ts
@@ -5,6 +5,8 @@
  */
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { IConversationService } from '@/process/services/IConversationService';
+import type { IWorkerTaskManager } from '@/process/task/IWorkerTaskManager';
 
 type Provider = (payload?: unknown) => Promise<unknown>;
 
@@ -39,7 +41,7 @@ const mockWorkerTaskManager = {
 
 const registerMocks = () => {
   vi.doMock('@/agent/gemini', () => ({
-    GeminiAgent: class {},
+    GeminiAgent: vi.fn(),
     GeminiApprovalStore: { getInstance: vi.fn(() => ({})) },
   }));
 
@@ -67,6 +69,7 @@ const registerMocks = () => {
         responseSearchWorkSpace: { invoke: vi.fn() },
         stop: createCommand('conversation.stop'),
         getSlashCommands: createCommand('conversation.getSlashCommands'),
+        askSideQuestion: createCommand('conversation.askSideQuestion'),
         sendMessage: createCommand('conversation.sendMessage'),
         warmup: createCommand('conversation.warmup'),
         responseStream: { emit: vi.fn() },
@@ -85,6 +88,7 @@ const registerMocks = () => {
   vi.doMock('@process/utils/initStorage', () => ({
     getSkillsDir: vi.fn(() => '/mock/skills'),
     ProcessChat: { get: vi.fn(async () => []) },
+    ProcessConfig: { get: vi.fn(async () => []) },
   }));
 
   vi.doMock('@/process/task/agentUtils', () => ({
@@ -111,7 +115,10 @@ const registerMocks = () => {
 
 const getProvider = async (key: string): Promise<Provider> => {
   const mod = await import('@process/bridge/conversationBridge');
-  mod.initConversationBridge(mockConversationService as any, mockWorkerTaskManager as any);
+  mod.initConversationBridge(
+    mockConversationService as unknown as IConversationService,
+    mockWorkerTaskManager as unknown as IWorkerTaskManager
+  );
 
   const provider = handlers[key];
   if (!provider) {
diff --git a/tests/unit/process/bridge/conversationSideQuestionService.test.ts b/tests/unit/process/bridge/conversationSideQuestionService.test.ts
new file mode 100644
index 0000000000..f418fac1b3
--- /dev/null
+++ b/tests/unit/process/bridge/conversationSideQuestionService.test.ts
@@ -0,0 +1,301 @@
+import type { AcpPermissionRequest, AcpSessionUpdate } from '@/common/types/acpTypes';
+import type { TChatConversation } from '@/common/config/storage';
+import { ConversationSideQuestionService } from '@/process/bridge/services/ConversationSideQuestionService';
+import type { IConversationService } from '@/process/services/IConversationService';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+type DisconnectInfo = {
+  code: number | null;
+  signal: NodeJS.Signals | null;
+};
+
+type MockAcpConnectionInstance = {
+  onEndTurn: () => void;
+  onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ optionId: string }>;
+  onSessionUpdate: (data: AcpSessionUpdate) => void;
+};
+
+const {
+  mockAcpConnect,
+  mockAcpCancelPrompt,
+  mockAcpDisconnect,
+  mockAcpNewSession,
+  mockAcpSendPrompt,
+  mockAcpSetPromptTimeout,
+  mockProcessConfigGet,
+} = vi.hoisted(() => ({
+  mockAcpConnect: vi.fn(),
+  mockAcpCancelPrompt: vi.fn(),
+  mockAcpDisconnect: vi.fn(),
+  mockAcpNewSession: vi.fn(),
+  mockAcpSendPrompt: vi.fn(),
+  mockAcpSetPromptTimeout: vi.fn(),
+  mockProcessConfigGet: vi.fn(),
+}));
+
+vi.mock('@process/utils/initStorage', () => ({
+  ProcessConfig: {
+    get: (...args: unknown[]) => mockProcessConfigGet(...args),
+  },
+}));
+
+vi.mock('@process/agent/acp/AcpConnection', () => ({
+  AcpConnection: class {
+    onSessionUpdate: (data: AcpSessionUpdate) => void = () => {};
+    onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ optionId: string }> = async () => ({
+      optionId: 'reject_once',
+    });
+    onEndTurn: () => void = () => {};
+    onDisconnect: (error: DisconnectInfo) => void = () => {};
+
+    connect = (...args: unknown[]) => mockAcpConnect(...args);
+    newSession = (...args: unknown[]) => mockAcpNewSession(...args);
+    sendPrompt = (...args: unknown[]) => mockAcpSendPrompt(this, ...args);
+    disconnect = (...args: unknown[]) => mockAcpDisconnect(...args);
+    setPromptTimeout = (...args: unknown[]) => mockAcpSetPromptTimeout(...args);
+    cancelPrompt = (...args: unknown[]) => mockAcpCancelPrompt(...args);
+  },
+}));
+
+function makeConversation(overrides: Partial<TChatConversation> = {}): TChatConversation {
+  return {
+    id: 'conv-1',
+    name: 'Conversation',
+    type: 'gemini',
+    extra: { workspace: '/tmp/ws' },
+    model: {
+      id: 'provider-1',
+      platform: 'gemini',
+      name: 'Gemini',
+      baseUrl: 'https://example.com',
+      apiKey: 'secret',
+      useModel: 'gemini-2.5-flash',
+    },
+    createTime: Date.now(),
+    modifyTime: Date.now(),
+    ...overrides,
+  } as TChatConversation;
+}
+
+function makeService(conversation: TChatConversation | undefined): IConversationService {
+  return {
+    createConversation: vi.fn(),
+    deleteConversation: vi.fn(),
+    updateConversation: vi.fn(),
+    getConversation: vi.fn(async () => conversation),
+    createWithMigration: vi.fn(),
+    listAllConversations: vi.fn(async () => []),
+  };
+}
+
+function makeClaudeConversation(): TChatConversation {
+  return makeConversation({
+    type: 'acp',
+    extra: {
+      acpSessionId: 'parent-session-1',
+      backend: 'claude',
+      workspace: '/tmp/ws',
+    },
+  });
+}
+
+function createTextChunkUpdate(text: string): AcpSessionUpdate {
+  return {
+    sessionId: 'fork-1',
+    update: {
+      sessionUpdate: 'agent_message_chunk',
+      content: {
+        type: 'text',
+        text,
+      },
+    },
+  };
+}
+
+function createPermissionRequest(): AcpPermissionRequest {
+  return {
+    options: [{ kind: 'reject_once', name: 'Reject', optionId: 'reject_once' }],
+    sessionId: 'fork-1',
+    toolCall: {
+      title: 'Bash',
+      toolCallId: 'tool-1',
+    },
+  };
+}
+
+function createToolCallUpdate(): AcpSessionUpdate {
+  return {
+    sessionId: 'fork-1',
+    update: {
+      kind: 'execute',
+      sessionUpdate: 'tool_call',
+      status: 'pending',
+      title: 'Bash',
+      toolCallId: 'tool-1',
+    },
+  };
+}
+
+describe('ConversationSideQuestionService', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockAcpConnect.mockResolvedValue(undefined);
+    mockAcpCancelPrompt.mockReset();
+    mockAcpNewSession.mockResolvedValue({ sessionId: 'fork-1' });
+    mockAcpSendPrompt.mockImplementation(async (connection: MockAcpConnectionInstance) => {
+      connection.onSessionUpdate(createTextChunkUpdate('The file was `config/aion.json`.'));
+      connection.onEndTurn();
+      return {};
+    });
+    mockAcpDisconnect.mockResolvedValue(undefined);
+    mockAcpSetPromptTimeout.mockReturnValue(undefined);
+    mockProcessConfigGet.mockImplementation(async (key: string) => {
+      if (key === 'acp.config') {
+        return {
+          claude: {
+            cliPath: 'claude',
+          },
+        };
+      }
+      return undefined;
+    });
+  });
+
+  it('returns invalid for an empty question', async () => {
+    const service = new ConversationSideQuestionService(makeService(undefined));
+
+    await expect(service.ask('conv-1', '   ')).resolves.toEqual({
+      status: 'invalid',
+      reason: 'emptyQuestion',
+    });
+  });
+
+  it('returns unsupported when the conversation is not claude ACP', async () => {
+    const conversation = {
+      id: 'conv-1',
+      type: 'acp',
+      name: 'ACP Conversation',
+      extra: { backend: 'opencode' },
+      createTime: Date.now(),
+      modifyTime: Date.now(),
+    } as TChatConversation;
+    const service = new ConversationSideQuestionService(makeService(conversation));
+
+    await expect(service.ask('conv-1', 'what model are we using?')).resolves.toEqual({
+      status: 'unsupported',
+    });
+  });
+
+  it('returns unsupported for non-claude ACP conversations even with session metadata', async () => {
+    const conversation = makeConversation({
+      type: 'acp',
+      extra: {
+        acpSessionId: 'parent-session-1',
+        backend: 'opencode',
+        workspace: '/tmp/ws',
+      },
+    });
+    const service = new ConversationSideQuestionService(makeService(conversation));
+
+    await expect(service.ask('conv-1', 'what file did we use?')).resolves.toEqual({
+      status: 'unsupported',
+    });
+    expect(mockAcpConnect).not.toHaveBeenCalled();
+  });
+
+  it('uses an ACP forked session when claude ACP session metadata is available', async () => {
+    const service = new ConversationSideQuestionService(makeService(makeClaudeConversation()));
+
+    await expect(service.ask('conv-1', 'what file did we use?')).resolves.toEqual({
+      status: 'ok',
+      answer: 'The file was `config/aion.json`.',
+    });
+
+    expect(mockAcpConnect).toHaveBeenCalledWith('claude', 'claude', '/tmp/ws', undefined, undefined);
+    expect(mockAcpNewSession).toHaveBeenCalledWith('/tmp/ws', {
+      forkSession: true,
+      mcpServers: [],
+      resumeSessionId: 'parent-session-1',
+    });
+  });
+
+  it('returns noAnswer when the claude fork ends without text', async () => {
+    const service = new ConversationSideQuestionService(makeService(makeClaudeConversation()));
+
+    mockAcpSendPrompt.mockImplementationOnce(async (connection: MockAcpConnectionInstance) => {
+      connection.onEndTurn();
+      return {};
+    });
+
+    await expect(service.ask('conv-1', 'what file did we use?')).resolves.toEqual({
+      status: 'noAnswer',
+    });
+  });
+
+  it('returns unsupported when the ACP backend rejects forked sessions', async () => {
+    const service = new ConversationSideQuestionService(makeService(makeClaudeConversation()));
+    mockAcpNewSession.mockRejectedValueOnce(new Error('fork not supported'));
+
+    await expect(service.ask('conv-1', 'what file did we use?')).resolves.toEqual({
+      status: 'unsupported',
+    });
+  });
+
+  it('rejects when the ACP side question times out', async () => {
+    vi.useFakeTimers();
+    const service = new ConversationSideQuestionService(makeService(makeClaudeConversation()));
+    mockAcpSendPrompt.mockImplementationOnce(() => new Promise(() => {}));
+
+    const promise = service.ask('conv-1', 'what file did we use?');
+    const expectation = expect(promise).rejects.toThrow('ACP /btw timed out.');
+    await vi.advanceTimersByTimeAsync(30_000);
+
+    await expectation;
+    expect(mockAcpDisconnect).toHaveBeenCalled();
+    vi.useRealTimers();
+  });
+
+  it('returns toolsRequired when permission request is triggered without prior text', async () => {
+    const service = new ConversationSideQuestionService(makeService(makeClaudeConversation()));
+
+    mockAcpSendPrompt.mockImplementationOnce(async (connection: MockAcpConnectionInstance) => {
+      await connection.onPermissionRequest(createPermissionRequest());
+      return {};
+    });
+
+    await expect(service.ask('conv-1', 'what file did we use?')).resolves.toEqual({
+      status: 'toolsRequired',
+    });
+    expect(mockAcpCancelPrompt).toHaveBeenCalled();
+  });
+
+  it('returns toolsRequired when tool call is attempted without prior text', async () => {
+    const service = new ConversationSideQuestionService(makeService(makeClaudeConversation()));
+
+    mockAcpSendPrompt.mockImplementationOnce(async (connection: MockAcpConnectionInstance) => {
+      connection.onSessionUpdate(createToolCallUpdate());
+      return {};
+    });
+
+    await expect(service.ask('conv-1', 'what file did we use?')).resolves.toEqual({
+      status: 'toolsRequired',
+    });
+    expect(mockAcpCancelPrompt).toHaveBeenCalled();
+  });
+
+  it('returns ok with partial text when tool call is attempted after text', async () => {
+    const service = new ConversationSideQuestionService(makeService(makeClaudeConversation()));
+
+    mockAcpSendPrompt.mockImplementationOnce(async (connection: MockAcpConnectionInstance) => {
+      connection.onSessionUpdate(createTextChunkUpdate('Here is what I found'));
+      connection.onSessionUpdate(createToolCallUpdate());
+      return {};
+    });
+
+    await expect(service.ask('conv-1', 'what file did we use?')).resolves.toEqual({
+      status: 'ok',
+      answer: 'Here is what I found',
+    });
+    expect(mockAcpCancelPrompt).toHaveBeenCalled();
+  });
+});
diff --git a/tests/unit/sendboxBtw.dom.test.tsx b/tests/unit/sendboxBtw.dom.test.tsx
new file mode 100644
index 0000000000..e88e94e106
--- /dev/null
+++ b/tests/unit/sendboxBtw.dom.test.tsx
@@ -0,0 +1,289 @@
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockWarning = vi.fn();
+const mockAsk = vi.fn();
+const mockDismiss = vi.fn();
+const mockBtwOverlay = vi.fn(() => React.createElement('div', {}, 'BtwOverlay'));
+const mockBtwState = {
+  answer: '',
+  isLoading: false,
+  isOpen: false,
+  question: '',
+};
+
+const mockUseConversationContextSafe = vi.fn(() => ({ conversationId: 'conv-1' }));
+const mockUseLayoutContext = vi.fn(() => ({ isMobile: false }));
+const mockUseSlashCommandController = vi.fn(() => ({
+  isOpen: false,
+  filteredCommands: [],
+  activeIndex: 0,
+  setActiveIndex: vi.fn(),
+  onSelectByIndex: vi.fn(),
+  onKeyDown: vi.fn(() => false),
+}));
+const mockUsePreviewContext = vi.fn(() => ({
+  setSendBoxHandler: vi.fn(),
+  domSnippets: [],
+  removeDomSnippet: vi.fn(),
+  clearDomSnippets: vi.fn(),
+}));
+
+vi.mock('@/common', () => ({
+  ipcBridge: {
+    conversation: {
+      warmup: { invoke: vi.fn().mockResolvedValue(undefined) },
+    },
+  },
+}));
+
+vi.mock('@/renderer/hooks/context/ConversationContext', () => ({
+  useConversationContextSafe: () => mockUseConversationContextSafe(),
+}));
+
+vi.mock('@/renderer/hooks/context/LayoutContext', () => ({
+  useLayoutContext: () => mockUseLayoutContext(),
+}));
+
+vi.mock('@/renderer/pages/conversation/Preview', () => ({
+  usePreviewContext: () => mockUsePreviewContext(),
+}));
+
+vi.mock('@/renderer/hooks/chat/useInputFocusRing', () => ({
+  useInputFocusRing: () => ({
+    activeBorderColor: '#000',
+    inactiveBorderColor: '#ccc',
+    activeShadow: '0 0 0 2px rgba(0,0,0,0.1)',
+  }),
+}));
+
+vi.mock('@/renderer/hooks/chat/useCompositionInput', () => ({
+  useCompositionInput: () => ({
+    compositionHandlers: {},
+    createKeyDownHandler: (onEnterPress: () => void, onKeyDownIntercept?: (e: React.KeyboardEvent) => boolean) => {
+      return (event: React.KeyboardEvent) => {
+        if (onKeyDownIntercept?.(event)) {
+          return;
+        }
+        if (event.key === 'Enter' && !event.shiftKey) {
+          event.preventDefault();
+          onEnterPress();
+        }
+      };
+    },
+  }),
+}));
+
+vi.mock('@/renderer/hooks/file/useDragUpload', () => ({
+  useDragUpload: () => ({
+    isFileDragging: false,
+    dragHandlers: {},
+  }),
+}));
+
+vi.mock('@/renderer/hooks/file/usePasteService', () => ({
+  usePasteService: () => ({
+    onPaste: vi.fn(),
+    onFocus: vi.fn(),
+  }),
+}));
+
+vi.mock('@renderer/hooks/ui/useLatestRef', () => ({
+  useLatestRef: (value: unknown) => ({ current: value }),
+}));
+
+vi.mock('@renderer/services/FileService', () => ({
+  allSupportedExts: [],
+}));
+
+vi.mock('@renderer/hooks/file/useUploadState', () => ({
+  useUploadState: () => ({ isUploading: false }),
+}));
+
+vi.mock('@renderer/components/media/UploadProgressBar', () => ({
+  __esModule: true,
+  default: () => React.createElement('div', {}, 'UploadProgressBar'),
+}));
+
+vi.mock('@/renderer/components/chat/SlashCommandMenu', () => ({
+  __esModule: true,
+  default: () => React.createElement('div', {}, 'SlashCommandMenu'),
+}));
+
+vi.mock('@/renderer/components/chat/BtwOverlay', () => ({
+  __esModule: true,
+  default: (props: unknown) => mockBtwOverlay(props),
+}));
+
+vi.mock('@/renderer/components/chat/BtwOverlay/useBtwCommand', () => ({
+  useBtwCommand: () => ({
+    ask: mockAsk,
+    dismiss: mockDismiss,
+    ...mockBtwState,
+  }),
+}));
+
+vi.mock('@/renderer/hooks/chat/useSlashCommandController', () => ({
+  useSlashCommandController: (args: unknown) => mockUseSlashCommandController(args),
+}));
+
+vi.mock('@/renderer/utils/ui/focus', () => ({
+  blurActiveElement: vi.fn(),
+  shouldBlockMobileInputFocus: vi.fn(() => false),
+}));
+
+vi.mock('react-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string) => key,
+  }),
+}));
+
+vi.mock('@arco-design/web-react', () => ({
+  Button: ({ onClick, children, icon, ...props }: React.ComponentProps<'button'>) =>
+    React.createElement('button', { onClick, ...props }, icon ?? children),
+  Input: {
+    TextArea: ({ onKeyDown, onChange, value, ...props }: React.ComponentProps<'textarea'> & { value?: string }) =>
+      React.createElement('textarea', {
+        onKeyDown,
+        onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => onChange?.(event.target.value),
+        value,
+        ...props,
+      }),
+  },
+  Message: {
+    useMessage: () => [{ warning: mockWarning }, null],
+  },
+  Tag: ({ children }: { children: React.ReactNode }) => React.createElement('div', {}, children),
+}));
+
+vi.mock('@icon-park/react', () => ({
+  ArrowUp: () => React.createElement('span', {}, 'ArrowUp'),
+  CloseSmall: () => React.createElement('span', {}, 'CloseSmall'),
+}));
+
+import SendBox from '@/renderer/components/chat/sendbox';
+
+describe('SendBox /btw handling', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockBtwState.answer = '';
+    mockBtwState.isLoading = false;
+    mockBtwState.isOpen = false;
+    mockBtwState.question = '';
+    mockUsePreviewContext.mockReturnValue({
+      setSendBoxHandler: vi.fn(),
+      domSnippets: [],
+      removeDomSnippet: vi.fn(),
+      clearDomSnippets: vi.fn(),
+    });
+    mockUseSlashCommandController.mockReturnValue({
+      isOpen: false,
+      filteredCommands: [],
+      activeIndex: 0,
+      setActiveIndex: vi.fn(),
+      onSelectByIndex: vi.fn(),
+      onKeyDown: vi.fn(() => false),
+    });
+  });
+
+  it('routes /btw through side-question flow even while loading', () => {
+    const onChange = vi.fn();
+    const onSend = vi.fn();
+
+    const { container } = render(
+      <SendBox value='/btw what file did we use?' onChange={onChange} onSend={onSend} loading enableBtw />
+    );
+
+    const textarea = container.querySelector('textarea');
+    expect(textarea).toBeTruthy();
+
+    fireEvent.keyDown(textarea!, { key: 'Enter' });
+
+    expect(mockAsk).toHaveBeenCalledWith('what file did we use?');
+    expect(onSend).not.toHaveBeenCalled();
+    expect(mockWarning).not.toHaveBeenCalledWith('messages.conversationInProgress');
+  });
+
+  it('blocks /btw when attachments are pending', () => {
+    const { container } = render(
+      <SendBox value='/btw what file did we use?' onChange={vi.fn()} onSend={vi.fn()} hasPendingAttachments enableBtw />
+    );
+
+    const textarea = container.querySelector('textarea');
+    expect(textarea).toBeTruthy();
+
+    fireEvent.keyDown(textarea!, { key: 'Enter' });
+
+    expect(mockAsk).not.toHaveBeenCalled();
+    expect(mockWarning).toHaveBeenCalledWith('conversation.sideQuestion.attachmentsNotAllowed');
+  });
+
+  it('blocks a second /btw while one is already running', () => {
+    mockBtwState.isLoading = true;
+    mockBtwState.isOpen = true;
+    mockBtwState.question = 'existing side question';
+
+    const { container } = render(
+      <SendBox value='/btw what file did we use?' onChange={vi.fn()} onSend={vi.fn()} loading enableBtw />
+    );
+
+    const textarea = container.querySelector('textarea');
+    expect(textarea).toBeTruthy();
+
+    fireEvent.keyDown(textarea!, { key: 'Enter' });
+
+    expect(mockAsk).not.toHaveBeenCalled();
+    expect(mockWarning).toHaveBeenCalledWith('conversation.sideQuestion.alreadyRunning');
+  });
+
+  it('passes parent task running state to the btw overlay', () => {
+    const { rerender } = render(
+      <SendBox value='/btw what file did we use?' onChange={vi.fn()} onSend={vi.fn()} loading enableBtw />
+    );
+
+    expect(mockBtwOverlay).toHaveBeenCalled();
+    expect(mockBtwOverlay.mock.calls.at(-1)?.[0]).toMatchObject({
+      parentTaskRunning: true,
+    });
+
+    rerender(
+      <SendBox value='/btw what file did we use?' onChange={vi.fn()} onSend={vi.fn()} loading={false} enableBtw />
+    );
+
+    expect(mockBtwOverlay.mock.calls.at(-1)?.[0]).toMatchObject({
+      parentTaskRunning: false,
+    });
+  });
+
+  it('treats /btw as normal text when the feature is disabled', () => {
+    const onSend = vi.fn().mockResolvedValue(undefined);
+
+    const { container } = render(
+      <SendBox value='/btw what file did we use?' onChange={vi.fn()} onSend={onSend} loading={false} />
+    );
+
+    const textarea = container.querySelector('textarea');
+    expect(textarea).toBeTruthy();
+
+    fireEvent.keyDown(textarea!, { key: 'Enter' });
+
+    expect(mockAsk).not.toHaveBeenCalled();
+    expect(onSend).toHaveBeenCalledWith('/btw what file did we use?');
+  });
+
+  it('only registers the /btw slash command when enabled', () => {
+    render(<SendBox value='/' onChange={vi.fn()} onSend={vi.fn()} enableBtw />);
+
+    expect(mockUseSlashCommandController).toHaveBeenCalled();
+    const enabledCommands = mockUseSlashCommandController.mock.calls.at(-1)?.[0]?.commands ?? [];
+    expect(enabledCommands.some((command: { name: string }) => command.name === 'btw')).toBe(true);
+
+    mockUseSlashCommandController.mockClear();
+
+    render(<SendBox value='/' onChange={vi.fn()} onSend={vi.fn()} />);
+
+    const disabledCommands = mockUseSlashCommandController.mock.calls.at(-1)?.[0]?.commands ?? [];
+    expect(disabledCommands.some((command: { name: string }) => command.name === 'btw')).toBe(false);
+  });
+});
diff --git a/tests/unit/slashCommandController.test.tsx b/tests/unit/slashCommandController.test.tsx
new file mode 100644
index 0000000000..c963b22cbe
--- /dev/null
+++ b/tests/unit/slashCommandController.test.tsx
@@ -0,0 +1,70 @@
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { useSlashCommandController } from '@/renderer/hooks/chat/useSlashCommandController';
+
+describe('useSlashCommandController', () => {
+  it('inserts builtin commands marked with selectionBehavior=insert', () => {
+    const onExecuteBuiltin = vi.fn();
+    const onSelectTemplate = vi.fn();
+
+    const { result } = renderHook(() =>
+      useSlashCommandController({
+        input: '/btw',
+        commands: [
+          {
+            name: 'btw',
+            description: 'Ask a quick side question',
+            kind: 'builtin',
+            source: 'builtin',
+            selectionBehavior: 'insert',
+          },
+        ],
+        onExecuteBuiltin,
+        onSelectTemplate,
+      })
+    );
+
+    act(() => {
+      result.current.onKeyDown({
+        key: 'Enter',
+        shiftKey: false,
+        preventDefault: vi.fn(),
+      } as unknown as React.KeyboardEvent);
+    });
+
+    expect(onSelectTemplate).toHaveBeenCalledWith('btw');
+    expect(onExecuteBuiltin).not.toHaveBeenCalled();
+  });
+
+  it('still executes builtin commands by default', () => {
+    const onExecuteBuiltin = vi.fn();
+    const onSelectTemplate = vi.fn();
+
+    const { result } = renderHook(() =>
+      useSlashCommandController({
+        input: '/open',
+        commands: [
+          {
+            name: 'open',
+            description: 'Add file',
+            kind: 'builtin',
+            source: 'builtin',
+          },
+        ],
+        onExecuteBuiltin,
+        onSelectTemplate,
+      })
+    );
+
+    act(() => {
+      result.current.onKeyDown({
+        key: 'Enter',
+        shiftKey: false,
+        preventDefault: vi.fn(),
+      } as unknown as React.KeyboardEvent);
+    });
+
+    expect(onExecuteBuiltin).toHaveBeenCalledWith('open');
+    expect(onSelectTemplate).not.toHaveBeenCalled();
+  });
+});