Skip to content
Merged
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
24 changes: 14 additions & 10 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1521,9 +1523,11 @@ function ChatViewContent(props: ChatViewProps) {
>({});
const [isConnecting, _setIsConnecting] = useState(false);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>(
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<ApprovalRequestId[]>([]);
const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
ApprovalRequestId[]
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 0 additions & 4 deletions apps/web/src/components/chat/TraitsPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -557,19 +557,15 @@ export const TraitsPicker = memo(function TraitsPicker({
<span className="flex min-w-0 w-full items-center gap-1.5 overflow-hidden">
{fastModeIcon}
<span className="min-w-0 truncate">{triggerLabel}</span>
// T3-CUSTOM(expbkt3): the fork setting is planModeAvailable (fresh key, default on).
<ComposerControlChevron />
// T3-CUSTOM(expbkt3): the fork setting is planModeAvailable (fresh key, default on).
</span>
) : (
<>
// T3-CUSTOM(expbkt3): the fork setting is planModeAvailable (fresh key, default on).
{fastModeIcon}
<span>{triggerLabel}</span>
<ComposerControlChevron />
</>
)}
// T3-CUSTOM(expbkt3): the fork setting is planModeAvailable (fresh key, default on).
</MenuTrigger>
<MenuPopup align="start">
<TraitsMenuContent
Expand Down
83 changes: 83 additions & 0 deletions apps/web/src/rightPanelLayoutPreference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* T3-CUSTOM(expbkt3): the right panel's shape has to survive a thread switch.
*
* The hook itself is a thin `useLocalStorage` wrapper, so the behaviour worth
* pinning is the durable part: which key the choice lands under, that it
* round-trips, and that a fresh workspace still gets upstream's side-by-side
* default. That is what a thread switch reads back.
*/
import * as Schema from "effect/Schema";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

function createStorage(): Storage {
const store = new Map<string, string>();
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");
});
});
31 changes: 31 additions & 0 deletions apps/web/src/rightPanelLayoutPreference.ts
Original file line number Diff line number Diff line change
@@ -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);
}
87 changes: 86 additions & 1 deletion scripts/check-fork-markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,78 @@ function readBaseline(): ReadonlySet<string> {
);
}

/**
* 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<string>, 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<string>,
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");
Expand All @@ -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();
Expand Down Expand Up @@ -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), ` +
Expand Down
Loading