diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx
index 96eb837db4d69..3f77400c15d54 100644
--- a/apps/web/src/components/ChatView.browser.test.tsx
+++ b/apps/web/src/components/ChatView.browser.test.tsx
@@ -4,8 +4,9 @@ import { ThreadId } from "@t3tools/contracts";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import type * as Context from "effect/Context";
import { expect, it, vi } from "vite-plus/test";
-import { page } from "vite-plus/test/browser";
+import { page, userEvent } from "vite-plus/test/browser";
+import "~/index.css";
import { AppRoot } from "~/AppRoot";
// The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines)
// takes 6-11s; importing it here keeps that out of the test's timeout.
@@ -17,6 +18,7 @@ import {
ENVIRONMENT_ID,
PROJECT_ID,
installFakeEnvironment,
+ threadWithHistory,
type FakeDriver,
} from "../../test/fakeEnvironment";
import { render } from "../../test/render";
@@ -31,6 +33,50 @@ vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (i
return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) };
});
+it.each(["sid", "btw"])("opens and focuses a side chat on selecting /%s once", async (command) => {
+ const thread = threadWithHistory(`side-menu-${command}`, 1);
+ using _environment = installFakeEnvironment([thread], { sideChats: true });
+ __setClientSettingsForTests({
+ ...DEFAULT_CLIENT_SETTINGS,
+ onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
+ });
+ await page.viewport(1400, 900);
+ const router = getRouter(
+ createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }),
+ );
+ await router.load();
+ const container = document.createElement("div");
+ container.style.cssText = "height: 900px; width: 1400px;";
+ await using screen = await render(, {
+ container,
+ // AppRoot brings its own router.
+ router: false,
+ });
+ const mainInput = screen.getByRole("textbox", { name: `Message ${thread.title}` });
+ const sideInput = screen.getByRole("textbox", { name: "Message Side chat" });
+ const sideOption = page.getByRole("option", {
+ name: command === "sid" ? /^\/side / : /^\/btw /,
+ });
+ await expect.element(mainInput).toBeVisible();
+ await userEvent.type(mainInput, `/${command} keep this question`);
+ await userEvent.keyboard("{Home}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}");
+ await sideOption.click();
+ await expect
+ .element(mainInput)
+ .toHaveTextContent(`/${command === "sid" ? "side" : "btw"} keep this question`);
+ await expect.element(sideInput).not.toBeInTheDocument();
+
+ await mainInput.fill("");
+ await userEvent.type(mainInput, `/${command}`);
+ await expect.element(sideOption).toBeVisible();
+ await userEvent.keyboard("{Enter}");
+
+ await expect.element(sideInput, { timeout: 2000 }).toHaveFocus();
+ await userEvent.keyboard("a side question");
+ await expect.element(sideInput).toHaveTextContent("a side question");
+ await expect.element(mainInput).toHaveTextContent("");
+});
+
vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({
...(await original()),
trackRpcRequestSent: () => {},
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index 69022a93fbeea..e17d33e5a36fc 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -1903,6 +1903,139 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
};
};
+ const blurMobileComposerAfterSend = () => {
+ if (!isSoftwareKeyboardOpen) return;
+ if (composerBlurFrameRef.current !== null) {
+ window.cancelAnimationFrame(composerBlurFrameRef.current);
+ composerBlurFrameRef.current = null;
+ }
+ const activeElement = document.activeElement;
+ if (activeElement instanceof HTMLElement) {
+ activeElement.blur();
+ }
+ setIsComposerFocused(false);
+ };
+
+ const shouldBlurMobileComposerOnSubmit = () => {
+ if (!isSoftwareKeyboardOpen) return false;
+ if (
+ isSendBusy ||
+ isSendDisabled ||
+ noProviderAvailable ||
+ environmentUnavailable !== null ||
+ phase === "running"
+ ) {
+ return false;
+ }
+ if (activePendingProgress) {
+ return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers);
+ }
+ return showPlanFollowUpPrompt || composerSendState.hasSendableContent;
+ };
+
+ const submitComposerRef = useRef<
+ (dispatchMode?: ComposerDispatchMode, submissionIntent?: ComposerSubmissionIntent) => void
+ >(() => {});
+ const submitComposer = (
+ event?: { preventDefault: () => void },
+ dispatchMode?: ComposerDispatchMode,
+ submissionIntent?: ComposerSubmissionIntent,
+ ) => {
+ if (voiceInputActive) {
+ event?.preventDefault();
+ submitAfterDictation(() => submitComposerRef.current(dispatchMode, submissionIntent));
+ return;
+ }
+ if (
+ noProviderAvailable ||
+ isSendDisabled ||
+ !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
+ ) {
+ event?.preventDefault();
+ return;
+ }
+ // A send while a pasted image is still compressing would strand that image:
+ // the turn snapshot wouldn't include it, and it would surface in the *next*
+ // draft instead.
+ if (activeThreadId && countPreparingComposerImages(attachmentTargetKey) > 0) {
+ event?.preventDefault();
+ toastManager.add({
+ type: "info",
+ title: "Still compressing a pasted image.",
+ description: "Send again once its thumbnail appears.",
+ });
+ return;
+ }
+ if (pendingDraftWork.has(attachmentTargetKey)) {
+ event?.preventDefault();
+ toastManager.add({
+ type: "info",
+ title: "Still bringing attached context into this message.",
+ description: "Send again once its chip resolves.",
+ });
+ return;
+ }
+ const submission = submitComposerDraft({
+ prompt: promptRef.current,
+ submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn",
+ event,
+ onSend: (sendEvent) => {
+ providerInputRejectedRef.current = false;
+ onSend(
+ sendEvent,
+ dispatchMode ??
+ resolveComposerDispatchMode({
+ running: phase === "running",
+ alternateModifier: false,
+ activeTurnDefault: followUpBehavior,
+ canSteer: !isCompacting,
+ }),
+ submissionIntent,
+ );
+ return !providerInputRejectedRef.current;
+ },
+ });
+ setComposerSubmissionError(submission.validationMessage);
+ if (!submission.didDispatch) return;
+ onTypingGuardSend();
+ if (shouldBlurMobileComposerOnSubmit()) {
+ blurMobileComposerAfterSend();
+ }
+ };
+ const submitComposerStable = useStableCallback(submitComposer);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed submission handler to the asynchronous dictation completion
+ useLayoutEffect(() => {
+ submitComposerRef.current = (mode, intent) => submitComposerStable(undefined, mode, intent);
+ }, [submitComposerStable]);
+ useDictationSubmissionShortcut({
+ enabled: voiceInputActive && !speechInput.preparing,
+ formRef: composerFormRef,
+ onKeyDown: (event) => {
+ const intent = composerSubmissionIntentForKey({
+ event,
+ keybindings,
+ isSoftwareKeyboardOpen,
+ isDraftThread: routeKind === "draft",
+ isRunning: phase === "running",
+ sendShortcut: settings.sendShortcut,
+ prompt: promptRef.current,
+ });
+ if (!intent) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ submitComposer(
+ undefined,
+ resolveComposerDispatchMode({
+ running: phase === "running",
+ alternateModifier: intent === "alternate",
+ canSteer: !isCompacting,
+ activeTurnDefault: followUpBehavior,
+ }),
+ intent,
+ );
+ },
+ });
+
const { onUsageLimitsCommand } = props;
const onSelectComposerItem = (item: ComposerCommandItem) => {
if (composerSelectLockRef.current) return;
@@ -1952,13 +2085,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}
if (item.command === "side" || item.command === "btw") {
if (props.sideChatDisabledReason) return;
+ const openImmediately =
+ !isEditingQueuedMessage &&
+ snapshot.value.slice(0, trigger.rangeStart).trim() === "" &&
+ snapshot.value.slice(trigger.rangeEnd).trim() === "";
+ const replacement = `/${item.command} `;
+ const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
+ snapshot.value,
+ trigger.rangeEnd,
+ replacement,
+ );
const applied = applyPromptReplacement(
trigger.rangeStart,
- trigger.rangeEnd,
- `/${item.command} `,
- { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd) },
+ replacementRangeEnd,
+ replacement,
+ {
+ expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd),
+ focusEditorAfterReplace: !openImmediately,
+ },
);
- if (applied) setComposerHighlightedItemId(null);
+ if (applied) {
+ setComposerHighlightedItemId(null);
+ if (openImmediately) submitComposer();
+ }
return;
}
if (!planModeUiEnabled) return;
@@ -2103,138 +2252,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
setComposerHighlightedItemId(nextItem?.id ?? null);
};
- const blurMobileComposerAfterSend = () => {
- if (!isSoftwareKeyboardOpen) return;
- if (composerBlurFrameRef.current !== null) {
- window.cancelAnimationFrame(composerBlurFrameRef.current);
- composerBlurFrameRef.current = null;
- }
- const activeElement = document.activeElement;
- if (activeElement instanceof HTMLElement) {
- activeElement.blur();
- }
- setIsComposerFocused(false);
- };
-
- const shouldBlurMobileComposerOnSubmit = () => {
- if (!isSoftwareKeyboardOpen) return false;
- if (
- isSendBusy ||
- isSendDisabled ||
- noProviderAvailable ||
- environmentUnavailable !== null ||
- phase === "running"
- ) {
- return false;
- }
- if (activePendingProgress) {
- return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers);
- }
- return showPlanFollowUpPrompt || composerSendState.hasSendableContent;
- };
-
- const submitComposerRef = useRef<
- (dispatchMode?: ComposerDispatchMode, submissionIntent?: ComposerSubmissionIntent) => void
- >(() => {});
- const submitComposer = (
- event?: { preventDefault: () => void },
- dispatchMode?: ComposerDispatchMode,
- submissionIntent?: ComposerSubmissionIntent,
- ) => {
- if (voiceInputActive) {
- event?.preventDefault();
- submitAfterDictation(() => submitComposerRef.current(dispatchMode, submissionIntent));
- return;
- }
- if (
- noProviderAvailable ||
- isSendDisabled ||
- !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
- ) {
- event?.preventDefault();
- return;
- }
- // A send while a pasted image is still compressing would strand that image:
- // the turn snapshot wouldn't include it, and it would surface in the *next*
- // draft instead.
- if (activeThreadId && countPreparingComposerImages(attachmentTargetKey) > 0) {
- event?.preventDefault();
- toastManager.add({
- type: "info",
- title: "Still compressing a pasted image.",
- description: "Send again once its thumbnail appears.",
- });
- return;
- }
- if (pendingDraftWork.has(attachmentTargetKey)) {
- event?.preventDefault();
- toastManager.add({
- type: "info",
- title: "Still bringing attached context into this message.",
- description: "Send again once its chip resolves.",
- });
- return;
- }
- const submission = submitComposerDraft({
- prompt: promptRef.current,
- submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn",
- event,
- onSend: (sendEvent) => {
- providerInputRejectedRef.current = false;
- onSend(
- sendEvent,
- dispatchMode ??
- resolveComposerDispatchMode({
- running: phase === "running",
- alternateModifier: false,
- activeTurnDefault: followUpBehavior,
- canSteer: !isCompacting,
- }),
- submissionIntent,
- );
- return !providerInputRejectedRef.current;
- },
- });
- setComposerSubmissionError(submission.validationMessage);
- if (!submission.didDispatch) return;
- onTypingGuardSend();
- if (shouldBlurMobileComposerOnSubmit()) {
- blurMobileComposerAfterSend();
- }
- };
- const submitComposerStable = useStableCallback(submitComposer);
- // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed submission handler to the asynchronous dictation completion
- useLayoutEffect(() => {
- submitComposerRef.current = (mode, intent) => submitComposerStable(undefined, mode, intent);
- }, [submitComposerStable]);
- useDictationSubmissionShortcut({
- enabled: voiceInputActive && !speechInput.preparing,
- formRef: composerFormRef,
- onKeyDown: (event) => {
- const intent = composerSubmissionIntentForKey({
- event,
- keybindings,
- isSoftwareKeyboardOpen,
- isDraftThread: routeKind === "draft",
- isRunning: phase === "running",
- sendShortcut: settings.sendShortcut,
- prompt: promptRef.current,
- });
- if (!intent) return;
- event.preventDefault();
- event.stopImmediatePropagation();
- submitComposer(
- undefined,
- resolveComposerDispatchMode({
- running: phase === "running",
- alternateModifier: intent === "alternate",
- canSteer: !isCompacting,
- activeTurnDefault: followUpBehavior,
- }),
- intent,
- );
- },
- });
const handleSubmitMessage = (event: React.MouseEvent) => {
event.preventDefault();
submitComposer(
diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx
index f0d8afa5f319c..60139fc437c69 100644
--- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx
+++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx
@@ -177,6 +177,7 @@ export function RightPanelSurfaceContent(props: {
parentRef={threadRef}
threadId={surface.threadId}
parentPanel={props.sideChatParentPanel}
+ visible={visible}
onClose={() => onCloseSurface(surface)}
/>
);
diff --git a/apps/web/src/components/chat/SideChatPane.tsx b/apps/web/src/components/chat/SideChatPane.tsx
index 1a5bd1141b31f..b1fbb21ef5b70 100644
--- a/apps/web/src/components/chat/SideChatPane.tsx
+++ b/apps/web/src/components/chat/SideChatPane.tsx
@@ -12,9 +12,9 @@ import {
type ThreadId,
} from "@t3tools/contracts";
import { Trash2Icon, XIcon } from "lucide-react";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
-import { ComposerHandleContext } from "../../composerHandleContext";
+import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext";
import { useClientSettings } from "../../hooks/useSettings";
import type { SideChatParentPanel } from "../../lib/sideChatPane";
import { readLocalApi } from "../../localApi";
@@ -35,6 +35,7 @@ export default function SideChatPane(props: {
readonly parentRef: ScopedThreadRef;
readonly threadId: ThreadId;
readonly parentPanel: SideChatParentPanel;
+ readonly visible: boolean;
readonly onClose: () => void;
}) {
const { parentRef, threadId } = props;
@@ -45,6 +46,14 @@ export default function SideChatPane(props: {
[parentRef, threadId],
);
const shell = useQuietThreadShell(threadRef);
+ const composerRef: ComposerHandleRef = useRef(null);
+ const chatReady = shell !== null;
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the embedded composer DOM after its side chat becomes visible
+ useEffect(() => {
+ if (!props.visible || !chatReady) return;
+ const frame = window.requestAnimationFrame(() => composerRef.current?.focusAtEnd());
+ return () => window.cancelAnimationFrame(frame);
+ }, [chatReady, props.visible]);
const parent = useQuietThreadShell(parentRef);
const snapshotLoaded = useEnvironmentShellHasSnapshot(parentRef.environmentId);
const projection = useQuietThreadProjection(shell === null ? null : threadRef)?.projection;
@@ -164,7 +173,7 @@ export default function SideChatPane(props: {
) : null
) : (
-
+
| null
let threads: ReadonlyArray = [];
let sequence = 1;
let vcsStatusSubscriber: Queue.Queue | null = null;
+let shellSubscriber: Queue.Queue | null = null;
+let sideChats = false;
+
+const currentServerConfig = () =>
+ sideChats
+ ? {
+ ...serverConfig,
+ environment: {
+ ...serverConfig.environment,
+ capabilities: { ...serverConfig.environment.capabilities, sideChats: true },
+ },
+ }
+ : serverConfig;
const subscribe = (first: ReadonlyArray, register?: (queue: Queue.Queue) => () => void) =>
Stream.unwrap(
@@ -335,6 +349,27 @@ const subscribe = (first: ReadonlyArray, register?: (queue: Queue.Queue
);
const handlers: Record unknown> = {
+ [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command: OrchestrationV2Command) =>
+ Effect.gen(function* () {
+ if (sideChats && command.type === "thread.visit") return { sequence };
+ if (!sideChats || command.type !== "thread.fork" || !command.sideChat || !shellSubscriber) {
+ return yield* Effect.never;
+ }
+ const thread: FakeThread = {
+ ...threadWithHistory(command.targetThreadId, 0),
+ title: command.title ?? "Side chat",
+ sideChatOf: command.sourceThreadId,
+ };
+ threads = [...threads, thread];
+ sequence += 1;
+ yield* Queue.offer(shellSubscriber, {
+ kind: "thread.updated",
+ location: "active",
+ sequence,
+ thread: shellOf(thread),
+ });
+ return { sequence };
+ }),
[WS_METHODS.subscribeTerminalMetadata]: () =>
subscribe([{ type: "snapshot", terminals: [] }], (queue) => {
terminalMetadataSubscriber = queue;
@@ -343,19 +378,27 @@ const handlers: Record unknown> = {
};
}),
[ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () =>
- subscribe([
- {
- kind: "snapshot",
- snapshot: {
- schemaVersion: 1,
- snapshotSequence: sequence,
- projects: [project],
- threads: threads.map(shellOf),
- archivedThreads: [],
+ subscribe(
+ [
+ {
+ kind: "snapshot",
+ snapshot: {
+ schemaVersion: 1,
+ snapshotSequence: sequence,
+ projects: [project],
+ threads: threads.map(shellOf),
+ archivedThreads: [],
+ },
},
+ { kind: "synchronized" },
+ ],
+ (queue) => {
+ shellSubscriber = queue;
+ return () => {
+ if (shellSubscriber === queue) shellSubscriber = null;
+ };
},
- { kind: "synchronized" },
- ]),
+ ),
[ORCHESTRATION_V2_WS_METHODS.subscribeThread]: (input: { readonly threadId: ThreadId }) => {
const thread = threads.find((candidate) => candidate.id === input.threadId);
if (!thread) return Stream.never;
@@ -382,7 +425,7 @@ const handlers: Record unknown> = {
};
}),
[WS_METHODS.subscribeServerConfig]: () =>
- subscribe([{ version: 1 as const, type: "snapshot" as const, config: serverConfig }]),
+ subscribe([{ version: 1 as const, type: "snapshot" as const, config: currentServerConfig() }]),
};
// Unanswered calls stay pending, like a server that has not replied yet.
@@ -397,9 +440,9 @@ const client = new Proxy(handlers, {
const session: EnvironmentConnectionLease["session"] = {
client: client as unknown as WsRpcProtocolClient,
- initialConfig: Effect.succeed(serverConfig),
+ initialConfig: Effect.sync(currentServerConfig),
subscribeServerConfig: () =>
- subscribe([{ version: 1 as const, type: "snapshot" as const, config: serverConfig }]),
+ subscribe([{ version: 1 as const, type: "snapshot" as const, config: currentServerConfig() }]),
ready: Effect.void,
probe: Effect.void,
inboundFrames: Effect.succeed(0),
@@ -433,8 +476,12 @@ const encodedDescriptor = Schema.encodeSync(ExecutionEnvironmentDescriptor)(
);
/** Serves the environment's HTTP API and these threads until disposed. */
-export function installFakeEnvironment(fixtures: ReadonlyArray): Disposable {
+export function installFakeEnvironment(
+ fixtures: ReadonlyArray,
+ options?: { readonly sideChats?: boolean },
+): Disposable {
threads = fixtures;
+ sideChats = options?.sideChats === true;
globalThis.fetch = async (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input), window.location.href);
if (url.origin !== window.location.origin) return realFetch(input, init);
@@ -456,6 +503,7 @@ export function installFakeEnvironment(fixtures: ReadonlyArray): Dis
[Symbol.dispose]: () => {
globalThis.fetch = realFetch;
threads = [];
+ sideChats = false;
},
};
}