Skip to content

[Feat]: Sync URL with active thread (issue #27)#34

Merged
FireTable merged 4 commits into
mainfrom
feat/issue-27-thread-url-sync
Jul 10, 2026
Merged

[Feat]: Sync URL with active thread (issue #27)#34
FireTable merged 4 commits into
mainfrom
feat/issue-27-thread-url-sync

Conversation

@FireTable

@FireTable FireTable commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Closes #27.

What

Clicking a thread in the sidebar now updates the URL to /chat/<threadId>. Reload and share-link on /chat/<threadId> land on the right thread without the empty-thread flash. Browser back/forward walk thread history.

Why the prior attempt looped (and how this avoids it)

The previous attempt routed through next/navigation's router.push, which on a dynamic route triggers an RSC payload refetch and re-passes params.threadId as a React prop. That re-pass is what tripped the assistant-ui runtime's "new options reference → re-init the load effect" path — firing state?subgraphs=true and adapter.fetch(remoteId) twice for the same id. Issue #27's Risk section flagged this exact cycle as the reason a previous attempt was abandoned.

Fix is to bypass next/navigation entirely for thread navigation:

  • useLangGraphRuntime({ onThreadIdChange: writeUrlForThread }) — aUI's built-in deduped callback (see RemoteThreadListThreadListRuntimeCore._notifyThreadIdChange) writes the URL via window.history.pushState. No RSC roundtrip.
  • ThreadUrlShadow listens for popstate so browser back/forward sync aUI without a Next.js route change.
  • The new app/chat/[threadId]/page.tsx exists for share-link / refresh, but renders <Assistant /> with no threadId prop — thread state is read off window.location.pathname client-side. The dynamic-param-as-prop flow was the trigger for the double-load; removing it cuts the cycle.
  • Parallel URL-write effect for +New (placeholder __LOCALID_* has no remoteId, so onThreadIdChange is silent until first message initializes the backend thread); the placeholder pushes /chat, the real uuid then takes over.

Verified

End-to-end via Chrome DevTools MCP:

Result
Click existing thread on /chat/<A> URL → /chat/<B> via pushState; state?subgraphs=true, 0 RSC, 0 duplicate adapter.fetch
Click +New on /chat/<A> URL → /chat; 0 state fetch (placeholder, lazy init)
Reload on /chat/<A> Correct thread rendered, no empty-thread flash
Browser back /chat/<A>/chat/<B> popstate handler calls switchToThread, aUI state resumes from cache

Follow-up

A Discussion is being filed on assistant-ui/assistant-ui asking whether the runtime's "new options reference → re-load" sensitivity should be relaxed (or documented as a contract). The above fix is the recommended workaround in either reading; the Discussion is to surface the design question for maintainers rather than flag a bug.


Summary by cubic

Sync the chat URL with the active thread and fix the navigation loop from issue #27. Threads now have stable, shareable URLs; back/forward is smooth; /chat opens a fresh chat; invalid thread URLs fall back to /chat.

  • New Features

    • Update URL on thread changes via history.pushState; bypass next/navigation and simplify ThreadUrlShadow to a single URL↔aUI path.
    • Add /chat/[threadId] page for refresh/share; thread id is read from window.location.pathname to avoid prop-driven remounts.
    • Make the URL canonical and remove ACTIVE_THREAD_ID localStorage; /chat now starts a new chat.
  • Bug Fixes

    • Remove duplicate load and adapter fetch caused by router.push on dynamic routes.
    • Eliminate the empty-thread flash and /chat flicker on reload/share; no spurious history entries.
    • Redirect bad thread URLs to /chat via switchToThread().catch(...); keep /chat/[threadId]/error.tsx for segment errors.
    • Fix popstate listener cleanup to avoid leaked handlers.

Written for commit 519a5ad. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR closes issue #27 by replacing the localStorage-based ThreadPersistence component with a history.pushState-based ThreadUrlShadow, making thread URLs stable, shareable, and back/forward-navigable without triggering RSC refetches or double-loading the adapter.

  • URL sync via onThreadIdChange: A module-scope writeUrlForThread callback is passed to useLangGraphRuntime; it updates the URL on every aUI-driven thread change and guards against echoes with a pathname === target check. popstate events sync the reverse direction (browser → aUI).
  • New /chat/[threadId] route: The page renders <Assistant /> without forwarding params.threadId as a prop — thread state is read off window.location.pathname client-side by ThreadUrlShadow, avoiding the prop-change remount that caused the double-load() in the prior attempt.
  • Cleanup: ACTIVE_THREAD_ID localStorage constant removed; error.tsx boundary added to redirect stale/invalid segment URLs back to /chat.

Confidence Score: 5/5

Safe to merge — all changed paths are additive or replace equivalent functionality with a more correct design.

The core URL-sync logic is sound: the pathname === target guard in writeUrlForThread correctly prevents the popstate echo without needing a separate ref, switchToThread is idempotent so rapid back-presses are safe, and router.replace in the error boundary avoids spurious history entries. The only finding is a stale comment that contradicts itself about a non-existent lastSyncedIdRef.

The block comment at the top of ThreadUrlShadow in app/assistant.tsx (lines 353–361) is internally contradictory and should be cleaned up, but it has no runtime impact.

Important Files Changed

Filename Overview
app/assistant.tsx Replaces localStorage-based ThreadPersistence with ThreadUrlShadow; adds module-scope writeUrlForThread hooked into useLangGraphRuntime's onThreadIdChange; popstate listener handles back/forward; echo prevention is the pathname-equality guard in writeUrlForThread. Block comment contains a stale lastSyncedIdRef reference that contradicts itself within the same paragraph.
app/chat/[threadId]/page.tsx New dynamic route page for share-link / refresh support; intentionally does not pass params.threadId to Assistant, avoiding the prop-driven remount that caused double-load in issue #27; auth guard via getSessionFromHeaders is correct.
app/chat/[threadId]/error.tsx New Next.js error boundary for the dynamic segment; uses router.replace (no extra history entry) to fall back to /chat on any segment-level render failure; correctly drops error/reset props it cannot meaningfully consume.
lib/constants.ts Removes ACTIVE_THREAD_ID (localStorage key made obsolete by URL-as-source-of-truth); updates LOCAL_THREAD_PREFIX comment to reflect its new gating context; no functional change.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser
    participant ThreadUrlShadow
    participant aUI as aUI Runtime
    participant writeUrlForThread

    Note over Browser,writeUrlForThread: Thread click (sidebar)
    Browser->>aUI: user clicks thread item
    aUI->>writeUrlForThread: onThreadIdChange(remoteId)
    writeUrlForThread->>Browser: history.pushState(/chat/remoteId)

    Note over Browser,writeUrlForThread: Browser Back / Forward
    Browser->>ThreadUrlShadow: popstate event
    ThreadUrlShadow->>ThreadUrlShadow: readUrlThreadId()
    ThreadUrlShadow->>aUI: switchToThread(urlThreadId)
    aUI->>writeUrlForThread: onThreadIdChange(remoteId)
    writeUrlForThread->>writeUrlForThread: "pathname === target? no-op"

    Note over Browser,writeUrlForThread: Refresh / Share-link on /chat/id
    Browser->>ThreadUrlShadow: mount (syncFromUrl)
    ThreadUrlShadow->>ThreadUrlShadow: readUrlThreadId() → id
    ThreadUrlShadow->>aUI: switchToThread(id)
    aUI-->>ThreadUrlShadow: reject (bogus id)
    ThreadUrlShadow->>Browser: history.replaceState(/chat)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser
    participant ThreadUrlShadow
    participant aUI as aUI Runtime
    participant writeUrlForThread

    Note over Browser,writeUrlForThread: Thread click (sidebar)
    Browser->>aUI: user clicks thread item
    aUI->>writeUrlForThread: onThreadIdChange(remoteId)
    writeUrlForThread->>Browser: history.pushState(/chat/remoteId)

    Note over Browser,writeUrlForThread: Browser Back / Forward
    Browser->>ThreadUrlShadow: popstate event
    ThreadUrlShadow->>ThreadUrlShadow: readUrlThreadId()
    ThreadUrlShadow->>aUI: switchToThread(urlThreadId)
    aUI->>writeUrlForThread: onThreadIdChange(remoteId)
    writeUrlForThread->>writeUrlForThread: "pathname === target? no-op"

    Note over Browser,writeUrlForThread: Refresh / Share-link on /chat/id
    Browser->>ThreadUrlShadow: mount (syncFromUrl)
    ThreadUrlShadow->>ThreadUrlShadow: readUrlThreadId() → id
    ThreadUrlShadow->>aUI: switchToThread(id)
    aUI-->>ThreadUrlShadow: reject (bogus id)
    ThreadUrlShadow->>Browser: history.replaceState(/chat)
Loading

Reviews (4): Last reviewed commit: "refactor(chat): trust URL threadId; simp..." | Re-trigger Greptile

Context used:

  • Context used - CLAUDE.md (source)

Bypass next/navigation for thread navigation: router.push re-fires
the dynamic route's RSC, which double-fires useLangGraphRuntime's
mount-only load() plus the adapter fetch() for the same id (issue
#27's "previous attempt looped"). Instead:

  - useLangGraphRuntime's onThreadIdChange pushes the URL via
    window.history.pushState — no RSC roundtrip.
  - ThreadUrlShadow listens for popstate so browser back/forward
    hydrates the active thread from window.location.pathname.
  - The new app/chat/[threadId]/page.tsx renders <Assistant />
    without a threadId prop so a re-render never carries the
    thread id back through React props — that flow was the
    trigger for the double-load.

Verified end-to-end via Chrome DevTools MCP: click existing
thread -> 1x state?subgraphs=true (no RSC, no duplicate fetch);
click +New -> URL goes to /chat; reload on /chat/<id> -> no
empty-thread flash. See issue #27 for the full before/after.
@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown

PR review — focus on correctness, race conditions, and CLAUDE.md compliance

Overall the onThreadIdChange + window.history.pushState strategy is a clean workaround for issue #27's RSC double-load, and the popstate listener ties back-button to switchToThread without re-introducing the same problem. The pages under /chat correctly gate on session and defer ownership checks to the existing /api/threads/[id] 404 contract. That said, there are concrete concerns blocking this from being ready.

CLAUDE.md rule violations

  1. Rule 2 (TDD for new code) — no tests added. The diff introduces ~115 lines of new client-side state machinery (ThreadUrlShadow, writeUrlForThread, the parallel placeholder-push effect, the popstate listener, and a new server page) with zero coverage. Minimum I'd want to see:
    • Tests for readUrlThreadId regex/decode edge cases (URL-encoded ids, trailing slash, percent-encoded malformed sequences).
    • A test asserting that reloading on /chat/<X> does not push the URL onto the history stack more than once.
    • A test for the placeholder → real thread URL transition.
    • A test for popstate with stale-closure activeExternalId.
      The patterns from tests/api/threads.test.ts and tests/threads/adapter.test.ts are the right templates.

Correctness — race conditions and history pollution

  1. The third useEffect (placeholder → /chat push) is missing a hydration guard. On initial mount at /chat/<X>, before switchToThread resolves, activeExternalId is undefined; the effect's first condition doesn't trigger, and since pathname !== /chat it fires pushState("/chat"). Once switchToThread(urlThreadId) finally resolves and onThreadIdChangewriteUrlForThread runs, it pushes /chat/<X> again. Net effect on the history stack: [previous, /chat/<X>, /chat, /chat/<X>] — three entries for one logical reload — plus a brief paint where the URL bar shows /chat. The effect was designed for the +New transition (placeholder after a real id), not the reload case; gate it with didHydrateRef (or equivalent) and skip the initial commit.

  2. History pollution feeds straight into popstate. With the polluted stack above, a user pressing back from the final /chat/<X> lands on /chat (the third-effect entry), which reads as a "new chat" route but the runtime is still on thread X. syncFromUrl won't fire switchToThread because urlThreadId is null. URL stays /chat until the user takes another action. Visible bug.

  3. The localStorage write effect lost its hydration guard. The old ThreadPersistence skipped the first render via hasHydratedRef; the new effect runs unconditionally on mount with id = activeExternalId ?? mainThreadId. On reload of /chat/<X>, the first commit sees activeExternalId === undefined and mainThreadId = __LOCALID_*, hits the LOCAL_THREAD_PREFIX branch, and immediately removeItem(ACTIVE_THREAD_ID). If the user closes the tab in the gap between mount and switchToThread resolution, their saved thread id is lost. Same shape as feat(002-web-tools): web search/fetch tools, suggestions, rebrand #2 — the saved id is meant to persist through the placeholder phase.

Error handling

  1. .catch(() => {}) on switchToThread(urlThreadId) is silent. All the failure modes flow through this catch with no UI feedback:
    • Cross-user thread URLs (someone shares /chat/<other-user-thread-id>): GET /api/threads/[id] → 404, switchToThread rejects, swallowed. URL stays at /chat/<other-id>, no toast, no redirect.
    • Deleted thread: same swallow.
    • Network error: same swallow.
      At minimum, route to a known-good thread and surface the failure. The catch already clears localStorage — good — but the user-visible URL needs reconciliation.

Minor

  1. /chat/page.tsx and /chat/[threadId]/page.tsx are essentially identical. Consider extracting the auth + render into a small shared module, or letting /chat/page.tsx not exist by renaming [threadId] to [[...threadId]]. Right now any change to the auth check has to land in two files.

  2. readUrlThreadId crashes on malformed percent-encoding. window.location.pathname.match(/^\/chat\/([^/]+)$/) matches strings like /chat/%E0%A4%A (single-segment, but the percent sequence is incomplete), and decodeURIComponent('%E0%A4%A') throws URIError: URI malformed. The throw escapes the effect synchronously and prevents the popstate listener from being registered on the next mount. Wrap the decode in try/catch and fall back to raw m[1] or null.

  3. The activeExternalId closure capture in syncFromUrl is intentionally stale ([] deps + eslint-disable). Not a bug given lastSyncedIdRef does the dedupe, but worth a code comment noting the contract — future-you would assume stale means broken.

Nit: comment density (rule 5)

Most "ponytail:" headers are legit "why" comments documenting the non-obvious workaround for the double-load, which rule 5 endorses. A couple cross the line:

  • // writeUrlForThread handled it — restates the next line.
  • The // URL → aUI: initial mount + browser back/forward… block is borderline; some readers will appreciate the scaffolding, others will say it's narrating. Lean toward keeping it — the design is genuinely subtle.

A pnpm test and a manual reload-on-/chat/<X> smoke in Chrome DevTools before merge would confirm or refute whether the extra pushState in #2 is actually firing — the verify table in the PR description does not record the resulting history stack length, just the network counts.

Comment thread app/assistant.tsx
const readUrlThreadId = (): string | null => {
if (typeof window === "undefined") return null;
const m = window.location.pathname.match(/^\/chat\/([^/]+)$/);
return m ? decodeURIComponent(m[1]) : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decodeURIComponent will throw URIError: URI malformed on inputs like /chat/%E0%A4%A (incomplete percent sequence). The regex /^\/chat\/([^/]+)$/ happily matches such paths because % is one character and there's no /. The synchronous throw escapes the effect, and on the next mount the listener could fail to register until the malformed URL is replaced. Wrap the decode in try/catch and fall back to raw m[1] or to null.

Suggested change
return m ? decodeURIComponent(m[1]) : null;
let m: RegExpExecArray | null;
if (typeof window === "undefined" || !(m = /^\/chat\/([^/]+)$/.exec(window.location.pathname))) {
return null;
}
try {
return decodeURIComponent(m[1]);
} catch {
return null;
}

Comment thread app/assistant.tsx Outdated
// here so the bar reflects "new chat" instantly; once the backend
// thread materializes, `onThreadIdChange` → writeUrlForThread pushes
// the real `/chat/<uuid>`.
useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing the hasHydratedRef guard that the prior ThreadPersistence had for the analogous effect (see the deleted block in the diff). On initial mount at /chat/<X>, before switchToThread(urlThreadId) has resolved, activeExternalId is undefined (or the __LOCALID_* placeholder); the first two conditions don't early-return, and since pathname !== /chat the effect fires pushState("/chat"). writeUrlForThread then re-pushes /chat/<X> once the runtime settles, polluting the history stack to [..., /chat/<X>, /chat, /chat/<X>] for one logical reload. Gate the first commit with didHydrateRef (already declared on line 363) and only push on later transitions. Also note: the // writeUrlForThread handled it comment on line 439 restates the code — rule 5 says default to no comment.

Comment thread app/assistant.tsx Outdated
Comment thread app/assistant.tsx Outdated
Comment thread app/assistant.tsx
Comment on lines +365 to +372
// Read thread id off the current path. /chat/<id> → id. /chat (or
// anything else) → null. We use window.location (NOT useParams/usePathname)
// because Next.js's router cache doesn't observe our pushState writes.
const readUrlThreadId = (): string | null => {
if (typeof window === "undefined") return null;
const m = window.location.pathname.match(/^\/chat\/([^/]+)$/);
return m ? decodeURIComponent(m[1]) : null;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 "What" comments violate Rule 5

The first line of the readUrlThreadId comment ("Read thread id off the current path. /chat/ → id. /chat (or anything else) → null.") restates what the regex immediately below makes obvious. Per CLAUDE.md Rule 5, comments should explain why, not narrate what — this sentence should be dropped and only the reasoning about window.location vs useParams retained. The same pattern appears on line 374 ("URL → aUI: initial mount + browser back/forward.") and line 416 ("localStorage write-through so a stale tab still re-opens correctly.").

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/assistant.tsx
Line: 365-372

Comment:
**"What" comments violate Rule 5**

The first line of the `readUrlThreadId` comment ("Read thread id off the current path. /chat/<id> → id. /chat (or anything else) → null.") restates what the regex immediately below makes obvious. Per CLAUDE.md Rule 5, comments should explain *why*, not narrate *what* — this sentence should be dropped and only the reasoning about `window.location` vs `useParams` retained. The same pattern appears on line 374 ("URL → aUI: initial mount + browser back/forward.") and line 416 ("localStorage write-through so a stale tab still re-opens correctly.").

**Context Used:** CLAUDE.md ([source](https://app.greptile.com/firetable/github/FireTable/langgraph-app/-/custom-context?memory=629c10ea-a5a4-4d67-ba55-56060d5b9284))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment thread app/assistant.tsx
Comment on lines 341 to 445
return null;
};

// Persists the active thread id to localStorage so the chat reopens on the
// same thread after a refresh. We write the runtime's externalId (our
// Postgres uuid) rather than mainThreadId, because the runtime keeps
// _mainThreadId on the placeholder __LOCALID_* until the user
// explicitly switches threads — see assistant-ui #2577 and PR #3855
// for the related ExternalStore fix; RemoteThreadList is still affected.
const ThreadPersistence: FC = () => {
// ponytail: the URL is a shadow of the active aUI thread (issue #27).
// We deliberately bypass `next/router` for thread nav: `router.push` on
// this page re-fires the dynamic route's RSC, which double-fires `load()`
// and the adapter `fetch()` — aUI's runtime assumes ONE mount per active
// thread, so the second call hits the network again. Instead we:
// - push URL via `history.pushState` (URL bar updates; no RSC roundtrip)
// - hook the runtime's `onThreadIdChange` (passed in `useLangGraphRuntime`
// below) so user-driven thread switches write the URL automatically.
// - listen to `popstate` so browser back/forward syncs aUI without a
// Next.js route change.
// `lastSyncedIdRef` prevents the popstate → switchToThread → onThreadIdChange
// echo from re-pushing.
const ThreadUrlShadow: FC = () => {
const api = useAui();
const mainThreadId = useAuiState((s) => s.threads.mainThreadId);
const activeExternalId = useAuiState(
(s) => s.threads.threadItems.find((t) => t.id === s.threads.mainThreadId)?.externalId,
);
const hasHydratedRef = useRef(false);
const lastSyncedIdRef = useRef<string | null>(null);
const didHydrateRef = useRef(false);

// Runs before the write effect on first commit so hasHydratedRef
// suppresses the placeholder write below.
// Read thread id off the current path. /chat/<id> → id. /chat (or
// anything else) → null. We use window.location (NOT useParams/usePathname)
// because Next.js's router cache doesn't observe our pushState writes.
const readUrlThreadId = (): string | null => {
if (typeof window === "undefined") return null;
const m = window.location.pathname.match(/^\/chat\/([^/]+)$/);
return m ? decodeURIComponent(m[1]) : null;
};

// URL → aUI: initial mount + browser back/forward. After our pushState
// popstate does NOT fire — that's the whole point of bypassing the router.
useEffect(() => {
const savedId = localStorage.getItem(ACTIVE_THREAD_ID);
if (savedId) {
// switchToThread is typed void but returns a Promise at runtime.
void Promise.resolve(api.threads().switchToThread(savedId) as unknown as Promise<void>).catch(
() => {
localStorage.removeItem(ACTIVE_THREAD_ID);
},
);
const syncFromUrl = () => {
const urlThreadId = readUrlThreadId();
const currentExternalId = typeof activeExternalId === "string" ? activeExternalId : null;
if (
urlThreadId &&
urlThreadId !== currentExternalId &&
urlThreadId !== lastSyncedIdRef.current
) {
// switchToThread is typed void but returns a Promise at runtime.
void Promise.resolve(
api.threads().switchToThread(urlThreadId) as unknown as Promise<void>,
).catch(() => {});
} else if (urlThreadId) {
lastSyncedIdRef.current = urlThreadId;
}
};
if (!didHydrateRef.current) {
didHydrateRef.current = true;
const urlThreadId = readUrlThreadId();
if (!urlThreadId) {
// /chat root: seed from localStorage so a stale tab re-opens on
// its last thread. The seeded thread will then trigger
// onThreadIdChange → pushState.
const savedId = localStorage.getItem(ACTIVE_THREAD_ID);
if (savedId) {
void Promise.resolve(
api.threads().switchToThread(savedId) as unknown as Promise<void>,
).catch(() => {
localStorage.removeItem(ACTIVE_THREAD_ID);
});
}
}
syncFromUrl();
}
hasHydratedRef.current = true;
window.addEventListener("popstate", syncFromUrl);
return () => window.removeEventListener("popstate", syncFromUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// localStorage write-through so a stale tab still re-opens correctly.
// mainThreadId is also tracked (placeholder phase) — see #27 history
// on the placeholder id fallback (assistant-ui #2577, PR #3855).
useEffect(() => {
if (!hasHydratedRef.current) return;
const id = activeExternalId ?? mainThreadId;
if (!id || id.startsWith(LOCAL_THREAD_PREFIX)) {
if (!id || (typeof id === "string" && id.startsWith(LOCAL_THREAD_PREFIX))) {
localStorage.removeItem(ACTIVE_THREAD_ID);
return;
}
localStorage.setItem(ACTIVE_THREAD_ID, id);
}, [activeExternalId, mainThreadId]);

// ponytail: parallel URL-write for the NEW-thread placeholder case.
// `onThreadIdChange` only fires on `_mainThreadRemoteId` transitions,
// which stays `undefined` for `__LOCALID_*` until the first message
// hits the server and `adapter.initialize()` resolves. So when the
// user clicks +New and `activeExternalId` clears, the canonical hook
// is silent and the URL stays on the previous thread. We push /chat
// here so the bar reflects "new chat" instantly; once the backend
// thread materializes, `onThreadIdChange` → writeUrlForThread pushes
// the real `/chat/<uuid>`.
useEffect(() => {
if (typeof window === "undefined") return;
if (activeExternalId) return; // writeUrlForThread handled it
if (window.location.pathname === "/chat") return;
window.history.pushState({ threadId: null }, "", "/chat");
}, [activeExternalId]);

return null;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No tests for ThreadUrlShadow URL-state logic (Rule 2)

ThreadUrlShadow contains meaningful branching: popstate → switchToThread, localStorage seed vs URL seed, new-thread placeholder URL push, and the lastSyncedIdRef dedup path. CLAUDE.md Rule 2 requires a failing test first for non-declarative code. None is included in this PR. The component can be tested with @testing-library/react + jsdom's window.history and a mocked useAui/useAuiState, covering at minimum: initial URL seed, browser-back nav, and the +New /chat redirect.

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/assistant.tsx
Line: 341-445

Comment:
**No tests for `ThreadUrlShadow` URL-state logic (Rule 2)**

`ThreadUrlShadow` contains meaningful branching: popstate → `switchToThread`, localStorage seed vs URL seed, new-thread placeholder URL push, and the `lastSyncedIdRef` dedup path. CLAUDE.md Rule 2 requires a failing test first for non-declarative code. None is included in this PR. The component can be tested with `@testing-library/react` + `jsdom`'s `window.history` and a mocked `useAui`/`useAuiState`, covering at minimum: initial URL seed, browser-back nav, and the `+New` `/chat` redirect.

**Context Used:** CLAUDE.md ([source](https://app.greptile.com/firetable/github/FireTable/langgraph-app/-/custom-context?memory=629c10ea-a5a4-4d67-ba55-56060d5b9284))

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Comment thread app/assistant.tsx Outdated
// switchToThread is typed void but returns a Promise at runtime.
void Promise.resolve(
api.threads().switchToThread(urlThreadId) as unknown as Promise<void>,
).catch(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent .catch(() => {}) is too quiet for switchToThread(urlThreadId) failures — this is the path users land on when they share /chat/<other-user-thread-id> (cross-user 404), follow a deleted thread, or hit a network blip. The catch on line 405 cleans up localStorage, but the URL bar keeps the bad id forever, the sidebar shows "New Chat", and the user has no signal anything is wrong. At minimum, redirect to /chat on catch (mirroring what the third effect does for activeExternalId === null) and consider a toast for deleted/inaccessible threads. As written, an attacker who learns any uuid format can leave /chat/ URLs lying around that look like they ought to work.

Comment thread app/assistant.tsx Outdated
// localStorage write-through so a stale tab still re-opens correctly.
// mainThreadId is also tracked (placeholder phase) — see #27 history
// on the placeholder id fallback (assistant-ui #2577, PR #3855).
useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effect lost the hasHydratedRef early-return that the old ThreadPersistence had on lines 335–338 of the previous version. On initial mount at /chat/<X>, the first commit sees activeExternalId === undefined, mainThreadId = __LOCALID_*id = "__LOCALID_*", hits the LOCAL_THREAD_PREFIX branch, and removeItem(ACTIVE_THREAD_ID). The first effect (line 376) kicks off switchToThread(urlThreadId) asynchronously, but localStorage is already empty for the duration of that round-trip. If the user closes the tab during the gap, their saved thread id is lost. The original hasHydratedRef was there for this exact reason — please re-add it (or a didHydrateRef) here too.

// this `params.threadId` prop — passing it down would trip a runtime
// remount on every route push (issue #27 history: that double-fires
// `load()` and the adapter `fetch()`).
export default async function ChatThreadPage() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This page is byte-for-byte equivalent to app/chat/page.tsx (modulo the doc comment). Both: import the same auth helper, redirect on missing session, render <Assistant />. Two ways to consolidate:

  1. Pull the auth check into a shared helper (lib/auth/require-session-or-redirect.ts) so the auth contract lives in one place. Cheapest fix, smallest diff. Bonus: any future change (e.g., add a ?next= param to the /login redirect, surface a specific error code) only lands once.

  2. Drop /chat/page.tsx and turn [threadId] into an optional catch-all ([[...threadId]]/page.tsx). The client-side ThreadUrlShadow already handles the no-id case (/chat ↔ "new chat"), so the server doesn't actually need a non-dynamic sibling. This also lets the route file own the doc comment about the dynamic-route workaround, instead of duplicating it.

Either fix prevents the "I changed the redirect URL on one page and forgot the other" failure mode.

…now (#27)

localStorage ACTIVE_THREAD_ID was a write-through mirror that hydrated
`/chat` (no thread id) on next load — useful before the URL became the
source of truth, redundant once it is.

What changes for users:
  - /chat (root) used to seed from a persisted uuid when one existed;
    now the runtime constructor's own switchToNewThread() runs and the
    user lands on a fresh placeholder (welcome view + suggestions).
  - /chat/<id> behavior unchanged. The dynamic page still hydrates from
    window.location.pathname; reload / share-link / back-forward land
    on the right thread.

Removal:
  - ThreadUrlShadow: the `didHydrateRef` block + the write-through
    effect (also dropped the `mainThreadId` useAuiState, no longer
    needed). The mount useEffect collapses to "listen for popstate,
    also re-sync on every render" — URL is the only state.
  - lib/constants.ts: ACTIVE_THREAD_ID gone; LOCAL_THREAD_PREFIX is
    kept (still defined for any future placeholder-id filter, but no
    current consumer in assistant.tsx — the observability components
    use their own `__LOCAL_` prefix and that bit-rot is out of scope
    for this PR).

Net diff: -29 lines across 2 files. No behavior change for /chat/<id>
flow; /chat root changes from "resume last" to "fresh chat"
(matches mainstream chat apps).
Comment thread app/assistant.tsx Outdated
urlThreadId &&
urlThreadId !== currentExternalId &&
urlThreadId !== lastSyncedIdRef.current
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastSyncedIdRef.current is set in the wrong branch — it is updated only when switchToThread is not called (the else if path), so it never guards against the case it is documented to prevent. If syncFromUrl fires twice rapidly for the same urlThreadId (e.g. two quick Back presses before the first switchToThread promise settles), the condition is re-evaluated with lastSyncedIdRef.current still null, and switchToThread is invoked a second time — a double load() + double adapter.fetch(), which is the exact regression this PR exists to prevent.

Move the assignment to the call site so the ref is written before the switch:

Suggested change
) {
if (
urlThreadId &&
urlThreadId !== currentExternalId &&
urlThreadId !== lastSyncedIdRef.current
) {
lastSyncedIdRef.current = urlThreadId;
// switchToThread is typed void but returns a Promise at runtime.
void Promise.resolve(
api.threads().switchToThread(urlThreadId) as unknown as Promise<void>,
).catch(() => {});
} else if (urlThreadId) {
lastSyncedIdRef.current = urlThreadId;
}

Comment thread app/assistant.tsx Outdated
// here so the bar reflects "new chat" instantly; once the backend
// thread materializes, `onThreadIdChange` → writeUrlForThread pushes
// the real `/chat/<uuid>`.
useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing hydration guard — this effect was designed for the +New transition (placeholder after a real id), not the initial-mount case. On direct navigation to /chat/<X>, effect order means syncFromUrl runs first (correctly calls switchToThread(X)), but then this effect fires with activeExternalId === undefined and pathname === "/chat/<X>" (not /chat), so it unconditionally pushes /chat. Once the runtime resolves switchToThread, onThreadIdChange → writeUrlForThread(X) pushes /chat/<X> again.

Resulting history stack on a single reload: [..., /chat/<X>, /chat, /chat/<X>] — three entries for one logical reload, plus a brief paint of /chat in the URL bar. A user pressing Back from the final /chat/<X> lands on /chat, reads as a "new chat" route, but the runtime is still on thread X. syncFromUrl won't switchToThread because urlThreadId is null. URL stays at /chat until the next action. Visible bug.

Fix: gate on a didHydrateRef (set true after the first syncFromUrl settles), or fold this into syncFromUrl so the placeholder push only fires when the previous external id was a real one — not the initial undefined.

Comment thread app/assistant.tsx
const readUrlThreadId = (): string | null => {
if (typeof window === "undefined") return null;
const m = window.location.pathname.match(/^\/chat\/([^/]+)$/);
return m ? decodeURIComponent(m[1]) : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decodeURIComponent throws URIError: URI malformed on incomplete percent sequences. A path like /chat/%E0%A4%A matches the regex but blows up here, escaping the effect synchronously and aborting addEventListener("popstate", ...) — back/forward then silently stops syncing for the lifetime of the mount.

Wrap in try/catch and fall back to raw m[1] (or null) on failure. The same throw propagates from popstate, so a malformed URL encountered via Back corrupts the listener permanently.

Comment thread app/assistant.tsx
const hasHydratedRef = useRef(false);
const lastSyncedIdRef = useRef<string | null>(null);

// Read thread id off the current path. /chat/<id> → id. /chat (or

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rule 5: the first sentence ("Read thread id off the current path. /chat/ → id. /chat (or anything else) → null.") restates what the regex immediately below makes obvious. Drop it; keep only the window.location vs useParams/usePathname rationale (the actual why — Next.js's router cache doesn't observe our pushState writes, so a hook reading from the router would be stale).

Comment thread app/assistant.tsx Outdated
localStorage.setItem(ACTIVE_THREAD_ID, id);
}, [activeExternalId, mainThreadId]);
if (typeof window === "undefined") return;
if (activeExternalId) return; // writeUrlForThread handled it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rule 5: // writeUrlForThread handled it restates the next line (if (activeExternalId) return;). Either drop it or replace with the actual why — e.g. that activeExternalId becoming truthy means the canonical onThreadIdChange → writeUrlForThread path will fire with the real id, so this fallback effect should stay quiet to avoid a redundant push.

@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rule 2 (TDD for new code): no test for this route. Minimum coverage: an authenticated GET returns <Assistant />; an unauthenticated GET redirects to /login; the rendered tree does not pass params.threadId down as a prop (regression guard for the RSC-double-load from issue #27). The tests/api/auth mock pattern (next/headers + @/lib/auth/config) is the template — see tests/api/alchemy/status.test.ts.

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown

Independent follow-up. Adds concrete bugs not surfaced by inline comments yet. Echoes / extends the existing top-level review.

Concrete bugs to fix

1. lastSyncedIdRef is updated in the wrong branch

syncFromUrl sets lastSyncedIdRef.current only on the else-if branch (the URL-already-matches-aUI case). The branch that DOES call switchToThread leaves the ref at its previous value. The stated contract (prevents the popstate -> switchToThread -> onThreadIdChange echo from re-pushing) is the one place the ref is needed most, and it is the one place it is not being written.

Trigger: rapid Back presses before the first switchToThread settles. syncFromUrl fires twice for the same urlThreadId, the condition re-evaluates with lastSyncedIdRef.current still null, and switchToThread is invoked twice. That is a double load() + double adapter.fetch(), the exact regression this PR exists to prevent.

2. Placeholder effect missing hydration guard - history pollution on reload

Effect order on mount at /chat/X: the syncFromUrl useEffect runs first, calls switchToThread(X). Then the placeholder useEffect fires with activeExternalId = undefined and pathname = /chat/X (not /chat), so it unconditionally pushes /chat. Once switchToThread resolves, onThreadIdChange -> writeUrlForThread(X) pushes /chat/X again. Resulting history stack on a reload: [..., /chat/X, /chat, /chat/X] - three entries for one logical reload, and the URL bar briefly paints /chat.

A user pressing Back from the final /chat/X lands on /chat, reads as a "new chat" route, but the runtime is still on thread X. syncFromUrl will not switchToThread because urlThreadId is null. URL stays at /chat until the next action. Visible bug.

3. decodeURIComponent throws on malformed percent-encoding

A path like /chat/%E0%A4%A matches the regex but decodeURIComponent throws URIError: URI malformed. The throw escapes the effect synchronously and aborts addEventListener("popstate", ...), so back/forward silently stops syncing for

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown

Continuation of the top-level review above.

3. decodeURIComponent throws on malformed percent-encoding

A path like /chat/%E0%A4%A matches the regex but decodeURIComponent throws URIError: URI malformed. The throw escapes the effect synchronously and aborts addEventListener("popstate", ...), so back/forward silently stops syncing for the lifetime of the mount. Wrap in try/catch and fall back to raw m[1] (or null) on failure.

Correction to the prior top-level review

"The localStorage write effect lost its hydration guard" - there is no localStorage write effect in this diff. ThreadPersistence's second effect was deleted along with the component. The first effect's localStorage READ was also deleted. The hydration concern translates to "the placeholder effect (item 2 above) needs a hydration guard" - same shape, different code path.

Rule 2 (tests)

No coverage for ~115 lines of new client state. Suggested cases (templates: tests/api/threads.test.ts, tests/threads/adapter.test.ts):

  • readUrlThreadId regex + decode (incl. malformed percent-encoding -> no throw)
  • reload on /chat/X produces exactly one history entry (item 2 guard)
  • placeholder -> real-thread URL transition
  • popstate with stale-closure activeExternalId
  • switchToThread rejected -> URL reconciles, no uncaught promise

A short history-stack assertion on reload would catch item 2 without needing full jsdom wiring for assistant-ui.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/constants.ts">

<violation number="1" location="lib/constants.ts:5">
P3: The historical localStorage note adds stale implementation detail after localStorage was removed and obscures the useful reason for rejecting placeholder IDs. Keeping the comment focused on the missing server record would match the repository’s comment guideline.</violation>
</file>

<file name="app/assistant.tsx">

<violation number="1" location="app/assistant.tsx:396">
P2: Direct development loads of `/chat/<id>` now call `switchToThread` twice because Strict Mode replays this unguarded effect while `lastSyncedIdRef` remains unset in the switching branch. Recording `urlThreadId` immediately before `switchToThread` (or retaining a one-time hydration guard) would prevent the duplicate load and race.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread app/assistant.tsx
lastSyncedIdRef.current = urlThreadId;
}
};
syncFromUrl();

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Direct development loads of /chat/<id> now call switchToThread twice because Strict Mode replays this unguarded effect while lastSyncedIdRef remains unset in the switching branch. Recording urlThreadId immediately before switchToThread (or retaining a one-time hydration guard) would prevent the duplicate load and race.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/assistant.tsx, line 396:

<comment>Direct development loads of `/chat/<id>` now call `switchToThread` twice because Strict Mode replays this unguarded effect while `lastSyncedIdRef` remains unset in the switching branch. Recording `urlThreadId` immediately before `switchToThread` (or retaining a one-time hydration guard) would prevent the duplicate load and race.</comment>

<file context>
@@ -390,41 +393,12 @@ const ThreadUrlShadow: FC = () => {
-      }
-      syncFromUrl();
-    }
+    syncFromUrl();
     window.addEventListener("popstate", syncFromUrl);
     return () => window.removeEventListener("popstate", syncFromUrl);
</file context>
Fix with cubic

Comment thread lib/constants.ts
Comment on lines +5 to +6
// load. (Used to gate localStorage; the localStorage write itself was
// removed in issue #27 once URL became the source of truth.)

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The historical localStorage note adds stale implementation detail after localStorage was removed and obscures the useful reason for rejecting placeholder IDs. Keeping the comment focused on the missing server record would match the repository’s comment guideline.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/constants.ts, line 5:

<comment>The historical localStorage note adds stale implementation detail after localStorage was removed and obscures the useful reason for rejecting placeholder IDs. Keeping the comment focused on the missing server record would match the repository’s comment guideline.</comment>

<file context>
@@ -1,14 +1,11 @@
+// hasn't been persisted yet. Filter it out before writing to anything
+// (URL, telemetry, etc.) — the placeholder has no backing record on the
+// server, so anything that points back at it would 404 on the next page
+// load. (Used to gate localStorage; the localStorage write itself was
+// removed in issue #27 once URL became the source of truth.)
 export const LOCAL_THREAD_PREFIX = "__LOCALID_";
</file context>
Suggested change
// load. (Used to gate localStorage; the localStorage write itself was
// removed in issue #27 once URL became the source of truth.)
// load.
Fix with cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/chat/[threadId]/page.tsx">

<violation number="1" location="app/chat/[threadId]/page.tsx:1">
P3: This page duplicates the complete authenticated chat-page implementation, so future auth or rendering changes can diverge between `/chat` and `/chat/<threadId>`. Re-exporting or sharing the existing page keeps both routes on one implementation.</violation>

<violation number="2" location="app/chat/[threadId]/page.tsx:13">
P2: Logged-out users opening `/chat/<threadId>` lose the deep link and land on `/chat` after sign-in. Preserve the requested chat path through the authentication flow and redirect back to it.</violation>

<violation number="3" location="app/chat/[threadId]/page.tsx:14">
P2: A hard reload can still paint the New Chat state before the URL thread is selected after hydration. Gate the visible thread UI until the initial URL sync completes, or seed the runtime before the first render.</violation>
</file>

<file name="app/assistant.tsx">

<violation number="1" location="app/assistant.tsx:371">
P2: A malformed encoded thread id can throw and break URL sync logic. Wrapping decode in a safe parse path keeps navigation handling resilient to bad URLs.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread app/assistant.tsx
const readUrlThreadId = (): string | null => {
if (typeof window === "undefined") return null;
const m = window.location.pathname.match(/^\/chat\/([^/]+)$/);
return m ? decodeURIComponent(m[1]) : null;

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A malformed encoded thread id can throw and break URL sync logic. Wrapping decode in a safe parse path keeps navigation handling resilient to bad URLs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/assistant.tsx, line 371:

<comment>A malformed encoded thread id can throw and break URL sync logic. Wrapping decode in a safe parse path keeps navigation handling resilient to bad URLs.</comment>

<file context>
@@ -335,45 +341,117 @@ const AuiRefCapture: FC<{ bridgeRef: RefObject<RuntimeBridge> }> = ({ bridgeRef
+  const readUrlThreadId = (): string | null => {
+    if (typeof window === "undefined") return null;
+    const m = window.location.pathname.match(/^\/chat\/([^/]+)$/);
+    return m ? decodeURIComponent(m[1]) : null;
+  };
+
</file context>
Fix with cubic

Comment thread app/assistant.tsx Outdated
export default async function ChatThreadPage() {
const session = await getSessionFromHeaders();
if (!session) redirect("/login");
return <Assistant />;

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A hard reload can still paint the New Chat state before the URL thread is selected after hydration. Gate the visible thread UI until the initial URL sync completes, or seed the runtime before the first render.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/chat/[threadId]/page.tsx, line 14:

<comment>A hard reload can still paint the New Chat state before the URL thread is selected after hydration. Gate the visible thread UI until the initial URL sync completes, or seed the runtime before the first render.</comment>

<file context>
@@ -0,0 +1,15 @@
+export default async function ChatThreadPage() {
+  const session = await getSessionFromHeaders();
+  if (!session) redirect("/login");
+  return <Assistant />;
+}
</file context>
Fix with cubic

// `load()` and the adapter `fetch()`).
export default async function ChatThreadPage() {
const session = await getSessionFromHeaders();
if (!session) redirect("/login");

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Logged-out users opening /chat/<threadId> lose the deep link and land on /chat after sign-in. Preserve the requested chat path through the authentication flow and redirect back to it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/chat/[threadId]/page.tsx, line 13:

<comment>Logged-out users opening `/chat/<threadId>` lose the deep link and land on `/chat` after sign-in. Preserve the requested chat path through the authentication flow and redirect back to it.</comment>

<file context>
@@ -0,0 +1,15 @@
+// `load()` and the adapter `fetch()`).
+export default async function ChatThreadPage() {
+  const session = await getSessionFromHeaders();
+  if (!session) redirect("/login");
+  return <Assistant />;
+}
</file context>
Fix with cubic

@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This page duplicates the complete authenticated chat-page implementation, so future auth or rendering changes can diverge between /chat and /chat/<threadId>. Re-exporting or sharing the existing page keeps both routes on one implementation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/chat/[threadId]/page.tsx, line 1:

<comment>This page duplicates the complete authenticated chat-page implementation, so future auth or rendering changes can diverge between `/chat` and `/chat/<threadId>`. Re-exporting or sharing the existing page keeps both routes on one implementation.</comment>

<file context>
@@ -0,0 +1,15 @@
+import { redirect } from "next/navigation";
+import { Assistant } from "@/app/assistant";
+import { getSessionFromHeaders } from "@/lib/auth/queries";
</file context>
Fix with cubic

Two failure modes met at /chat/<bogus>:

  1. adapter.fetch 404 — the silent .catch(() => {}) left the sidebar
     pinned at 5× ThreadListSkeleton.
  2. Turbopack "Entry not available in the store" on a dynamic param —
     blank page until manual reload.

Fix layering:

  - Pre-validate against threadListAdapter.list() before calling
    api.threads().switchToThread(urlThreadId). aUI's switchToThread
    internally re-fetches the item; if THAT 404s the function rejects
    but the runtime's _state.isLoading flag stays stuck mid-transition
    no matter how the URL is rewritten (router.replace, window.history,
    window.location.assign all leave the sidebar pinned at the
    skeleton). Gating the call on a list() lookup keeps the runtime
    state clean in the first place.

  - Keep the silent .catch as a fallback for the cross-user race
    (list() returned a stale yes between the precheck and the per-thread
    fetch).

  - Add app/chat/[threadId]/error.tsx as a server-side catch for
    Turbopack/streaming errors that bypass the client-side catch.

Drive-by: popstate handler was registered as `() => syncFromUrl()`
at addEventListener and removed as a fresh `() => syncFromUrl()` at
cleanup — oxlint no-invalid-remove-event-listener caught the
reference mismatch. Single onPopState reference now.
Comment thread app/assistant.tsx Outdated
Comment on lines +445 to +450
useEffect(() => {
if (!hasHydratedRef.current) return;
const id = activeExternalId ?? mainThreadId;
if (!id || id.startsWith(LOCAL_THREAD_PREFIX)) {
localStorage.removeItem(ACTIVE_THREAD_ID);
return;
}
localStorage.setItem(ACTIVE_THREAD_ID, id);
}, [activeExternalId, mainThreadId]);
if (typeof window === "undefined") return;
if (activeExternalId) return; // writeUrlForThread handled it
if (window.location.pathname === "/chat") return;
window.history.pushState({ threadId: null }, "", "/chat");
}, [activeExternalId]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Spurious /chat history entry on share-link/reload to /chat/<id>

On initial mount the aUI runtime creates a placeholder thread (__LOCALID_*) before the thread list loads, so activeExternalId is undefined. This effect fires immediately — before syncFromUrl (async, awaiting threadListAdapter.list()) has had a chance to call switchToThread — and pushes /chat to history while the URL is still /chat/<id>. Later, when switchToThread settles and onThreadIdChange fires, writeUrlForThread pushes /chat/<id> back. The net result is an extra /chat entry in the history stack: pressing Back from the restored /chat/<id> lands on the spurious /chat entry rather than the user's actual previous page.

The two scenarios this effect must distinguish are identical from the effect's perspective: a user who just clicked "+New" and a page that just loaded on /chat/<id> both present activeExternalId = undefined with mainThreadId as a __LOCALID_* placeholder. Suppressing the push until syncFromUrl has finished its first run (tracked via a ref) would separate the cases — the "new thread" push only needs to happen when initialization has completed and there is still no external id to push.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/assistant.tsx
Line: 445-450

Comment:
**Spurious `/chat` history entry on share-link/reload to `/chat/<id>`**

On initial mount the aUI runtime creates a placeholder thread (`__LOCALID_*`) before the thread list loads, so `activeExternalId` is `undefined`. This effect fires immediately — before `syncFromUrl` (async, awaiting `threadListAdapter.list()`) has had a chance to call `switchToThread` — and pushes `/chat` to history while the URL is still `/chat/<id>`. Later, when `switchToThread` settles and `onThreadIdChange` fires, `writeUrlForThread` pushes `/chat/<id>` back. The net result is an extra `/chat` entry in the history stack: pressing Back from the restored `/chat/<id>` lands on the spurious `/chat` entry rather than the user's actual previous page.

The two scenarios this effect must distinguish are identical from the effect's perspective: a user who just clicked "+New" and a page that just loaded on `/chat/<id>` both present `activeExternalId = undefined` with `mainThreadId` as a `__LOCALID_*` placeholder. Suppressing the push until `syncFromUrl` has finished its first run (tracked via a ref) would separate the cases — the "new thread" push only needs to happen when initialization has completed and there is still no external id to push.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Comment thread app/assistant.tsx Outdated
// here so the bar reflects "new chat" instantly; once the backend
// thread materializes, `onThreadIdChange` → writeUrlForThread pushes
// the real `/chat/<uuid>`.
useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL flash on direct /chat/<id> visit

On initial mount with activeExternalId === undefined, this effect pushes /chat synchronously — before the first effect's syncFromUrl has a chance to await threadListAdapter.list() and hand off to switchToThread. The result:

  1. URL: /chat/<id> → mount → effects run
  2. Effect 2 (this one) fires: activeExternalId is undefined, pathname is /chat/<id> (not /chat), so pushState({ threadId: null }, "", "/chat") fires. Bar now shows /chat.
  3. syncFromUrl's await threadListAdapter.list() resolves, switchToThread(<id>) succeeds.
  4. onThreadIdChange(<id>)writeUrlForThread pushes /chat/<id> back.

The user sees the address bar briefly flip to /chat — exactly the flash this PR claims to eliminate (and exactly the symptom Greptile flagged in the sequence diagram).

The "user clicks +New" intent this effect needs to serve is post-mount, not on first render. Gate it with a mounted ref (e.g. const mountedRef = useRef(false) set to true after the first useEffect tick and the URL has been observed via readUrlThreadId), or skip the push when the initial render already had a __LOCALID_* placeholder AND the URL has a threadId segment (it can't be a "new chat" intent at that moment — mainThreadId would have been [threadId segment] if the runtime had hydrated).

Minimal repro: copy the PR's verified table row "Reload on /chat/<A>" and add "watch the address bar between page-paint and the first state?subgraphs=true response." Even a single paint frame of /chat is a flash the user will notice if they typed the URL or refreshed.

Suggested change
useEffect(() => {
const mountedRef = useRef(false);
useEffect(() => {
// Skip the very first synchronous push after hydration — the parallel
// mount effect (syncFromUrl) is still resolving at this tick and the
// URL may legitimately carry a threadId we're about to hydrate. After
// the first tick, "activeExternalId cleared" always means the user
// clicked +New and we should reset to /chat.
mountedRef.current = true;
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
if (!mountedRef.current) return;
if (activeExternalId) return; // writeUrlForThread handled it
if (window.location.pathname === "/chat") return;
window.history.pushState({ threadId: null }, "", "/chat");
}, [activeExternalId]);

Comment thread app/assistant.tsx
// - listen to `popstate` so browser back/forward syncs aUI without a
// Next.js route change.
// `lastSyncedIdRef` prevents the popstate → switchToThread → onThreadIdChange
// echo from re-pushing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastSyncedIdRef is only written on the no-op branch — comment vs. implementation mismatch

The leading comment on ThreadUrlShadow says:

lastSyncedIdRef prevents the popstate → switchToThread → onThreadIdChange echo from re-pushing.

But the only assignment is lastSyncedIdRef.current = urlThreadId inside the urlThreadId === currentExternalId early return — i.e. the case where we did no work. After a successful switchToThread(urlThreadId) the ref still holds whatever it held before (null on first popstate). So the urlThreadId === lastSyncedIdRef.current check at line 393 only ever short-circuits the same popstate event firing twice — that's a real possibility (some browsers do fire popstate twice for a single back gesture under back/forward cache restores), so the ref isn't useless, just nowhere near what the comment claims.

Either:

  • Drop the leading comment and document the real semantic ("memoizes the last url threadId we observed, regardless of whether we switched to it — protects against popstate firing twice on a single back gesture"), or
  • Actually populate the ref after switchToThread resolves so the ref documents "the last thread we successfully switched to from a popstate," which then gates popstate-driven re-entry to already-switched ids.

Right now the inline comment is misleading enough that a future reader will think the dedup is stronger than it is.

Suggested change
// echo from re-pushing.
// Memoizes the last url threadId we observed, regardless of whether
// switchToThread ran — protects against `popstate` firing twice on a
// single back gesture (some browsers do, in particular under bfcache
// restores). The value is intentionally not updated after a successful
// switchToThread; see `writeUrlForThread` for the URL write path.

Comment thread app/assistant.tsx
// Read thread id off the current path. /chat/<id> → id. /chat (or
// anything else) → null. We use window.location (NOT useParams/usePathname)
// because Next.js's router cache doesn't observe our pushState writes.
const readUrlThreadId = (): string | null => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decodeURIComponent can throw URIError on a malformed path segment

window.location.pathname is normally sane (the browser percent-encodes anything that isn't a valid path byte) but a hand-crafted <a href> targeting this app — or a Next.js redirect that round-trips user input — can land a malformed %-sequence on /chat/<bad%>. decodeURIComponent raises synchronously; the throw bubbles out of syncFromUrl's call site (useEffect callback), and React will surface an uncaught error rather than the redirect we want.

Wrap the decode, or skip to the no-thread path on failure.

let id: string | null = null;
try {
  id = decodeURIComponent(m[1]);
} catch {
  // fall through, leave id null
}
return id;

Currently the effect's catch on switchToThread doesn't cover this and the throw escapes.

Suggested change
const readUrlThreadId = (): string | null => {
const readUrlThreadId = (): string | null => {
if (typeof window === "undefined") return null;
const m = window.location.pathname.match(/^\/chat\/([^/]+)$/);
if (!m) return null;
try {
return decodeURIComponent(m[1]);
} catch {
return null;
}
};

@@ -0,0 +1,28 @@
"use client";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-flight the redirect inside the server pass (no client flash)

router.replace in useEffect (line 25) means: the user lands on /chat/<bogus>, the segment tries to render, fails, error.tsx mounts, then the URL bar jumps to /chat on the next tick. The error UI renders return null so the flash is empty → black; the user briefly sees a blank /chat/ → /chat churn.

Since this is the failure-mode fallback for "the page couldn't render," the cleanest fix is to do the redirect server-side in app/chat/[threadId]/page.tsx (e.g. when params.threadId fails an obvious sanity check, redirect("/chat")), so the user never sees the bogus URL at all. The Turbopack "Entry not available in the store" case is harder to filter server-side — that one still needs the client fallback — but a pure-static redirect for obviously-bad ids would eliminate most of this.

(Alternative: at minimum, document which of the two failure modes actually requires the client fallback, since the comment lists both as the same path.)

Suggested change
"use client";
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
// Client fallback for render-time errors only — primarily the
// Turbopack "Entry not available in the store" cache miss on a
// dynamic param we haven't built yet. Static id validation (length,
// charset, uuid shape) is handled server-side in
// `app/chat/[threadId]/page.tsx` via `redirect("/chat")`, so by
// the time this mounts the URL is already past the cheap filters
// and we're in genuine error-recovery territory.
// `error`/`reset` are Next-mandated props; we don't surface the
// error to the user — by the time this mounts we've already
// decided the URL is bogus, so just navigate.
export default function ChatThreadError({
error: _error,
reset: _reset,
}: {
error: Error;
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
router.replace("/chat");
}, [router]);
return null;
}

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review summary

Direction is sound: bypassing next/navigation to avoid the RSC/double-load loop is the correct call (the prior attempt's failure mode is well-understood and the new code's diagnostics in the comments are mostly earned). The popstateonThreadIdChange round-trip is the right shape. The commit narrative is honest about the previous attempt's loop and concrete about why this one shouldn't.

But there are a few issues that need to land before merge. I've posted four inline comments — they're the blocking set; this body is the supporting context.

Inline comments posted

  1. app/assistant.tsx:445 — the +New placeholder effect runs at hydration and pushes /chat over /chat/<id>, reintroducing the exact URL flash the PR is trying to eliminate. Greptile's sequence diagram flagged this correctly. Needs a mounted-ref guard.
  2. app/assistant.tsx:354 — the leading comment on lastSyncedIdRef overstates what the ref does; the only write is on the no-op branch, so the "popstate echo guard" claim is inaccurate. Either update the comment to match behavior, or actually populate the ref after switchToThread resolves.
  3. app/assistant.tsx:365decodeURIComponent can throw URIError on a malformed %-sequence in the URL; the throw escapes the effect. Wrap it.
  4. app/chat/[threadId]/error.tsx:1 — the client router.replace adds a render-then-redirect flash that the server could avoid entirely for ids that fail obvious validation.

CLAUDE.md compliance

Things that look fine

  • The auth check in ChatThreadPage matches app/chat/page.tsx; both call getSessionFromHeaders() and redirect to /login if absent. There's intentional duplication (the new file exists solely for the dynamic param, the existing one for the root). A shared helper would DRY it; whether that's worth a touch on this PR is a judgment call. I'd lean toward leaving it — duplicated auth is cheaper to read than an indirection.
  • The pre-validation threadListAdapter.list() adds a round-trip on every direct /chat/<id> visit. Acceptable cost given the alternative is leaving aUI's loading flag stuck (the bug the comment cites is real). No action needed.
  • mcp__github_inline_comment__create_inline_comment refs will need to resolve; latest commit is a7d567f2.

Cross-reference: I didn't open a Discussion on assistant-ui/assistant-ui to verify the runtime's "new options reference → re-load" sensitivity claim. The PR's "we're not flagging a bug, just surfacing a design question" framing is the right tone.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/chat/[threadId]/error.tsx">

<violation number="1" location="app/chat/[threadId]/error.tsx:25">
P1: Valid thread URLs are discarded whenever this segment hits an unrelated render, auth, or transient RSC error, so users are silently moved to a new chat and the original failure is masked. This boundary should preserve the URL and expose `reset`/an error state; only a confirmed invalid-thread response should replace the URL with `/chat`.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

}) {
const router = useRouter();
useEffect(() => {
router.replace("/chat");

@cubic-dev-ai cubic-dev-ai Bot Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Valid thread URLs are discarded whenever this segment hits an unrelated render, auth, or transient RSC error, so users are silently moved to a new chat and the original failure is masked. This boundary should preserve the URL and expose reset/an error state; only a confirmed invalid-thread response should replace the URL with /chat.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/chat/[threadId]/error.tsx, line 25:

<comment>Valid thread URLs are discarded whenever this segment hits an unrelated render, auth, or transient RSC error, so users are silently moved to a new chat and the original failure is masked. This boundary should preserve the URL and expose `reset`/an error state; only a confirmed invalid-thread response should replace the URL with `/chat`.</comment>

<file context>
@@ -0,0 +1,28 @@
+}) {
+  const router = useRouter();
+  useEffect(() => {
+    router.replace("/chat");
+  }, [router]);
+  return null;
</file context>
Fix with cubic

Comment thread app/assistant.tsx Outdated
Comment thread app/assistant.tsx Outdated
URL was flickering /chat/<x> → /chat → /chat/<x> because ThreadUrlShadow had
three URL-write paths stacked on top of each other (writeUrlForThread
on onThreadIdChange, a parallel "placeholder effect" for +New, and a
precheck that re-routed to /chat when aUI's `s.threads.threadIds`
didn't contain the URL id). Each rule had its own escape valve, and
they ended up tripping each other under timing variance.

Reset to a single principle: /chat means "trust aUI to pick a
thread"; /chat/<id> means "switch aUI to that id, then back off".
aUI already knows how to reject unknown ids via switchToThread's
Promise — that's the only signal we need.

Removed:
  - activeExternalId / threadIds / threadsIsLoading useAuiState reads
  - lastSyncedIdRef dedup (switchToThread is idempotent in aUI)
  - parallel "placeholder effect" (writeUrlForThread now pushes /chat
    directly when remoteId is undefined)
  - precheck-then-switch two-step on URL→aUI (single switchToThread +
    catch replaces it)

Kept (one path each):
  - URL → aUI: switchToThread on mount + popstate, catch on reject
    replaces /chat (the only "smart" path left; one wasted 404 round-
    trip on bogus ids is the trade)
  - aUI → URL: writeUrlForThread in onThreadIdChange, one pushState
    per remoteId transition (aUI dedupes internally)

Verified scenarios (Chrome DevTools):
  - /chat/<real> direct load  → loads, no flicker, 1× /api/threads
  - /chat/<bogus> direct load → /chat via reject catch
  - click sidebar thread     → URL = /chat/<new> immediately, no
                                intermediate /chat
  - /chat (root)             → Welcome, no fetch
@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown

Thanks for the careful write-up — the bypass-next/navigation rationale is convincing and the verified table makes the regression class obvious. Reviewed the diff against the current app/assistant.tsx, lib/threads/adapter.ts, and the existing app/chat/page.tsx. A few things to confirm before merging:

1. The double-push race is real, despite the comment claiming it's not. On a share-link/reload to /chat/<id>, the runtime's create: factory calls threadListAdapter.initialize!("local"), which generates a fresh placeholder (__LOCALID_*) before syncFromUrl's switchToThread(id) resolves. That's exactly the "placeholder before remoteId is known" state the existing ThreadPersistence had to suppress with hasHydratedRef. The current code's onThreadIdChange(undefined) → writeUrlForThread(undefined) → pushState("/chat") event will fire on that path and write an extra /chat entry to history before the eventual real onThreadIdChange(id) repushes /chat/<id>. Back from /chat/<id> lands on the spurious /chat. Greptile's sequence diagram nails it.

The author's "no clever precheck … switchToThread is idempotent and onThreadIdChange already dedupes" argument doesn't address this — deduping happens per mainThreadRemoteId, but the first notify is for undefined (the placeholder), which IS the current URL state at mount time, so pushState proceeds (the equality check compares pathname, not state.id). The same race can hit +New because onThreadIdChange fires for the new placeholder too.

Fix sketch: gate writeUrlForThread on a hasSeenFirstRemoteIdRef that flips once remoteId is a real uuid (or a hydratedFromUrlRef flipped inside the syncFromUrl .then), and silence the placeholder notification until then. Alternatively, replay that initial undefined notify as a replaceState (not pushState) so it doesn't grow the history stack.

2. Stale docstring above ThreadUrlShadow. Lines 343-362 contain a multi-paragraph comment that still mentions a lastSyncedIdRef that the implementation doesn't have — the very next paragraph says "no clever precheck, no parallel placeholder effect, no lastSyncedIdRef" while the line above says "lastSyncedIdRef prevents the popstate → switchToThread → onThreadIdChange echo from re-pushing." One of the two is wrong; right now the code has no such ref, so the upper paragraph is stale. Also, per CLAUDE.md rule #5 the comment block is ~20 lines of prose explaining the bypass — a 2-3 line summary would carry the same signal.

3. Back-button to /chat is a no-op. When popstate fires for /chat (no id), syncFromUrl reads null and returns early — it never tells aUI to switch to a fresh thread. So pressing back from /chat/<id> to /chat leaves the chat UI still showing thread <id> while the URL says /chat. Inconsistent. Needs an explicit api.threads().switchToThread(undefined) / initialize! path on the !urlThreadId branch.

4. Dead typeof window === "undefined" checks (lines 370, 386). Both are inside code paths that already require a window (a useEffect body that just called addEventListener on window / a .catch chained off switchToThread after that same effect). Browser-only code cannot have window === undefined here — they're noise that makes the actual branch (window.location.pathname === "/chat") harder to spot in review.

5. PR description claims "pre-validate ids with threadListAdapter.list() before switchToThread" but the code only pre-validates by trying switchToThread and catching. If a future reviewer reasons from the description they'll look for the list call and not find it. Either implement the pre-validation or update the description.

6. Error boundary swallows the error. app/chat/[threadId]/error.tsx ignores the error prop entirely and silently redirects. That makes sense for the bogus-id case described in the comment, but error.tsx also catches any segment-level render error (the comment acknowledges this). Genuine production bugs (auth throw, RSC serialize hiccup, runtime init failure) are now invisible to whoever is monitoring the console — console.error(error) would preserve dev visibility while keeping the redirect.

7. TDD (CLAUDE.md rule #2). The new ThreadUrlShadow + writeUrlForThread behavior — including the race in #1 and the back-button-to-/chat gap in #3 — has no test in tests/frontend/ or tests/e2e/. The PR description's "Verified" table is a Chrome DevTools MCP walkthrough but isn't reproducible in CI. At minimum, exercising the popstate → switchToThread echo via a Vitest + jsdom simulation would catch #1 and #3 in PR CI.

Happy to expand any of these into inline comments or PR code suggestions.

Comment thread app/assistant.tsx
// below) so user-driven thread switches write the URL automatically.
// - listen to `popstate` so browser back/forward syncs aUI without a
// Next.js route change.
// `lastSyncedIdRef` prevents the popstate → switchToThread → onThreadIdChange

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale docstring — this paragraph describes a lastSyncedIdRef that the actual ThreadUrlShadow implementation never defines; the implementation now uses no such ref. Either reintroduce the ref (which would actually fix the race in the comment 30 lines above this) or trim this line so the docstring matches the code. CLAUDE.md rule #5 also asks for short, why-not-what comments — this block is ~20 lines of prose explaining the bypass that could collapse to a 2-3 line note.

Comment thread app/assistant.tsx
// RemoteThreadListThreadListRuntimeCore._notifyThreadIdChange.
// We push the URL via plain history.pushState (no Next router), so
// thread nav doesn't RSC-refetch the page.
onThreadIdChange: writeUrlForThread,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race: on mount of /chat/<id>, the runtime's create: factory initializes a placeholder (__LOCALID_*) and fires onThreadIdChange(undefined)writeUrlForThread(undefined)pushState("/chat"). Then syncFromUrl's switchToThread(<id>) resolves and aUI fires onThreadIdChange(<id>) → another pushState("/chat/<id>"). Net effect: history gains an extra /chat entry between the user's previous page and the restored thread, breaking Back on share-link/reload (the exact scenario this PR targets).

The pathname equality check is not enough here — undefined → "/chat" proceeds because the URL is /chat/<id> at the time, not /chat. Dedup happens per remoteId inside aUI, but undefined IS a distinct notify the runtime fires on first mount.

Fix: silence the placeholder notify (e.g. flip a hasSeenFirstRemoteIdRef once a real uuid arrives, or treat undefined → /chat as replaceState instead of pushState so it doesn't grow the stack).

Comment thread app/assistant.tsx
localStorage.setItem(ACTIVE_THREAD_ID, id);
}, [activeExternalId, mainThreadId]);
const syncFromUrl = () => {
const urlThreadId = readUrlThreadId();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the user navigates back/forward to the bare /chat URL, syncFromUrl reads null and returns early — it never tells aUI to switch off the current thread. So popstate to /chat leaves the chat UI still rendering thread <id> while the URL bar says /chat. Inconsistent state.

Either invoke api.threads().switchToThread(undefined) (or re-run the create: path that boots a fresh placeholder) on the !urlThreadId branch, or have +New push /chat and rely on the runtime already being in placeholder mode at that point. Right now back-button behavior is documented as "works" in the PR description but the popstate handler does nothing for /chat.

Comment thread app/assistant.tsx
void Promise.resolve(
api.threads().switchToThread(urlThreadId) as unknown as Promise<void>,
).catch(() => {
if (typeof window === "undefined" || window.location.pathname === "/chat") return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead branch: typeof window === "undefined" inside readUrlThreadId, which is only ever called from syncFromUrl inside a useEffect body that already called addEventListener("popstate", ...). window is necessarily defined by the time we get here. The check makes the real branch (pathname !== /chat) easier to miss in review and adds SSR paranoia to a path that can never run during SSR. Drop it.

Comment thread app/assistant.tsx
// anything else) → null. window.location (NOT useParams/usePathname)
// because Next.js's router cache doesn't observe our pushState writes.
const readUrlThreadId = (): string | null => {
if (typeof window === "undefined") return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead branch: typeof window === "undefined" inside .catch(), which runs as a microtask after switchToThread was invoked from syncFromUrl (itself inside the same effect that already bound popstate). The window global is necessarily defined. The pathname guard is the actual logic; the typeof check is noise.

const router = useRouter();
useEffect(() => {
router.replace("/chat");
}, [router]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error is silently dropped. The comment names "auth check throw, RSC serialize hiccup" as expected fallthrough cases — those are real production bugs that should be visible in dev / staging console logs. Calling console.error(error) before router.replace keeps the redirect UX intact while preserving signal for debugging. Today, the only way an engineer sees those errors is by reproducing the URL — which is fine for the bogus-id case (error.message isn't interesting) but not for the generic catch-all.

// remount on every route push (issue #27 history: that double-fires
// `load()` and the adapter `fetch()`).
export default async function ChatThreadPage() {
const session = await getSessionFromHeaders();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing tests (CLAUDE.md rule #2). The PR introduces non-trivial new client behavior — ThreadUrlShadow, writeUrlForThread, this dynamic route page, and the error boundary — and the verification in the PR description is a Chrome DevTools MCP walkthrough, not a CI-runnable test. At minimum, a Vitest+jsdom test for writeUrlForThread (idempotency, placeholder handling, no spurious history) and a Playwright test that exercises share-link reload + back/forward would catch the race + the /chat popstate gap I'd flag inline. No test files under tests/frontend/ or tests/e2e/ reference ThreadUrlShadow / chat/[threadId].

@FireTable FireTable merged commit 7c6e455 into main Jul 10, 2026
39 checks passed
@FireTable FireTable deleted the feat/issue-27-thread-url-sync branch July 10, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat]: Clicking a thread in the thread list should sync the URL (so reload / share-link lands on that thread directly)

1 participant