diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 026de58bddce..2f445afe50ee 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -253,6 +253,8 @@ import { import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; // T3-CUSTOM(expbkt3): buildThreadRouteParams is used by the fork's promotion navigation. import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; +// T3-CUSTOM(expbkt3): durable right-panel layout preference. +import { useRightPanelMaximizedPreference } from "../rightPanelLayoutPreference"; // T3-CUSTOM(expbkt3): Keep delayed draft promotion from stealing navigation. import { useDraftPromotionNavigationGuard } from "../hooks/useDraftPromotionNavigationGuard"; import { @@ -1521,9 +1523,11 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( - null, - ); + // T3-CUSTOM(expbkt3): upstream keeps "is the right panel maximized" as a + // per-thread key in component state, so the layout resets on every thread + // switch and reload. Persist the choice instead — see rightPanelLayoutPreference. + const [rightPanelMaximizedPreference, setRightPanelMaximizedPreference] = + useRightPanelMaximizedPreference(); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] @@ -1956,8 +1960,8 @@ function ChatViewContent(props: ChatViewProps) { const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; - const rightPanelMaximized = - canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; + // T3-CUSTOM(expbkt3): every thread opens the panel in the last shape the user chose. + const rightPanelMaximized = canMaximizeRightPanel && rightPanelMaximizedPreference; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; useEffect(() => { @@ -4058,7 +4062,8 @@ function ChatViewContent(props: ChatViewProps) { }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]); const closePreviewPanel = useCallback(() => { if (activeThreadRef) { - setMaximizedRightPanelThreadKey(null); + // T3-CUSTOM(expbkt3): upstream cleared the maximized flag here. The fork + // keeps it: closing a panel is not a decision about how the next one opens. useRightPanelStore.getState().close(activeThreadRef); } }, [activeThreadRef]); @@ -4266,10 +4271,9 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, closePreviewPanel, rightPanelOpen]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; - setMaximizedRightPanelThreadKey((threadKey) => - threadKey === routeThreadKey ? null : routeThreadKey, - ); - }, [canMaximizeRightPanel, routeThreadKey]); + // T3-CUSTOM(expbkt3): the toggle records a durable preference, not thread state. + setRightPanelMaximizedPreference((maximized) => !maximized); + }, [canMaximizeRightPanel, setRightPanelMaximizedPreference]); const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 228a001827e4..d3298d57c134 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -557,19 +557,15 @@ export const TraitsPicker = memo(function TraitsPicker({ {fastModeIcon} {triggerLabel} - // T3-CUSTOM(expbkt3): the fork setting is planModeAvailable (fresh key, default on). - // T3-CUSTOM(expbkt3): the fork setting is planModeAvailable (fresh key, default on). ) : ( <> - // T3-CUSTOM(expbkt3): the fork setting is planModeAvailable (fresh key, default on). {fastModeIcon} {triggerLabel} )} - // T3-CUSTOM(expbkt3): the fork setting is planModeAvailable (fresh key, default on). (); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + }; +} + +async function loadWithStorage(storage: Storage) { + vi.stubGlobal("window", { localStorage: storage }); + vi.stubGlobal("localStorage", storage); + const storageModule = await import("./hooks/useLocalStorage"); + const preferenceModule = await import("./rightPanelLayoutPreference"); + return { ...storageModule, ...preferenceModule }; +} + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); +}); + +describe("right panel layout preference", () => { + it("reads as side-by-side until the user picks full screen", async () => { + const { getLocalStorageItem, RIGHT_PANEL_MAXIMIZED_STORAGE_KEY } = + await loadWithStorage(createStorage()); + + expect(getLocalStorageItem(RIGHT_PANEL_MAXIMIZED_STORAGE_KEY, Schema.Boolean)).toBe(null); + }); + + it("round-trips full screen, so the next thread opens the same way", async () => { + const storage = createStorage(); + const { setLocalStorageItem, RIGHT_PANEL_MAXIMIZED_STORAGE_KEY } = + await loadWithStorage(storage); + + setLocalStorageItem(RIGHT_PANEL_MAXIMIZED_STORAGE_KEY, true, Schema.Boolean); + + // Re-import against the same storage: this is what opening a panel in + // another thread — or reloading the app — actually sees. + vi.resetModules(); + const reloaded = await loadWithStorage(storage); + expect( + reloaded.getLocalStorageItem(reloaded.RIGHT_PANEL_MAXIMIZED_STORAGE_KEY, Schema.Boolean), + ).toBe(true); + }); + + it("goes back to side-by-side when the user toggles out of full screen", async () => { + const storage = createStorage(); + const { getLocalStorageItem, setLocalStorageItem, RIGHT_PANEL_MAXIMIZED_STORAGE_KEY } = + await loadWithStorage(storage); + + setLocalStorageItem(RIGHT_PANEL_MAXIMIZED_STORAGE_KEY, true, Schema.Boolean); + setLocalStorageItem(RIGHT_PANEL_MAXIMIZED_STORAGE_KEY, false, Schema.Boolean); + + expect(getLocalStorageItem(RIGHT_PANEL_MAXIMIZED_STORAGE_KEY, Schema.Boolean)).toBe(false); + }); + + it("keeps the panel width under its own durable key", async () => { + // Width already persists upstream; the preference must not collide with it. + const { RIGHT_PANEL_MAXIMIZED_STORAGE_KEY } = await loadWithStorage(createStorage()); + expect(RIGHT_PANEL_MAXIMIZED_STORAGE_KEY).not.toBe("t3code:preview-panel-width"); + }); +}); diff --git a/apps/web/src/rightPanelLayoutPreference.ts b/apps/web/src/rightPanelLayoutPreference.ts new file mode 100644 index 000000000000..d92757d79a38 --- /dev/null +++ b/apps/web/src/rightPanelLayoutPreference.ts @@ -0,0 +1,31 @@ +/** + * T3-CUSTOM(expbkt3): the right panel's layout is a workspace habit, not a + * property of one thread. + * + * Upstream tracks "is the right panel maximized" as a per-thread key held in + * component state, so it resets on reload and on every thread switch: open a + * plan full-screen in one thread, open a plan in the next, and it comes back + * side-by-side. People pick a working shape once (full-screen for reading a + * plan, side-by-side for editing next to the chat) and expect it to stick. + * + * The panel's *width* is already durable — PreviewPanelShell persists it under + * `t3code:preview-panel-width` for every surface that does not override the + * key — so only the maximized/side-by-side choice needs a home. Keeping that + * here rather than inline in ChatView.tsx keeps the upstream merge surface to a + * handful of marked lines. + * + * @module rightPanelLayoutPreference + */ +import * as Schema from "effect/Schema"; + +import { useLocalStorage } from "./hooks/useLocalStorage"; + +export const RIGHT_PANEL_MAXIMIZED_STORAGE_KEY = "t3code:right-panel-maximized"; + +/** + * Whether a right-panel surface should open full-screen. Defaults to false, so + * a first-time user still gets upstream's side-by-side layout. + */ +export function useRightPanelMaximizedPreference() { + return useLocalStorage(RIGHT_PANEL_MAXIMIZED_STORAGE_KEY, false, Schema.Boolean); +} diff --git a/scripts/check-fork-markers.ts b/scripts/check-fork-markers.ts index 40919ae77757..17a8e52cbf3d 100644 --- a/scripts/check-fork-markers.ts +++ b/scripts/check-fork-markers.ts @@ -219,6 +219,78 @@ function readBaseline(): ReadonlySet { ); } +/** + * A marker comment in JSX *children* position is rendered by React as literal + * text. A 2026-08-27 upstream merge shipped four of them into the composer's + * traits chip and the settings model row, where users saw + * "// T3-CUSTOM(expbkt3): ..." printed beside the model picker. + * + * Markers between a JSX element's attributes are legal, so the scan first walks + * back to decide whether the comment sits inside an unterminated opening tag. + */ +function isInsideOpeningTag(lines: ReadonlyArray, index: number): boolean { + for (let cursor = index - 1; cursor >= 0 && cursor > index - 40; cursor -= 1) { + const line = lines[cursor]?.trim() ?? ""; + if (line.length === 0 || line.startsWith("//")) continue; + if ( + line.endsWith(">") || + line.endsWith("/>") || + line.endsWith(")") || + line.endsWith(";") || + line.endsWith("{") || + line.endsWith(",") + ) { + return false; + } + if (/<[A-Za-z][\w.]*$/.test(line)) return true; + } + return false; +} + +function neighbourLine( + lines: ReadonlyArray, + index: number, + step: 1 | -1, + skipComments: boolean, +): string { + for (let cursor = index + step; cursor >= 0 && cursor < lines.length; cursor += step) { + const line = lines[cursor]?.trim() ?? ""; + if (line.length === 0) continue; + if (skipComments && line.startsWith("//")) continue; + return line; + } + return ""; +} + +/** Every `.tsx` file in the repo, minus vendored and generated trees. */ +function tsxFiles(): string[] { + return git(["ls-files", "*.tsx"]) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith(".repos/")); +} + +export function findRenderedMarkers(): Violation[] { + const violations: Violation[] = []; + for (const file of tsxFiles()) { + if (!NodeFS.existsSync(file)) continue; + const lines = NodeFS.readFileSync(file, "utf8").split("\n"); + lines.forEach((rawLine, index) => { + if (!rawLine.trim().startsWith(`// ${MARKER}`)) return; + if (isInsideOpeningTag(lines, index)) return; + const previous = neighbourLine(lines, index, -1, true); + const next = neighbourLine(lines, index, 1, false); + const closesJsx = + previous.endsWith(">") || previous.endsWith("<>") || previous.endsWith(")}"); + const opensJsx = next.startsWith("<") || next.startsWith("{"); + if (closesJsx && opensJsx) { + violations.push({ file, startLine: index + 1, lineCount: 1 }); + } + }); + } + return violations; +} + function main(): number { const argv = new Set(process.argv.slice(2)); const writeBaseline = argv.has("--write-baseline"); @@ -240,6 +312,19 @@ function main(): number { return 0; } + // Rendered markers are never baselined: they are a visible product defect, + // not merge debt, and the fix is always to delete or move one comment. + const rendered = findRenderedMarkers(); + if (rendered.length > 0) { + console.error( + `\n${MARKER} comments in JSX children position are rendered to users as text.\n` + + "Delete them, or move them into the element's attribute list.\n", + ); + for (const violation of rendered) { + console.error(` ${violation.file}:${violation.startLine}`); + } + } + const baseline = readBaseline(); const unmarked = [...offending].filter((file) => !baseline.has(file)).sort(); const nowClean = [...baseline].filter((file) => !offending.has(file)).sort(); @@ -267,7 +352,7 @@ function main(): number { for (const file of nowClean) console.error(` ${file}`); } - if (unmarked.length === 0 && nowClean.length === 0) { + if (unmarked.length === 0 && nowClean.length === 0 && rendered.length === 0) { const skipped = baseline.size; console.log( `Fork marker check passed. ${modified.length} modified upstream file(s), ` +