From e767f8a56b7246106cc745074ac7cb25aa680c75 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Tue, 3 Nov 2026 14:01:47 +0530 Subject: [PATCH 1/2] fix(web): open side chats on slash menu selection and focus input --- .../src/components/ChatView.browser.test.tsx | 48 +++- apps/web/src/components/chat/ChatComposer.tsx | 208 ++++++++++-------- .../chat/RightPanelSurfaceContent.tsx | 1 + apps/web/src/components/chat/SideChatPane.tsx | 15 +- apps/web/test/fakeEnvironment.ts | 78 +++++-- 5 files changed, 235 insertions(+), 115 deletions(-) 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 9ad9158cd4da3..346889aca4dca 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1847,6 +1847,99 @@ 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 submitComposer = ( + event?: { preventDefault: () => void }, + dispatchMode?: ComposerDispatchMode, + submissionIntent?: ComposerSubmissionIntent, + ) => { + 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); + const { onUsageLimitsCommand } = props; const onSelectComposerItem = (item: ComposerCommandItem) => { if (composerSelectLockRef.current) return; @@ -1886,13 +1979,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } if (item.command === "side" || item.command === "btw") { if (props.sideChatDisabledReason) return; + const openImmediately = + 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; @@ -2037,98 +2145,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 submitComposer = ( - event?: { preventDefault: () => void }, - dispatchMode?: ComposerDispatchMode, - submissionIntent?: ComposerSubmissionIntent, - ) => { - 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); 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 b703e5d05da97..2d0b5e56a63ec 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -176,6 +176,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; }, }; } From 559ecfc6a06e73a4dc4c95cab57937b6de0abea3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 6 Nov 2026 02:56:58 +0000 Subject: [PATCH 2/2] fix(web): keep slash side-chat selection from saving a queued edit --- apps/web/src/components/chat/ChatComposer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4024c058b1e85..e17d33e5a36fc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2086,6 +2086,7 @@ 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} `;