}
- aria-label={`Queued. ${statusLabel}.`}
+ aria-label={`${sending ? "Sending" : "Queued"}. ${statusLabel}.`}
>
- Queued
+ {sending ? "Sending" : "Queued"}
+
@@ -2771,7 +2792,7 @@ function ReasoningTraceBlock({
type="button"
aria-expanded={expanded}
onClick={() => ctx.onToggleReasoning(first.id, !expanded, anchorKey)}
- className="flex min-h-6 cursor-pointer select-none items-center gap-1.5 rounded-md px-0.5 text-start text-sm leading-relaxed transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
+ className="flex min-h-6 cursor-pointer select-none items-center gap-1.5 rounded-md ps-0.5 pe-2 text-start text-sm leading-relaxed transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
>
diff --git a/apps/web/src/components/chat/sendQueuedMessage.ts b/apps/web/src/components/chat/sendQueuedMessage.ts
new file mode 100644
index 000000000000..ac10dfef48c8
--- /dev/null
+++ b/apps/web/src/components/chat/sendQueuedMessage.ts
@@ -0,0 +1,211 @@
+import { scopedThreadKey } from "@t3tools/client-runtime/environment";
+import {
+ runAtomCommand,
+ squashAtomCommandFailure,
+ type AtomCommand,
+} from "@t3tools/client-runtime/state/runtime";
+import type { ScopedThreadRef } from "@t3tools/contracts";
+import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend";
+import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";
+
+import { buildMessageContext, terminalContextReference } from "../../lib/composerContextRecords";
+import { removeInlineContextReference } from "../../lib/composerContextReferences";
+import {
+ awaitAttachmentUploads,
+ getUploadedAttachments,
+ releaseDraftAttachments,
+ startAttachmentUpload,
+} from "../../lib/attachmentUploadQueue";
+import { newMessageId } from "../../lib/utils";
+import { latestCompletedToolActivityId, useQueuedMessageStore } from "../../queuedMessageStore";
+import { appAtomRegistry } from "../../rpc/atomRegistry";
+import { readThread, readThreadShell } from "../../state/entities";
+import { environmentServerConfigsAtom } from "../../state/server";
+import { threadEnvironment } from "../../state/threads";
+import {
+ createLocalDispatchSnapshot,
+ deriveComposerSendState,
+ readFileAsDataUrl,
+ resolveThreadMetadataUpdateForNextTurn,
+ revokeBlobPreviewUrl,
+} from "../ChatView.logic";
+import { toastManager } from "../ui/toast";
+import { fileAttachmentCapabilityBlockReason } from "./composerAttachmentFiles";
+import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./composerPromptHistory";
+
+async function run(command: AtomCommand, input: W): Promise {
+ const result = await runAtomCommand(appAtomRegistry, command, input, { reportFailure: false });
+ if (result._tag === "Failure") throw squashAtomCommandFailure(result);
+ return result.value;
+}
+
+/**
+ * Sends one queued message as a turn on its thread. `QueuedMessageSender`
+ * calls it when the head message is due, and Send now calls it directly. It
+ * reads nothing from the composer, so it works for threads that are not on
+ * screen. A failed send stays at the head of the queue, held for Send now.
+ */
+export async function sendQueuedMessage(
+ threadRef: ScopedThreadRef,
+ messageId: string,
+): Promise {
+ const { environmentId, threadId } = threadRef;
+ const threadKey = scopedThreadKey(threadRef);
+ const queue = useQueuedMessageStore.getState();
+ const message = queue.beginSend(
+ threadKey,
+ messageId,
+ latestCompletedToolActivityId(readThread(threadRef)?.activities ?? []),
+ );
+ if (!message) return;
+ const { sendSettings } = message;
+ const attachments = [...message.images, ...message.files];
+ const readConfig = () => appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId);
+ const assertFilesAllowed = () => {
+ const config = readConfig();
+ const reason = fileAttachmentCapabilityBlockReason({
+ files: message.files,
+ attachmentUploadsCapabilityKnown: config !== undefined,
+ supportsAttachmentUploads: config?.environment.capabilities.attachmentUploads === true,
+ maxFileAttachmentBytes:
+ config?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null,
+ });
+ if (reason !== null) throw new Error(reason);
+ };
+ try {
+ const { sendableTerminalContexts, hasSendableContent } = deriveComposerSendState({
+ prompt: message.prompt,
+ imageCount: attachments.length,
+ terminalContexts: message.terminalContexts,
+ elementContextCount: message.previewAnnotations.length + message.reviewComments.length,
+ });
+ // Only expired terminal context was left. Retrying would block the queue
+ // on every boundary, so drop it and let the queue move on.
+ if (!hasSendableContent) {
+ queue.finishSend(threadKey, message.id);
+ return;
+ }
+ // Expired terminal excerpts are not sent; their chips leave the text with them.
+ const prompt = message.terminalContexts
+ .filter((context) => !sendableTerminalContexts.includes(context))
+ .reduce(
+ (text, context) =>
+ removeInlineContextReference(text, terminalContextReference(context).contextId).prompt,
+ message.prompt,
+ )
+ .trim();
+ const text = applyClaudePromptEffortPrefix(
+ prompt || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
+ sendSettings.promptEffort,
+ );
+
+ assertFilesAllowed();
+ const useUploads = readConfig()?.environment.capabilities.attachmentUploads === true;
+ if (useUploads && attachments.length > 0) {
+ for (const attachment of attachments) {
+ startAttachmentUpload({ environmentId, image: attachment, draftTarget: threadRef });
+ }
+ await awaitAttachmentUploads(attachments.map((attachment) => attachment.id));
+ }
+ const wireAttachments = await Promise.all(
+ attachments.map(async (attachment) => {
+ if (useUploads) {
+ const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0];
+ if (!uploaded) throw new Error(`Attachment '${attachment.name}' did not upload.`);
+ return uploaded;
+ }
+ if (attachment.type !== "image") {
+ throw new Error("This server does not support file attachments.");
+ }
+ return {
+ type: "image" as const,
+ id: attachment.id,
+ name: attachment.name,
+ mimeType: attachment.mimeType,
+ sizeBytes: attachment.sizeBytes,
+ dataUrl: await readFileAsDataUrl(attachment.file),
+ ...(attachment.source ? { source: attachment.source } : {}),
+ };
+ }),
+ );
+ assertFilesAllowed();
+
+ // The server starts the turn with the thread's stored modes, so a change
+ // made in the composer before queueing is saved first.
+ const createdAt = new Date().toISOString();
+ const shell = readThreadShell(threadRef);
+ const metadataUpdate = shell
+ ? resolveThreadMetadataUpdateForNextTurn({
+ currentModelSelection: shell.modelSelection,
+ nextModelSelection: sendSettings.modelSelection,
+ currentBranch: shell.branch,
+ })
+ : null;
+ if (metadataUpdate) {
+ await run(threadEnvironment.updateMetadata, {
+ environmentId,
+ input: { threadId, ...metadataUpdate },
+ });
+ }
+ if (shell && shell.runtimeMode !== sendSettings.runtimeMode) {
+ await run(threadEnvironment.setRuntimeMode, {
+ environmentId,
+ input: { threadId, runtimeMode: sendSettings.runtimeMode, createdAt },
+ });
+ }
+ if (shell && shell.interactionMode !== sendSettings.interactionMode) {
+ await run(threadEnvironment.setInteractionMode, {
+ environmentId,
+ input: { threadId, interactionMode: sendSettings.interactionMode, createdAt },
+ });
+ }
+
+ // Stop hands a preparing message back to the composer. Past this point
+ // the send can no longer be taken back.
+ const thread = readThread(threadRef) ?? undefined;
+ if (!queue.markDispatching(threadKey, message.id, createLocalDispatchSnapshot(thread))) return;
+ const context = buildMessageContext({
+ terminalContexts: sendableTerminalContexts,
+ reviewComments: message.reviewComments,
+ previewAnnotations: message.previewAnnotations,
+ attachments: attachments.map((attachment, index) => ({
+ attachment,
+ attachmentId: wireAttachments[index]?.id ?? attachment.id,
+ })),
+ });
+ // Servers from before inline context drop the records, so their turns
+ // carry the payload in the text instead.
+ const inlineContext = readConfig()?.environment.capabilities.inlineMessageContext === true;
+ await run(threadEnvironment.startTurn, {
+ environmentId,
+ input: {
+ threadId,
+ message: {
+ messageId: newMessageId(),
+ role: "user",
+ text:
+ context !== undefined && !inlineContext
+ ? serializeLegacyContextMessage({ text, records: context.records })
+ : text,
+ attachments: wireAttachments,
+ ...(context !== undefined && inlineContext ? { context } : {}),
+ },
+ modelSelection: sendSettings.modelSelection,
+ runtimeMode: sendSettings.runtimeMode,
+ interactionMode: sendSettings.interactionMode,
+ createdAt,
+ },
+ });
+ queue.finishSend(threadKey, message.id);
+ if (useUploads) releaseDraftAttachments(attachments);
+ for (const image of message.images) revokeBlobPreviewUrl(image.previewUrl);
+ } catch (error) {
+ if (!queue.failSend(threadKey, message.id)) return;
+ const title = readThreadShell(threadRef)?.title;
+ toastManager.add({
+ type: "error",
+ title: title ? `Queued message not sent in "${title}"` : "Queued message not sent",
+ description: error instanceof Error ? error.message : "Use Send now to try again.",
+ });
+ }
+}
diff --git a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx
new file mode 100644
index 000000000000..92514d012b82
--- /dev/null
+++ b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx
@@ -0,0 +1,209 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { EnvironmentId, ProjectId } from "@t3tools/contracts";
+import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
+
+const mocks = vi.hoisted(() => ({
+ importThreads: vi.fn(),
+ createProject: vi.fn(),
+ complete: vi.fn(),
+ refresh: vi.fn(),
+ toast: vi.fn(),
+ projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>,
+}));
+vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" }));
+vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "create" } }));
+vi.mock("../../state/use-atom-command", () => ({
+ useAtomCommand: (command: string) =>
+ command === "import"
+ ? mocks.importThreads
+ : command === "create"
+ ? mocks.createProject
+ : mocks.refresh,
+}));
+vi.mock("../../onboarding/firstRun", () => ({ useCompleteOnboarding: () => mocks.complete }));
+vi.mock("../../state/entities", () => ({
+ useProjects: () => mocks.projects,
+ readProjects: () => mocks.projects,
+}));
+vi.mock("../../state/environments", () => {
+ const environment = {
+ environmentId: "test-env",
+ label: "Computer",
+ connection: { phase: "connected" },
+ };
+ return {
+ useEnvironments: () => ({ environments: [environment] }),
+ usePrimaryEnvironment: () => environment,
+ };
+});
+vi.mock("../../state/server", () => ({
+ serverEnvironment: {
+ providersValueAtom: () => [],
+ configValueAtom: () => null,
+ refreshProviders: "refresh",
+ },
+}));
+vi.mock("@effect/atom-react", () => ({ useAtomValue: (value: unknown) => value }));
+vi.mock("../../onboarding/useProjectScans", () => ({
+ useProjectScans: () => [
+ {
+ environmentId: "test-env",
+ isPending: false,
+ error: null,
+ refresh: mocks.refresh,
+ data: {
+ truncated: false,
+ candidates: [
+ {
+ path: "/project",
+ title: "project",
+ projectId: "test-project",
+ threadCount: 29,
+ lastActiveAt: new Date().toISOString(),
+ sources: ["codex"],
+ },
+ ],
+ },
+ },
+ ],
+}));
+vi.mock("../../connection/onboarding", () => ({ connectPairing: vi.fn() }));
+vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} }));
+vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() }));
+vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));
+vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null }));
+vi.mock("../cloud/CloudEnvironmentConnectList", () => ({
+ CloudEnvironmentConnectRows: () => null,
+}));
+vi.mock("../ui/toast", () => ({
+ toastManager: { add: mocks.toast, close: vi.fn(), update: vi.fn() },
+}));
+
+import { WelcomeWizard } from "./WelcomeWizard";
+
+let root: Root;
+let container: HTMLDivElement;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+ Object.defineProperty(Element.prototype, "getAnimations", {
+ configurable: true,
+ value: () => [],
+ });
+ mocks.projects = [{ id: "test-project", environmentId: "test-env", workspaceRoot: "/project" }];
+ mocks.complete.mockResolvedValue(undefined);
+ mocks.refresh.mockResolvedValue(undefined);
+ mocks.importThreads.mockResolvedValue({
+ _tag: "Success",
+ value: { importedCount: 28, skippedCount: 1 },
+ });
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+});
+
+async function click(label: string) {
+ const button = [...document.querySelectorAll("button")].find(
+ (element) => element.textContent?.trim() === label,
+ );
+ expect(button, `button ${label}`).toBeDefined();
+ await act(async () => button!.click());
+}
+
+it("enters the workspace after a partial import and warns after navigation finishes", async () => {
+ let finishNavigation = () => {};
+ const navigation = new Promise((resolve) => {
+ finishNavigation = resolve;
+ });
+ const onDone = vi.fn(() => navigation);
+ await act(async () => root.render());
+ await click("Continue");
+ await click("Continue");
+ await click("Import 1 project");
+ expect(onDone).toHaveBeenCalledWith({
+ environmentId: EnvironmentId.make("test-env"),
+ projectId: ProjectId.make("test-project"),
+ });
+ expect(mocks.toast).not.toHaveBeenCalled();
+ await act(async () => finishNavigation());
+ expect(mocks.toast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "warning",
+ description: "Imported 28 threads. 1 thread could not be imported.",
+ }),
+ );
+ expect(mocks.toast.mock.invocationCallOrder[0]).toBeGreaterThan(
+ onDone.mock.invocationCallOrder[0]!,
+ );
+});
+
+it.each([
+ [0, 0, null],
+ [29, 0, null],
+ [1, 0, null],
+ [0, 1, "1 thread could not be imported."],
+ [0, 2, "2 threads could not be imported."],
+] as const)(
+ "finishes setup with %i imported and %i skipped threads",
+ async (importedCount, skippedCount, warning) => {
+ mocks.importThreads.mockResolvedValue({
+ _tag: "Success",
+ value: { importedCount, skippedCount },
+ });
+ const onDone = vi.fn();
+ await act(async () => root.render());
+ await click("Continue");
+ await click("Continue");
+ await click("Import 1 project");
+ expect(onDone).toHaveBeenCalledOnce();
+ if (warning === null && importedCount > 0) {
+ expect(mocks.toast).toHaveBeenCalledWith({
+ type: "success",
+ title: `Imported ${importedCount} ${importedCount === 1 ? "thread" : "threads"}`,
+ });
+ } else if (warning === null) {
+ expect(mocks.toast).not.toHaveBeenCalled();
+ } else {
+ expect(mocks.toast).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "warning", description: warning }),
+ );
+ }
+ },
+);
+
+it("keeps setup open when saving completion fails and preserves the import warning on retry", async () => {
+ mocks.complete.mockRejectedValueOnce(new Error("settings unavailable"));
+ const onDone = vi.fn();
+ await act(async () => root.render());
+ await click("Continue");
+ await click("Continue");
+ await click("Import 1 project");
+ expect(onDone).not.toHaveBeenCalled();
+ expect(mocks.toast).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "error", title: "Could not finish setup" }),
+ );
+ await click("Do not import projects");
+ expect(onDone).toHaveBeenCalledOnce();
+ expect(mocks.importThreads).toHaveBeenCalledOnce();
+ expect(mocks.toast).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ type: "warning",
+ description: "Imported 28 threads. 1 thread could not be imported.",
+ }),
+ );
+});
diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx
index 66e99359abae..942ffcd57ef6 100644
--- a/apps/web/src/components/onboarding/WelcomeWizard.tsx
+++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx
@@ -99,7 +99,7 @@ export function WelcomeWizard({
}: {
/** Whether this client is authenticated to the server serving the app. */
readonly localAvailable: boolean;
- readonly onDone: (projectRef?: ScopedProjectRef) => void;
+ readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise;
}) {
const completeOnboarding = useCompleteOnboarding();
const [step, setStep] = useState("connection");
@@ -141,7 +141,7 @@ export function WelcomeWizard({
};
const stageIndex = step === "agents" ? 1 : step === "import" ? 2 : 0;
const finish = useCallback(
- (projectRef?: ScopedProjectRef) => {
+ (projectRef?: ScopedProjectRef, importWarning?: string, importedThreadCount = 0) => {
if (finishingPromiseRef.current !== null) return finishingPromiseRef.current;
if (completionErrorToastIdRef.current !== null) {
toastManager.close(completionErrorToastIdRef.current);
@@ -149,12 +149,25 @@ export function WelcomeWizard({
}
const completion = completeOnboarding()
- .then(() => {
+ .then(async () => {
if (completionErrorToastIdRef.current !== null) {
toastManager.close(completionErrorToastIdRef.current);
completionErrorToastIdRef.current = null;
}
- onDone(projectRef);
+ await onDone(projectRef);
+ if (importWarning) {
+ toastManager.add({
+ type: "warning",
+ title: "Some history was not imported",
+ description: importWarning,
+ timeout: 0,
+ });
+ } else if (importedThreadCount > 0) {
+ toastManager.add({
+ type: "success",
+ title: `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}`,
+ });
+ }
return true;
})
.catch(() => {
@@ -952,14 +965,19 @@ function ImportStep({
readonly scans: ReturnType;
readonly isImporting: boolean;
readonly setIsImporting: (value: boolean) => void;
- readonly onDone: (projectRef?: ScopedProjectRef) => Promise;
+ readonly onDone: (
+ projectRef?: ScopedProjectRef,
+ importWarning?: string,
+ importedThreadCount?: number,
+ ) => Promise;
}) {
const { environments } = useEnvironments();
const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false });
const projects = useProjects();
const [selectedPaths, setSelectedPaths] = useState | null>(null);
- const [importError, setImportError] = useState("");
+ const importWarningRef = useRef("");
+ const importedThreadCountRef = useRef(0);
const [landingProject, setLandingProject] = useState(null);
// Keep project creation attempts separate from completed history imports so both can retry.
const importedProjectsRef = useRef(new Map());
@@ -988,9 +1006,11 @@ function ImportStep({
)
) {
setLandingProject(null);
- void onDone(landingProject).then((completed) => {
- if (!completed) setIsImporting(false);
- });
+ void onDone(landingProject, importWarningRef.current, importedThreadCountRef.current).then(
+ (completed) => {
+ if (!completed) setIsImporting(false);
+ },
+ );
}
}, [landingProject, onDone, projects, setIsImporting]);
@@ -1020,7 +1040,7 @@ function ImportStep({
importedProjectsRef.current,
);
if (projectRef === undefined) {
- void onDone();
+ void onDone(undefined, importWarningRef.current, importedThreadCountRef.current);
return;
}
setIsImporting(true);
@@ -1034,7 +1054,8 @@ function ImportStep({
return;
}
setIsImporting(true);
- setImportError("");
+ importWarningRef.current = "";
+ importedThreadCountRef.current = 0;
lastImportSelectionRef.current = selection.map((candidate) => candidate.key);
const importGeneration = importGenerationRef.current;
const importedProjects = importedProjectsRef.current;
@@ -1130,23 +1151,17 @@ function ImportStep({
if (refreshEnvironments.has(scan.environmentId)) scan.refresh();
}
setIsImporting(false);
+ importedThreadCountRef.current = importedThreadCount;
if (importedProjectsCount < selection.length) {
if (importedThreadCount > 0 && skippedThreadCount > 0) {
- setImportError(
- `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`,
- );
+ importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. ${skippedThreadCount} ${skippedThreadCount === 1 ? "thread" : "threads"} could not be imported.`;
} else if (skippedThreadCount > 0) {
- setImportError(
- `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`,
- );
+ importWarningRef.current = `${skippedThreadCount} ${skippedThreadCount === 1 ? "thread could" : "threads could"} not be imported.`;
} else if (importedThreadCount > 0) {
- setImportError(
- `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`,
- );
+ importWarningRef.current = `Imported ${importedThreadCount} ${importedThreadCount === 1 ? "thread" : "threads"}. Some thread history could not be imported.`;
} else {
- setImportError("Could not import thread history.");
+ importWarningRef.current = "Could not import thread history.";
}
- return;
}
finishAfterImport();
};
@@ -1253,14 +1268,9 @@ function ImportStep({
})}
- {importError ?
{importError}
: null}
-
}
/>
+
updateSettings({ chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth })}
+ />
+ ) : null
+ }
+ control={
+
+
+
+ }
+ />
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index d2beab3a83e7..8bff06f2769a 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -234,6 +234,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "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/settings/appearance",
searchTerms: ["red green blue orange additions deletions changes counts palette colorblind"],
},
+ {
+ id: "chat-width",
+ title: "Chat width",
+ to: "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/settings/appearance",
+ searchTerms: ["wide full width column layout messages composer monitor"],
+ },
{
id: "panel-animations",
title: "Panel animations",
diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx
index 7792b9606e41..ce9d41edaa5d 100644
--- a/apps/web/src/components/ui/scroll-area.tsx
+++ b/apps/web/src/components/ui/scroll-area.tsx
@@ -30,6 +30,7 @@ function ScrollArea({
hideScrollbars = false,
chainVerticalScroll = false,
radius = "inherit",
+ viewportTabIndex,
...props
}: ScrollAreaPrimitive.Root.Props & {
scrollFade?: boolean;
@@ -41,6 +42,8 @@ function ScrollArea({
chainVerticalScroll?: boolean;
/** The viewport clips to the parent's radius; "none" for a region flush to an edge. */
radius?: "inherit" | "none";
+ /** Override Base UI's focusable viewport when focusable descendants provide scroll access. */
+ viewportTabIndex?: number;
}) {
return (
-
+
{children}
@@ -597,6 +598,10 @@ function SidebarContent({
hideScrollbars
scrollFade
scrollFadePadding={false}
+ // Thread rows provide keyboard access to this scroll region. Keeping
+ // Base UI's viewport out of the tab order lets its presentational role
+ // flatten in WebKit instead of becoming a VoiceOver interaction group.
+ viewportTabIndex={-1}
className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]"
>