Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5854dbf
fix(web): sidebar reveals the active thread after back/forward naviga…
juliusmarminge Oct 21, 2026
3843854
fix(web): sidebar reveals the active thread after returning from Sett…
juliusmarminge Oct 21, 2026
a39cce2
test(web): add synthetic sidebar navigation fixtures
juliusmarminge Oct 22, 2026
32099f6
fix(web): sidebar reveal survives drawer remounts and late rows
juliusmarminge Oct 22, 2026
2c84c27
fix(web): sidebar scrolls to the open thread after a remount
juliusmarminge Oct 22, 2026
2d95414
test(web): extend synthetic sidebar capture fixtures
juliusmarminge Oct 22, 2026
a0462df
test(web): handle URL inputs in sidebar snapshot replay
juliusmarminge Oct 22, 2026
8bc79af
test(web): seed rendered navigation transcripts and settled tail
juliusmarminge Oct 22, 2026
1c7322e
fix(web): returning from Settings keeps the sidebar project scope
juliusmarminge Oct 22, 2026
6f09fcc
Merge main into fix/issue-16534
juliusmarminge Oct 22, 2026
ab95d44
chore(lint): knip ignores injected sidebar fixture scripts
juliusmarminge Oct 22, 2026
a923574
fix(web): sidebar reveal waits for search to clear and the row to mount
juliusmarminge Oct 22, 2026
1d04ab0
fix(fixtures): align sidebar replay across shell transports
juliusmarminge Oct 22, 2026
6de1d63
fix(fixtures): preserve callable Date in sidebar capture clock
juliusmarminge Oct 22, 2026
18bb128
fix(tooling): analyze injected sidebar fixtures as Knip entries
juliusmarminge Oct 22, 2026
cc2ac60
fix(tooling): preserve replay shell sequence continuity
juliusmarminge Oct 22, 2026
44215da
fix(fixtures): guard settled replay expansion without source
juliusmarminge Oct 22, 2026
a9dcdf3
fix(fixtures): translate shell resume cursors after replay
juliusmarminge Oct 22, 2026
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
9 changes: 7 additions & 2 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import ThreadSidebar from "./Sidebar";
import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome";
import { MainAppLocationTracker } from "./sidebar/mainAppLocation";
import type { SidebarRouteReveal } from "./sidebar/routeReveal";
import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
import { useProjects } from "../state/entities";
import {
Expand Down Expand Up @@ -330,6 +331,10 @@ function ProjectProjectionRetention() {
export function AppSidebarLayout({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const legacySidebarEnabled = useLegacySidebarEnabled();
const [sidebarRouteReveal] = useState<SidebarRouteReveal>(() => ({
observedKey: null,
pendingKey: null,
}));
const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
usePanelAnimationSettings();
useThreadVisitedMigration();
Expand Down Expand Up @@ -422,9 +427,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
<SettingsSidebarNav pathname={pathname} />
</>
) : legacySidebarEnabled ? (
<LegacyThreadSidebar />
<LegacyThreadSidebar routeReveal={sidebarRouteReveal} />
) : (
<ThreadSidebar />
<ThreadSidebar routeReveal={sidebarRouteReveal} />
)}
<SidebarRail onDoubleClick={resetSidebarWidth} />
</Sidebar>
Expand Down
65 changes: 51 additions & 14 deletions apps/web/src/components/LegacySidebar.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ import {
} from "../sidebarProjectGrouping";
import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget";
import {
completeSidebarRouteReveal,
pendingSidebarRouteReveal,
type SidebarRouteReveal,
} from "./sidebar/routeReveal";
const SIDEBAR_SORT_LABELS: Record<SidebarProjectSortOrder, string> = {
updated_at: "Last user message",
created_at: "Created at",
Expand Down Expand Up @@ -1199,20 +1204,26 @@ function useProjectThreadPanel(input: {
(settings) => settings.sidebarThreadPreviewCount,
);
const activeThreadKey = activeRouteThreadKey ?? undefined;
const pinnedCollapsedThread =
!activeThreadKey || projectExpanded
? null
: (visibleProjectThreads.find(
(thread) =>
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey,
) ?? null);
const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount;
const activeThread = activeThreadKey
? (visibleProjectThreads.find(
(thread) =>
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey,
) ?? null)
: null;
const pinnedCollapsedThread = projectExpanded ? null : activeThread;
// The open thread stays listed past the preview cut, as it does in a
// collapsed project, so it alone does not overflow the preview.
const hasOverflowingThreads = visibleProjectThreads.some(
(thread, index) =>
index >= sidebarThreadPreviewCount &&
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) !== activeThreadKey,
);
const previewThreads =
isThreadListExpanded || !hasOverflowingThreads
? visibleProjectThreads
: visibleProjectThreads.slice(0, sidebarThreadPreviewCount);
const visibleThreadKeys = new Set(
[...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) =>
[...previewThreads, ...(activeThread ? [activeThread] : [])].map((thread) =>
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
),
);
Expand Down Expand Up @@ -3202,10 +3213,17 @@ function useLegacySidebarThreads(input: {
isThreadListExpanded || !hasOverflowingThreads
? projectThreads
: projectThreads.slice(0, sidebarThreadPreviewCount);
const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads;
return renderedThreads.map((thread) =>
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
);
if (pinnedCollapsedThread) {
return [
scopedThreadKey(
scopeThreadRef(pinnedCollapsedThread.environmentId, pinnedCollapsedThread.id),
),
];
}
return projectThreads.flatMap((thread, index) => {
const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
return index < previewThreads.length || threadKey === activeThreadKey ? [threadKey] : [];
});
});
const threadJumpCommandByKey = new Map<
string,
Expand Down Expand Up @@ -3234,7 +3252,7 @@ function useLegacySidebarThreads(input: {
};
}

export default function LegacySidebar() {
export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) {
const reorderProjects = useUiStateStore((store) => store.reorderProjects);
const navigate = useNavigate();
const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder);
Expand Down Expand Up @@ -3298,6 +3316,25 @@ export default function LegacySidebar() {
sidebarThreadPreviewCount,
expandedThreadListsByProject,
});
// History and the command palette can open a thread scrolled out of view,
// and a remount starts the list at the top, so each route change and each
// mount scroll to the open thread once. Its row can mount after the route
// does, once its environment's shells arrive, so the reveal waits for it.
const mountRevealOwed = useRef(true);
const routeRowRendered =
routeThreadKey !== null && visibleSidebarThreadKeys.includes(routeThreadKey);
const revealRouteRow = useStableCallback(() => {
const pendingKey = pendingSidebarRouteReveal(routeReveal, routeThreadKey);
if (pendingKey === null && (!mountRevealOwed.current || routeThreadKey === null)) return;
const activeRow = document.querySelector(
'[data-sidebar="content"] [data-thread-item] [data-active="true"]',
);
if (!activeRow) return;
completeSidebarRouteReveal(routeReveal);
mountRevealOwed.current = false;
activeRow.scrollIntoView({ block: "nearest" });
});
useEffect(revealRouteRow, [revealRouteRow, routeThreadKey, routeRowRendered]);
const newThreadShortcutLabelOptions = {
platform,
context: {
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
type Thread,
} from "../types";
import { makeThreadFixture, type ThreadFixtureOverrides } from "../test-fixtures";
import { completeSidebarRouteReveal, pendingSidebarRouteReveal } from "./sidebar/routeReveal";

const localEnvironmentId = EnvironmentId.make("environment-local");

Expand Down Expand Up @@ -1216,3 +1217,24 @@ describe("Working shelf (beta)", () => {
});
});
});

describe("pendingSidebarRouteReveal", () => {
it("keeps a revealed thread settled across a mount that reads no route params", () => {
const reveal = { observedKey: null, pendingKey: null };
expect(pendingSidebarRouteReveal(reveal, "env:a1")).toBe("env:a1");
completeSidebarRouteReveal(reveal);

// Back from Settings: location commits before the thread match does.
expect(pendingSidebarRouteReveal(reveal, null)).toBeNull();
expect(pendingSidebarRouteReveal(reveal, "env:a1")).toBeNull();

expect(pendingSidebarRouteReveal(reveal, "env:b1")).toBe("env:b1");
});

it("keeps a pending reveal through a render without route params", () => {
const reveal = { observedKey: null, pendingKey: null };
pendingSidebarRouteReveal(reveal, "env:a1");
expect(pendingSidebarRouteReveal(reveal, null)).toBeNull();
expect(pendingSidebarRouteReveal(reveal, "env:a1")).toBe("env:a1");
});
});
45 changes: 44 additions & 1 deletion apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Side
import { ProjectFavicon } from "./ProjectFavicon";
import { deriveProviderEntriesByEnvironment } from "../providerInstances";
import { useStableCallback } from "../hooks/useStableCallback";
import {
completeSidebarRouteReveal,
pendingSidebarRouteReveal,
type SidebarRouteReveal,
} from "./sidebar/routeReveal";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Button } from "./ui/button";
import {
Expand Down Expand Up @@ -741,7 +746,7 @@ const SIDEBAR_DRAG_DISTANCE = 6;

const SIDEBAR_DRAG_LABEL_HEIGHT = 24;

export default function Sidebar() {
export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) {
const {
projects,
serverConfigs,
Expand Down Expand Up @@ -1061,6 +1066,44 @@ export default function Sidebar() {
});

const isSearchingThreads = threadSearchQuery.trim().length > 0;

// History, the command palette, and links can open a thread the project
// scope hides or the list has scrolled away. A remount starts the list at
// the top, so each mount also scrolls to the open thread once, without
// widening the scope.
const mountRevealOwed = useRef(true);
const revealRouteRow = useStableCallback(() => {
const pendingKey = pendingSidebarRouteReveal(routeReveal, routeRowThreadKey);
// Search results replace the thread list; reveal once the list returns.
if (isSearchingThreads) return;
const revealKey = pendingKey ?? (mountRevealOwed.current ? routeRowThreadKey : null);
if (revealKey === null) return;
const thread = threads.find((candidate) => threadShellKey(candidate) === revealKey);
if (thread === undefined) return;
if (
pendingKey !== null &&
scopedProjectKeys !== null &&
filterSidebarV2VisibleThreads([thread], scopedProjectKeys).length === 0 &&
filterSidebarV2VisibleThreads([thread], null).length === 1
) {
// The row renders under the widened scope; scroll on that pass.
setProjectScopeKey(null);
return;
}
const activeRow = document.querySelector('[data-sidebar="content"] [aria-current="page"]');
if (!activeRow) return;
completeSidebarRouteReveal(routeReveal);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
mountRevealOwed.current = false;
activeRow.scrollIntoView({ block: "nearest" });
});
useEffect(revealRouteRow, [
revealRouteRow,
routeRowThreadKey,
threads,
projectScopeKey,
isSearchingThreads,
]);

const threadSearchMatchByKey = new Map(
threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const),
);
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/components/sidebar/routeReveal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Owned by AppSidebarLayout because the narrow drawer and Settings unmount the
// thread sidebar without changing the route. Only a route change requests a
// reveal that may widen the project scope, so a scope chosen while one thread
// stays open survives a remount. Scroll position does not survive one.
//
// A null key is not an observation: leaving Settings commits the router's
// location before its matches, so the sidebar's first render can read no
// thread params. Recording that would make the same thread look new.
export interface SidebarRouteReveal {
observedKey: string | null;
pendingKey: string | null;
}

export function pendingSidebarRouteReveal(
reveal: SidebarRouteReveal,
routeKey: string | null,
): string | null {
if (routeKey === null) return null;
if (reveal.observedKey !== routeKey) {
reveal.observedKey = routeKey;
reveal.pendingKey = routeKey;
}
return reveal.pendingKey;
}

export function completeSidebarRouteReveal(reveal: SidebarRouteReveal): void {
reveal.pendingKey = null;
}
2 changes: 2 additions & 0 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
// Windows verify script with esbuild through a path knip does not trace, and
// electron-builder loads the macOS signer by path.
"entry": [
// These fixture scripts are injected into a browser page by path.
"fixtures/issue-16534/*.js",
"knip-schemas.ts",
"mobile-native-client.ts",
"setup-worktree.ts",
Expand Down
32 changes: 32 additions & 0 deletions scripts/fixtures/issue-16534/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
These fixtures reconstruct synthetic sidebar projections and client reset inputs.
They never start a provider or read personal application data. Projection-only
fixtures prove rendering/navigation, not event-sourced backend behavior.

Generate with `python3 scripts/fixtures/issue-16534/seed.py --output /tmp/sidebar-seed
--variant all`. Stop the isolated server before applying reset-threads.sql,
archive-unrelated-threads.sql, hide-v1-threads.sql, hide-unrelated-projects.sql,
projects.sql, threads.sql, messages.sql, turn-items.sql, transcript-runs.sql,
transcript-messages.sql and transcript-turn-items.sql, in that order, with the documented
`t3-sqlite-state.ts exec` helper, once per file. Never target the live T3 home.

`all` includes two eight-thread projects, forty spacers, child/grandchild/fork
lineage and synthetic markdown navigation messages. Other variants isolate shelf,
archive and lineage states. `fixture.json` records deterministic clock/settings.
The replay option records stopped synthetic shell representations for working,
status and delayed arrival without spawning providers. `replay.js` transforms
only real shell frames before the real RPC decoder/reducer receives them; delayed
shell frames are released explicitly through `__sidebarReplay.release()`.

Install bootstrap.js and replay.js as identical pre-module scripts in the served
HTML only for capture. Remove both before committing or switching checkouts.
Use a fresh navigation, never document.write over an evaluated module graph.
Configure `sessionStorage['sidebar-capture-config']` with `{storage, replay}`.
Clear only projection caches (shell/thread/vcs-refs in t3code:connection-runtime)
from a same-origin inert document before navigating to the app. Preserve auth.
Timers and performance.now remain native; only Date is frozen. Record the exact
injection externally with the artifacts.

For grouping, initialize the three synthetic /tmp/triage-16534-{A,B,Spacer}
paths as git repositories with the same synthetic origin, then use repository
grouping and an explicit separate override for Spacer. No network fetch needed.
All navigation links are synthetic t3-thread links rendered by the real client.
23 changes: 23 additions & 0 deletions scripts/fixtures/issue-16534/bootstrap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
(() => {
const NativeDate = Date,
epoch = NativeDate.parse("2026-10-22T00:00:00.000Z");
globalThis.Date = new Proxy(NativeDate, {
apply() {
return new NativeDate(epoch).toString();
},
construct(target, args, newTarget) {
return Reflect.construct(target, args.length ? args : [epoch], newTarget);
},
get(target, property, receiver) {
return property === "now" ? () => epoch : Reflect.get(target, property, receiver);
},
});
globalThis.__sidebarCaptureClock = {
epoch,
readyState: document.readyState,
moduleScripts: document.querySelectorAll('script[type="module"]').length,
};
const config = JSON.parse(sessionStorage.getItem("sidebar-capture-config") || "{}");
for (const [key, value] of Object.entries(config.storage || {}))
localStorage.setItem(key, typeof value === "string" ? value : JSON.stringify(value));
})();
Loading
Loading