Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion apps/web/src/components/ChatView.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -17,6 +18,7 @@ import {
ENVIRONMENT_ID,
PROJECT_ID,
installFakeEnvironment,
threadWithHistory,
type FakeDriver,
} from "../../test/fakeEnvironment";
import { render } from "../../test/render";
Expand All @@ -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(<AppRoot router={router} />, {
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: () => {},
Expand Down
289 changes: 153 additions & 136 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
return;
}
if (!planModeUiEnabled) return;
Expand Down Expand Up @@ -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<HTMLButtonElement>) => {
event.preventDefault();
submitComposer(
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/RightPanelSurfaceContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export function RightPanelSurfaceContent(props: {
parentRef={threadRef}
threadId={surface.threadId}
parentPanel={props.sideChatParentPanel}
visible={visible}
onClose={() => onCloseSurface(surface)}
/>
);
Expand Down
Loading
Loading