diff --git a/README.md b/README.md index 8af4d70e9fc74..a41497b820adc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This is a fork of T3 Code that integrates a few additional features, including: - New Codex/Antigravity logins are picked up mid-session without a full restart. - **Display mode per thread:** Right-click a thread and set Display mode to "Messages only" to hide the agent's work and see just your messages and the replies the agent sends to you with its `send_to_user` tool. The mode applies in the web and desktop apps, not in the mobile app. - "Messages + Dashboard" adds a Dashboard tab to the right panel (+ → Dashboard): six status slots that the agent writes with its `dashboard_set` tool and reads with `dashboard_get`. Each slot shows how long ago it changed, and new text stays highlighted until you click the slot. Select text in a slot to quote it with Cite, as you would in a message. - - "Chat + Replies" keeps the main list short: it shows only first messages, and the replies to a message show in a Replies tab in the right panel (+ → Replies, or the reply line under a message). Point at a message and choose Reply to answer under it; the tab has its own reply box. Under each first message you see how many replies it has, how many are new, and a few generated words on what they are about. The agent answers a message with `send_to_user` and that message's id, so each answer lands under the message it answers. + - "Chat + Replies" keeps the main list short: it shows only first messages, and the replies to a message show in a Replies tab in the right panel (+ → Replies, or the reply line under a message). Point at a message and choose Reply to answer under it; the tab has its own reply box. Under each first message you see how many replies it has, how many are new, and a few generated words on what they are about. The agent answers a message with `send_to_user` and that message's id, so each answer lands under the message it answers. A Cite in the main list opens that message's replies and puts the quote in their reply box, unless the main box or the open reply box already has text; then the quote goes there, the main box first. - In all three modes the agent sends one reply per message you type, and before a long task it may first send a short "on it" note; a thin line parts two agent messages in a row. To say more, it rewrites its reply in place with its `replace_send_to_user` tool. A rewritten reply shows a small "Edited" mark, and in Chat + Replies it counts as new. - Copy buttons on pages in the preview browser now put the copied text into your own clipboard. - **Two preview browser modes:** Settings → Browser → Preview browser picks the browser the server runs for preview tabs. **T3 native** (fast) is the default. It runs chrome-headless-shell, Chrome's small build that runs without a window. **Chrome** (slow) runs full Chrome, for pages that don't work in T3 native. On platforms where T3 has no Chrome for Testing build (Google's Chrome build for test automation), both choices run T3 native. Switching closes the open preview tabs. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 520dd9720f1b4..462e5532ac4c7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -63,7 +63,6 @@ import { AuthSourceControlWriteScope, AuthPreviewOperateScope, AuthTerminalOperateScope, - type AssistantCitation, type ResponseContextRecord, type ChatFileAttachment, isProviderNativeSubagentThread, @@ -132,7 +131,6 @@ import { assistantCitationFromLocation, useSideChatCitationReveal, } from "../lib/assistantCitationNavigation"; -import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useStore } from "zustand"; import { shallow } from "zustand/vanilla/shallow"; import { useShallow } from "zustand/react/shallow"; @@ -368,6 +366,7 @@ import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; import { ChatRightPanel } from "./chat/ChatRightPanel"; import { useDashboardSurfaceAdder } from "../fork/dashboard/dashboardSurface"; import { REPLY_BOX_QUEUE_EDIT_BLOCKED, subThreadDraftId } from "../fork/subThreads/subThreadReply"; +import { useSubThreadCite } from "../fork/subThreads/subThreadCite"; import { SubThreadViewContext } from "../fork/subThreads/subThreadsState"; import { useThreadShownView } from "../fork/displayMode/threadView"; import { timelineCitation } from "../fork/dashboard/dashboardCitation"; @@ -644,21 +643,7 @@ export default function ChatView(props: ChatViewProps) { const composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const branchToolbarRef = useRef(null); - const citeAssistantText = ( - citation: AssistantCitation, - sourceAnchor: AssistantCitationSourceAnchor, - ) => { - const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false; - if (!inserted) { - toastManager.add({ - type: "warning", - title: "The composer is not ready", - description: "Try citing the selection after the connection or pending input is resolved.", - }); - } - return inserted; - }; - const citeAssistantTextStable = useStableCallback(citeAssistantText); + const citeAssistantTextStable = useSubThreadCite(embedded ? null : routeThreadRef, composerRef); const replyToResponseStable = useStableCallback((response: ResponseContextRecord) => { const accepted = composerRef.current?.replyToResponse(response) ?? false; if (!accepted) { diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index 56846307b83da..3bc445bbfa6e7 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -1,5 +1,6 @@ import { type ReactNode } from "react"; +import { sheetInitialFocus } from "../fork/subThreads/subThreadCite"; import { Sheet, SheetHeader, SheetPopup, SheetTitle } from "./ui/sheet"; export function RightPanelSheet(props: { @@ -22,6 +23,7 @@ export function RightPanelSheet(props: { side="right" showCloseButton={false} keepMounted + initialFocus={sheetInitialFocus} className="w-[min(42vw,28rem)] min-w-80 max-w-[28rem] max-[760px]:w-[min(88vw,24rem)] max-[760px]:min-w-0 wco:mt-(--workspace-topbar-height) wco:h-[calc(100%-var(--workspace-topbar-height))] wco:max-h-[calc(100%-var(--workspace-topbar-height))]" > diff --git a/apps/web/src/fork/subThreads/SubThreadsPanel.tsx b/apps/web/src/fork/subThreads/SubThreadsPanel.tsx index 2a0c744587c6d..b044bdbd26baf 100644 --- a/apps/web/src/fork/subThreads/SubThreadsPanel.tsx +++ b/apps/web/src/fork/subThreads/SubThreadsPanel.tsx @@ -2,10 +2,11 @@ import { useAtomValue } from "@effect/atom-react"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef, ThreadSubThread } from "@t3tools/contracts"; import { ArrowLeftIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import ChatView from "../../components/ChatView"; import { BackgroundThreadContext } from "../../components/chat/backgroundThread"; +import type { ChatComposerHandle } from "../../components/chat/ChatComposer"; import { RelativeTime } from "../../components/RelativeTime"; import { ComposerHandleContext } from "../../composerHandleContext"; import type { SideChatParentPanel } from "../../lib/sideChatPane"; @@ -14,6 +15,7 @@ import { useQuietThreadProjection, useThreadHistory } from "../../state/entities import { environmentThreadDetails, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { useThreadDisplayMode } from "../displayMode/useThreadDisplayMode"; +import { registerReplyBox } from "./subThreadCite"; import { markSubThreadRead, type SubThreadSummary, @@ -202,6 +204,10 @@ function SubThreadView(props: { input: { threadId: threadRef.threadId, firstId: first, readAt: latestReply }, }); }, [props.visible, unread, latestReply, markRead, threadRef, first]); + const composer = useRef(null); + const threadKey = scopedThreadKey(threadRef); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells a Cite in the main list which reply box is mounted + useEffect(() => registerReplyBox(threadKey, first, composer), [threadKey, first]); return (
@@ -219,7 +225,7 @@ function SubThreadView(props: {
- + { + const [actual, { fakeDriver }, Layer] = await Promise.all([ + importOriginal<{ readonly ConnectionDriver: Context.Key }>(), + import("../../../test/fakeEnvironment"), + import("effect/Layer"), + ]); + return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, fakeDriver) }; + }, +); + +vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ + ...(await original()), + trackRpcRequestSent: () => {}, +})); + +function threadWithAgentSubThread(name: string) { + const history = threadWithHistory(name, 1); + const { + messageId: _id, + text: _text, + streaming: _streaming, + ...item + } = assistantItem(history.id, history.runs[0]!.id, `${name}-news`, 2, ""); + const news = { + ...item, + type: "dynamic_tool" as const, + toolName: "mcp__t3-code__send_to_user", + input: { text: "The deploy is done.", new_sub_thread: true }, + output: { ok: true }, + }; + return { thread: { ...history, items: [...history.items, news] }, first: news.id }; +} + +const layouts = { + "side panel": { width: 1194, ...threadWithAgentSubThread("cite-side-panel") }, + sheet: { width: 414, ...threadWithAgentSubThread("cite-sheet") }, +}; +const threads = Object.values(layouts).map((layout) => layout.thread); +const settings = { + threadDisplayModes: Object.fromEntries( + threads.map((thread) => [thread.id, CHAT_REPLIES_DISPLAY_MODE.id]), + ), +}; + +const waitForChatRepliesMode = () => + expect.element(page.getByRole("button", { name: "Reply" }).first()).toBeInTheDocument(); + +const selectWithPointer = (text: string) => userEvent.tripleClick(page.getByText(text)); + +it.each(Object.keys(layouts) as Array)( + "a Cite in the main list opens the message's replies in the %s, adds the quote to the saved reply draft and opens its comment box with focus", + async (layout) => { + const { width, thread, first } = layouts[layout]; + const threadRef = scopeThreadRef(ENVIRONMENT_ID, thread.id); + const replyDraft = subThreadDraftId(scopedThreadKey(threadRef), first)!; + using _environment = installFakeEnvironment(threads, { settings }); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + useComposerDraftStore.getState().setPrompt(replyDraft, "saved note"); + await page.viewport(width, 834); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${thread.id}`] }), + ); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await waitForChatRepliesMode(); + await selectWithPointer("The deploy is done."); + await page.getByRole("button", { name: "Cite selection in composer" }).click(); + + const comment = page.getByLabelText("Comment on selected text"); + await expect.element(comment).toBeInTheDocument(); + expect(document.querySelector(`[data-sub-thread-view="${first}"]`)).not.toBeNull(); + await vi.waitFor(() => expect(document.querySelector("[data-starting-style]")).toBeNull()); + await userEvent.keyboard("why?"); + await expect.element(comment).toHaveValue("why?"); + const reply = useComposerDraftStore.getState().getComposerDraft(replyDraft)?.prompt ?? ""; + expect(reply).toContain("saved note"); + expect( + collectAssistantCitations(reply).map(({ citation }) => [citation.messageId, citation.text]), + ).toEqual([[first, "The deploy is done."]]); + expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? "").toBe(""); + }, +); diff --git a/apps/web/src/fork/subThreads/subThreadCite.test.ts b/apps/web/src/fork/subThreads/subThreadCite.test.ts new file mode 100644 index 0000000000000..9b81248de5a0b --- /dev/null +++ b/apps/web/src/fork/subThreads/subThreadCite.test.ts @@ -0,0 +1,204 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type AssistantCitation, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { toastManager } from "../../components/ui/toast"; +import type { AssistantCitationSourceAnchor } from "../../lib/assistantTextSelection"; +import { + selectActiveRightPanel, + selectThreadRightPanelState, + useRightPanelStore, +} from "../../rightPanelStore"; +import { citeInThread, registerReplyBox, sheetInitialFocus } from "./subThreadCite"; +import { openSubThread, useSubThreadsStore } from "./subThreadsState"; + +const threadRef = scopeThreadRef(EnvironmentId.make("environment"), ThreadId.make("thread")); +const threadKey = scopedThreadKey(threadRef); +const citation: AssistantCitation = { + version: 1, + ...threadRef, + messageId: MessageId.make("first-b"), + text: "Pick A", + start: 0, + end: 6, + prefix: "", + suffix: " or B", +}; +const opaqueSourceAnchor = {} as AssistantCitationSourceAnchor; + +function fakeBox(text: string, answer = true) { + const cite = vi.fn(() => answer); + const ref = { + current: { + readSnapshot: () => ({ value: text, cursor: 0, expandedCursor: 0, contextIds: [] }), + citeAssistantText: cite, + }, + }; + return { ref, cite }; +} +type FakeBox = ReturnType; + +function mainView(ownText = "", layout: "inline" | "sheet" = "inline") { + const matchMedia = vi.fn(() => ({ matches: layout === "sheet" })); + vi.stubGlobal("window", { matchMedia }); + useRightPanelStore.setState(useRightPanelStore.getInitialState()); + useSubThreadsStore.setState({ shownByThreadKey: {} }); + const toast = vi.spyOn(toastManager, "add").mockReturnValue("toast"); + const own = fakeBox(ownText); + return { + toast, + ownCite: own.cite, + cite: () => citeInThread(threadRef, "first-b", own.ref, citation, opaqueSourceAnchor), + [Symbol.dispose]: () => { + toast.mockRestore(); + vi.unstubAllGlobals(); + sheetInitialFocus(); + }, + }; +} + +const mounted = (first: string, box: FakeBox): Disposable => ({ + [Symbol.dispose]: registerReplyBox(threadKey, first, box.ref), +}); + +function mountsWhenShown(first: string, box: FakeBox): Disposable { + let unmount: (() => void) | null = null; + const stop = useSubThreadsStore.subscribe((state) => { + if (unmount === null && state.shownByThreadKey[threadKey] === first) { + unmount = registerReplyBox(threadKey, first, box.ref); + } + }); + return { + [Symbol.dispose]: () => { + stop(); + unmount?.(); + }, + }; +} + +const panel = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef); +const repliesOnScreen = () => + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, threadRef) === "replies"; +const shownSubThread = () => useSubThreadsStore.getState().shownByThreadKey[threadKey]; + +describe("a Cite in the main list of a Chat + Replies thread", () => { + it("goes into the main box when it has text", () => { + using view = mainView(" my note "); + const reply = fakeBox("a reply draft"); + using _reply = mounted("first-a", reply); + openSubThread(threadRef, "first-a"); + + expect(view.cite()).toBe(true); + expect(view.ownCite).toHaveBeenCalledExactlyOnceWith(citation, opaqueSourceAnchor); + expect(reply.cite).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + "goes into the shown reply box with text, and returns its answer %s", + (answer) => { + using view = mainView(); + const reply = fakeBox("a reply draft", answer); + using _reply = mounted("first-a", reply); + openSubThread(threadRef, "first-a"); + + expect(view.cite()).toBe(answer); + expect(reply.cite).toHaveBeenCalledExactlyOnceWith(citation, opaqueSourceAnchor); + expect(view.ownCite).not.toHaveBeenCalled(); + expect(shownSubThread()).toBe("first-a"); + expect(view.toast).toHaveBeenCalledTimes(answer ? 0 : 1); + }, + ); + + it.each([ + { start: "the panel is closed and was left maximized", shown: null }, + { start: "Replies shows another sub-thread with an empty box", shown: "first-a" }, + { start: "the cited box is mounted while the panel is closed", shown: "first-b" }, + ])("opens the cited sub-thread and goes into its box when $start", ({ shown }) => { + using view = mainView(); + const store = useRightPanelStore.getState(); + const other = fakeBox(""); + const cited = fakeBox(""); + using _other = shown === "first-a" ? mounted("first-a", other) : null; + using _cited = + shown === "first-b" ? mounted("first-b", cited) : mountsWhenShown("first-b", cited); + if (shown === null) store.setMaximized(threadRef, true); + else openSubThread(threadRef, shown); + if (shown === "first-b") store.close(threadRef); + const revision = store.getUserActionRevision(threadRef); + + expect(view.cite()).toBe(true); + expect(cited.cite).toHaveBeenCalledExactlyOnceWith(citation, opaqueSourceAnchor); + expect(other.cite).not.toHaveBeenCalled(); + expect(view.ownCite).not.toHaveBeenCalled(); + expect(shownSubThread()).toBe("first-b"); + expect(repliesOnScreen()).toBe(true); + expect(panel().maximized).toBeUndefined(); + expect(store.getUserActionRevision(threadRef)).toBe(revision + 1); + }); + + it("returns the cited box's refusal after opening it", () => { + using view = mainView(); + const cited = fakeBox("", false); + using _cited = mountsWhenShown("first-b", cited); + + expect(view.cite()).toBe(false); + expect(cited.cite).toHaveBeenCalledOnce(); + expect(view.toast).toHaveBeenCalledOnce(); + }); + + it("keeps the selection when no reply box mounts after the open", () => { + using view = mainView(); + + expect(view.cite()).toBe(false); + expect(view.toast).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ title: "Could not open the reply box" }), + ); + expect(view.ownCite).not.toHaveBeenCalled(); + }); + + it("does not count a mounted reply box as shown while the panel is closed", () => { + using view = mainView(); + const hidden = fakeBox("a reply draft"); + using _hidden = mounted("first-a", hidden); + openSubThread(threadRef, "first-a"); + useRightPanelStore.getState().close(threadRef); + const cited = fakeBox(""); + using _cited = mountsWhenShown("first-b", cited); + + expect(view.cite()).toBe(true); + expect(hidden.cite).not.toHaveBeenCalled(); + expect(cited.cite).toHaveBeenCalledOnce(); + }); + + it("treats a box that shows only whitespace as empty", () => { + using view = mainView(" \n\t"); + const blank = fakeBox(" \n"); + using _blank = mounted("first-a", blank); + openSubThread(threadRef, "first-a"); + const cited = fakeBox(""); + using _cited = mountsWhenShown("first-b", cited); + + expect(view.cite()).toBe(true); + expect(view.ownCite).not.toHaveBeenCalled(); + expect(blank.cite).not.toHaveBeenCalled(); + expect(cited.cite).toHaveBeenCalledOnce(); + expect(shownSubThread()).toBe("first-b"); + }); + + it.each([ + { layout: "sheet", answer: true, sheetFocuses: false }, + { layout: "sheet", answer: false, sheetFocuses: true }, + { layout: "inline", answer: true, sheetFocuses: true }, + ] as const)( + "leaves focus to the comment box when it opens the sheet: $layout, box answers $answer", + ({ layout, answer, sheetFocuses }) => { + using view = mainView("", layout); + using _cited = mountsWhenShown("first-b", fakeBox("", answer)); + + expect(view.cite()).toBe(answer); + expect(sheetInitialFocus()).toBe(sheetFocuses); + expect(sheetInitialFocus()).toBe(true); + }, + ); +}); diff --git a/apps/web/src/fork/subThreads/subThreadCite.ts b/apps/web/src/fork/subThreads/subThreadCite.ts new file mode 100644 index 0000000000000..25d4aedb1695d --- /dev/null +++ b/apps/web/src/fork/subThreads/subThreadCite.ts @@ -0,0 +1,122 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { AssistantCitation, ScopedThreadRef } from "@t3tools/contracts"; +import type { RefObject } from "react"; +import { flushSync } from "react-dom"; + +import type { ChatComposerHandle } from "../../components/chat/ChatComposer"; +import { toastManager } from "../../components/ui/toast"; +import { useStableCallback } from "../../hooks/useStableCallback"; +import type { AssistantCitationSourceAnchor } from "../../lib/assistantTextSelection"; +import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../../rightPanelLayout"; +import { selectActiveRightPanel, useRightPanelStore } from "../../rightPanelStore"; +import type { CiteAssistantText } from "../dashboard/dashboardCitation"; +import { useThreadDisplayMode } from "../displayMode/useThreadDisplayMode"; +import { openSubThread, useSubThreadSummaries } from "./subThreadsState"; + +type CiteComposer = RefObject | null>; + +interface ReplyBox { + readonly first: string; + readonly composer: CiteComposer; +} + +// Set while a Cite opens the Replies sheet; the sheet's next initial focus takes it. +let sheetLeavesFocusToCite = false; +const replyBoxByThreadKey = new Map(); + +export function registerReplyBox( + threadKey: string, + first: string, + composer: CiteComposer, +): () => void { + const box = { first, composer }; + replyBoxByThreadKey.set(threadKey, box); + return () => { + if (replyBoxByThreadKey.get(threadKey) === box) replyBoxByThreadKey.delete(threadKey); + }; +} + +const hasText = (composer: CiteComposer) => + (composer.current?.readSnapshot().value ?? "").trim() !== ""; + +function citeInto( + composer: CiteComposer, + citation: AssistantCitation, + sourceAnchor: AssistantCitationSourceAnchor, +): boolean { + const inserted = composer.current?.citeAssistantText(citation, sourceAnchor) ?? false; + if (!inserted) { + toastManager.add({ + type: "warning", + title: "The composer is not ready", + description: "Try citing the selection after the connection or pending input is resolved.", + }); + } + return inserted; +} + +/** + * Opens as the user's own panel choice, so a waiting automatic Diff cannot replace it, and with no + * focus request, so focus stays with the quote's comment box. + */ +function openReplyBoxBesideMainList(threadRef: ScopedThreadRef, first: string): void { + const panel = useRightPanelStore.getState(); + panel.markUserAction(threadRef); + panel.setMaximized(threadRef, false); + openSubThread(threadRef, first, true); +} + +export function citeInThread( + threadRef: ScopedThreadRef, + cited: string, + own: CiteComposer, + citation: AssistantCitation, + sourceAnchor: AssistantCitationSourceAnchor, +): boolean { + if (hasText(own)) return citeInto(own, citation, sourceAnchor); + const threadKey = scopedThreadKey(threadRef); + const repliesOnScreen = + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, threadRef) === "replies"; + const shown = repliesOnScreen ? replyBoxByThreadKey.get(threadKey) : undefined; + if (shown && hasText(shown.composer)) return citeInto(shown.composer, citation, sourceAnchor); + if (shown?.first !== cited) { + sheetLeavesFocusToCite = window.matchMedia(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY).matches; + // A sync commit runs its passive effects before returning, so the box has loaded its draft. + flushSync(() => openReplyBoxBesideMainList(threadRef, cited)); + } + const box = replyBoxByThreadKey.get(threadKey); + if (box?.first !== cited) { + sheetLeavesFocusToCite = false; + toastManager.add({ type: "warning", title: "Could not open the reply box" }); + return false; + } + const inserted = citeInto(box.composer, citation, sourceAnchor); + if (!inserted) sheetLeavesFocusToCite = false; + return inserted; +} + +export function useSubThreadCite( + routingThreadRef: ScopedThreadRef | null, + composer: CiteComposer, +): CiteAssistantText { + const routes = useThreadDisplayMode(routingThreadRef).subThreads; + const summaries = useSubThreadSummaries(routes ? routingThreadRef : null); + return useStableCallback((citation, sourceAnchor) => + routingThreadRef !== null && summaries.has(citation.messageId) + ? citeInThread(routingThreadRef, citation.messageId, composer, citation, sourceAnchor) + : citeInto(composer, citation, sourceAnchor), + ); +} + +/** + * The right panel sheet's initial focus. When a Cite opened the sheet, focus is left to the + * quote's comment box; otherwise the sheet focuses as usual. + */ +export function sheetInitialFocus(): boolean { + const leave = sheetLeavesFocusToCite; + sheetLeavesFocusToCite = false; + return !leave; +} diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index 24aba2f822ea1..396b8a2b334a7 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -99,7 +99,10 @@ export const CLAUDE_PROVIDER = { }; const decodeServerConfig = Schema.decodeUnknownSync(ServerConfig); -const makeServerConfig = (providers: ReadonlyArray): ServerConfig => ({ +const makeServerConfig = ( + providers: ReadonlyArray, + settings: unknown = {}, +): ServerConfig => ({ ...decodeServerConfig({ environment: descriptor, auth, @@ -115,7 +118,7 @@ const makeServerConfig = (providers: ReadonlyArray): ServerConfig => ({ otlpTracesEnabled: false, otlpMetricsEnabled: false, }, - settings: {}, + settings, threadSnapshotPagination: true, threadResumeCompletionMarker: true, shellResumeCompletionMarker: true, @@ -511,10 +514,11 @@ export function installFakeEnvironment( /** Commands left unanswered even when accepting, until `rejectPendingCommand`. */ readonly holdCommand?: (command: OrchestrationV2Command) => boolean; readonly providers?: ReadonlyArray; + readonly settings?: unknown; }, ): Disposable { threads = fixtures; - baseServerConfig = makeServerConfig(options?.providers ?? [CODEX_PROVIDER]); + baseServerConfig = makeServerConfig(options?.providers ?? [CODEX_PROVIDER], options?.settings); dispatched = []; acceptCommands = options?.acceptCommands === true; holdCommand = options?.holdCommand ?? (() => false);