-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat]: Sync URL with active thread (issue #27) #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1ce0fb3
0427fd0
a7d567f
519a5ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -26,7 +26,6 @@ import { Button } from "@/components/ui/button"; | |||||||||||||||||||||||
| import { threadListAdapter } from "@/lib/threads/adapter"; | ||||||||||||||||||||||||
| import { R2AttachmentAdapter } from "@/lib/attachments/r2-adapter"; | ||||||||||||||||||||||||
| import { cn } from "@/lib/utils"; | ||||||||||||||||||||||||
| import { LOCAL_THREAD_PREFIX, ACTIVE_THREAD_ID } from "@/lib/constants"; | ||||||||||||||||||||||||
| import { createLangGraphStream } from "@/lib/langgraph/create-stream"; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Provider-scoped values (api, mainThreadId) bridged into a ref so the | ||||||||||||||||||||||||
|
|
@@ -216,6 +215,12 @@ export function Assistant() { | |||||||||||||||||||||||
| unstable_allowCancellation: true, | ||||||||||||||||||||||||
| unstable_enableMessageQueue: true, | ||||||||||||||||||||||||
| unstable_threadListAdapter: threadListAdapter, | ||||||||||||||||||||||||
| // ponytail: aUI's built-in thread-change hook fires once per | ||||||||||||||||||||||||
| // _mainThreadRemoteId transition, deduped internally — see | ||||||||||||||||||||||||
| // RemoteThreadListThreadListRuntimeCore._notifyThreadIdChange. | ||||||||||||||||||||||||
| // We push the URL via plain history.pushState (no Next router), so | ||||||||||||||||||||||||
| // thread nav doesn't RSC-refetch the page. | ||||||||||||||||||||||||
| onThreadIdChange: writeUrlForThread, | ||||||||||||||||||||||||
| stream, | ||||||||||||||||||||||||
| eventHandlers, | ||||||||||||||||||||||||
| ...(attachments ? { adapters: { attachments } } : {}), | ||||||||||||||||||||||||
|
|
@@ -307,7 +312,7 @@ export function Assistant() { | |||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||
| <AssistantRuntimeProvider aui={aui} runtime={runtime}> | ||||||||||||||||||||||||
| <AuiRefCapture bridgeRef={bridgeRef} /> | ||||||||||||||||||||||||
| <ThreadPersistence /> | ||||||||||||||||||||||||
| <ThreadUrlShadow /> | ||||||||||||||||||||||||
| <div className="bg-muted/30 flex h-dvh w-full"> | ||||||||||||||||||||||||
| <Sidebar collapsed={sidebarCollapsed} /> | ||||||||||||||||||||||||
| <div className="flex min-w-0 flex-1 flex-col overflow-hidden p-2 md:pl-0"> | ||||||||||||||||||||||||
|
|
@@ -335,45 +340,73 @@ const AuiRefCapture: FC<{ bridgeRef: RefObject<RuntimeBridge> }> = ({ bridgeRef | |||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale docstring — this paragraph describes a |
||||||||||||||||||||||||
| // echo from re-pushing. | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The leading comment on
But the only assignment is Either:
Right now the inline comment is misleading enough that a future reader will think the dedup is stronger than it is.
Suggested change
|
||||||||||||||||||||||||
| // ponytail: trust the URL. /chat → aUI's default (placeholder, new | ||||||||||||||||||||||||
| // thread welcome). /chat/<id> → "the active thread is this id". | ||||||||||||||||||||||||
| // React to URL changes (mount, back/forward) by switching aUI; react | ||||||||||||||||||||||||
| // to aUI's `onThreadIdChange` by writing the URL back. No clever | ||||||||||||||||||||||||
| // precheck, no parallel placeholder effect, no lastSyncedIdRef — | ||||||||||||||||||||||||
| // `switchToThread` is idempotent and `onThreadIdChange` already dedupes | ||||||||||||||||||||||||
| // internally. The only failure path is aUI's Promise reject on a | ||||||||||||||||||||||||
| // genuinely-bogus id (404 / cross-user), which we redirect to /chat. | ||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Runs before the write effect on first commit so hasHydratedRef | ||||||||||||||||||||||||
| // suppresses the placeholder write below. | ||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| hasHydratedRef.current = true; | ||||||||||||||||||||||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||
| // Read thread id off the current path. /chat/<id> → id. /chat (or | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||||||||||||||||
| // anything else) → null. window.location (NOT useParams/usePathname) | ||||||||||||||||||||||||
| // because Next.js's router cache doesn't observe our pushState writes. | ||||||||||||||||||||||||
| const readUrlThreadId = (): string | null => { | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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
Suggested change
|
||||||||||||||||||||||||
| if (typeof window === "undefined") return null; | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dead branch: |
||||||||||||||||||||||||
| const m = window.location.pathname.match(/^\/chat\/([^/]+)$/); | ||||||||||||||||||||||||
| return m ? decodeURIComponent(m[1]) : null; | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Wrap in try/catch and fall back to raw There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
Comment on lines
+366
to
+373
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The first line of the Context Used: CLAUDE.md (source) Prompt To Fix With AIThis 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! |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // URL → aUI: on mount + on browser back/forward. `switchToThread` | ||||||||||||||||||||||||
| // is idempotent (aUI returns early when already on the target id) | ||||||||||||||||||||||||
| // and resolves to a Promise that rejects for unknown / cross-user | ||||||||||||||||||||||||
| // ids — that's our "this URL was bogus" signal, no inference needed. | ||||||||||||||||||||||||
| 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]); | ||||||||||||||||||||||||
| const syncFromUrl = () => { | ||||||||||||||||||||||||
| const urlThreadId = readUrlThreadId(); | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When the user navigates back/forward to the bare Either invoke |
||||||||||||||||||||||||
| if (!urlThreadId) return; | ||||||||||||||||||||||||
| void Promise.resolve( | ||||||||||||||||||||||||
| api.threads().switchToThread(urlThreadId) as unknown as Promise<void>, | ||||||||||||||||||||||||
| ).catch(() => { | ||||||||||||||||||||||||
| if (typeof window === "undefined" || window.location.pathname === "/chat") return; | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dead branch: |
||||||||||||||||||||||||
| window.history.replaceState({ threadId: null }, "", "/chat"); | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
| syncFromUrl(); | ||||||||||||||||||||||||
| window.addEventListener("popstate", syncFromUrl); | ||||||||||||||||||||||||
| return () => window.removeEventListener("popstate", syncFromUrl); | ||||||||||||||||||||||||
| }, [api]); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
Comment on lines
340
to
396
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Context Used: CLAUDE.md (source) Prompt To Fix With AIThis 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. |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // aUI → URL: write side. Module-scope closure passed to | ||||||||||||||||||||||||
| // `useLangGraphRuntime`'s `onThreadIdChange`; the runtime already | ||||||||||||||||||||||||
| // dedupes internally (_notifyThreadIdChange keeps a lastNotifiedThreadId | ||||||||||||||||||||||||
| // ref), so we just sync the URL on every notify. | ||||||||||||||||||||||||
| // | ||||||||||||||||||||||||
| // `remoteId === undefined` → push /chat (placeholder / cleared state, | ||||||||||||||||||||||||
| // e.g. immediately after +New before adapter.initialize resolves; | ||||||||||||||||||||||||
| // we treat that as "user wants /chat for now"). Real uuid → push | ||||||||||||||||||||||||
| // /chat/<uuid>. No preconditions, no reads of aUI's state. | ||||||||||||||||||||||||
| const writeUrlForThread = (remoteId: string | undefined): void => { | ||||||||||||||||||||||||
| if (typeof window === "undefined") return; | ||||||||||||||||||||||||
| const target = remoteId ? `/chat/${remoteId}` : "/chat"; | ||||||||||||||||||||||||
| if (window.location.pathname === target) return; | ||||||||||||||||||||||||
| window.history.pushState(remoteId ? { threadId: remoteId } : { threadId: null }, "", target); | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,28 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "use client"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pre-flight the redirect inside the server pass (no client flash)
Since this is the failure-mode fallback for "the page couldn't render," the cleanest fix is to do the redirect server-side in (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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useEffect } from "react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useRouter } from "next/navigation"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ponytail: fall back to /chat whenever this segment can't render. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Two failure modes hit this in practice: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 1. Turbopack "Entry not available in the store" — a Next 16 dev-mode | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // cache miss on a dynamic param we haven't built yet. Replacing | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // with the root URL lands us in the well-trodden /chat path | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // where the runtime's constructor creates a fresh placeholder. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 2. Any other segment-level render error (auth check throw, RSC | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // serialize hiccup, etc.). Same fallback. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `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"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [router]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { redirect } from "next/navigation"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
| import { Assistant } from "@/app/assistant"; | ||
| import { getSessionFromHeaders } from "@/lib/auth/queries"; | ||
|
|
||
| // ponytail: the dynamic route exists ONLY so refresh / share-link on | ||
| // /chat/<id> lands on a real page. Thread state is read off | ||
| // `window.location.pathname` client-side by `ThreadUrlShadow`, NOT off | ||
| // 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() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This page is byte-for-byte equivalent to
Either fix prevents the "I changed the redirect URL on one page and forgot the other" failure mode. |
||
| const session = await getSessionFromHeaders(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 — |
||
| if (!session) redirect("/login"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Logged-out users opening Prompt for AI agents |
||
| return <Assistant />; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -1,14 +1,11 @@ | ||||||||
| // Placeholder mainThreadId assigned to a freshly-created "new thread" that | ||||||||
| // hasn't been persisted yet. Filter it out before writing to localStorage — | ||||||||
| // we don't want to "remember" a thread id that has no backing record on | ||||||||
| // the server, otherwise the next page load would try to switchToThread a | ||||||||
| // ghost and hit 404. | ||||||||
| // 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.) | ||||||||
|
Comment on lines
+5
to
+6
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||||
| export const LOCAL_THREAD_PREFIX = "__LOCALID_"; | ||||||||
|
|
||||||||
| // localStorage key for the active thread id. Must match the string the | ||||||||
| // runtime reads on hydration. | ||||||||
| export const ACTIVE_THREAD_ID = "ACTIVE_THREAD_ID"; | ||||||||
|
|
||||||||
| // User-facing product name. Don't hardcode "LangGraph" / "assistant-ui" | ||||||||
| // strings elsewhere; import this so the brand is one-line to change. | ||||||||
| export const APP_NAME = "LangGraph App"; | ||||||||
|
|
||||||||
There was a problem hiding this comment.
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'screate:factory initializes a placeholder (__LOCALID_*) and firesonThreadIdChange(undefined)→writeUrlForThread(undefined)→pushState("/chat"). ThensyncFromUrl'sswitchToThread(<id>)resolves and aUI firesonThreadIdChange(<id>)→ anotherpushState("/chat/<id>"). Net effect: history gains an extra/chatentry 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, butundefinedIS a distinct notify the runtime fires on first mount.Fix: silence the placeholder notify (e.g. flip a
hasSeenFirstRemoteIdRefonce a real uuid arrives, or treatundefined → /chatasreplaceStateinstead ofpushStateso it doesn't grow the stack).