From 532111b2e6772f86a109c8c0a33773011d4a34d3 Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:03:25 +0800 Subject: [PATCH] fix(frontend): encode thread IDs in chat routes (#4302) --- README.md | 2 ++ frontend/AGENTS.md | 1 + frontend/src/core/threads/utils.ts | 5 +++-- frontend/tests/unit/core/threads/utils.test.ts | 12 ++++++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4759c0240..1bc887417 100644 --- a/README.md +++ b/README.md @@ -682,6 +682,8 @@ Interrupted first-turn runs still persist a fallback conversation title, so stop In the Web UI, completed assistant turns can be branched into a new main conversation. The new thread starts from that turn's checkpoint. Because workspace files are not checkpointed, the branch only receives a best-effort copy of the current workspace when you branch from the latest turn; branching from an older turn keeps just the restored message history so the branch never inherits files that were created in a later part of the conversation. +Web UI chat links percent-encode custom thread identifiers before placing them in route segments, so reserved URL characters such as `#` and `?` do not change which conversation is opened. + ``` # Paths inside the sandbox container /mnt/skills/public diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 3abe3fa54..9c709e0d7 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -82,6 +82,7 @@ Tool-calling AI messages can contain user-visible text as well as `tool_calls`. - **Server Components by default**, `"use client"` only for interactive components - **Thread hooks** (`useThreadStream`, `useSubmitThread`, `useThreads`) are the primary API interface +- **Thread routes** — construct Web UI chat paths through `core/threads/utils.ts::pathOfThread()`, which percent-encodes both custom agent names and thread IDs before inserting them into route segments - **LangGraph client** is a singleton obtained via `getAPIClient()` in `core/api/` - **Environment validation** uses `@t3-oss/env-nextjs` with Zod schemas (`src/env.js`). Skip with `SKIP_ENV_VALIDATION=1` - **Subtask step history and runtime metadata** (`core/tasks/`) — the subtask card shows a subagent's full step timeline (#3779): its assistant reasoning turns interleaved with the tools it ran. `Subtask.steps[]` is accumulated live from `task_running` events (appended via `mergeSteps`, not overwritten) and backfilled on expand for historical runs by `fetchSubtaskSteps`, which pages the events endpoint scoped to one task (GET `/runs/{runId}/events?event_types=subagent.step&task_id=…&after_seq=…`) until a short page, so the run-wide limit can't truncate the timeline. `task_started` carries the effective `model_name`; `task_running` carries a cumulative usage snapshot after each completed LLM call. `core/tasks/lifecycle.ts` normalizes these additive events, and `computeNextSubtask` keeps the largest cumulative total so replayed or late SSE frames cannot double-count or roll the folded card backward. Terminal ToolMessage metadata (`subagent_model_name` / `subagent_token_usage`) restores the same values from normal history after reload; no per-card event fetch is needed. `core/tasks/steps.ts` is the pure step model: `messageToStep` (live), `eventsToSteps` (reload), `mergeSteps` (dedup by `message_index`), and `stepsForDisplay` (what the card renders — keeps tool steps + AI steps with text, drops the trailing final-answer AI step when completed since it's shown as `result`). `core/tasks/context.tsx`'s `useUpdateSubtask` applies updates against a `tasksRef` mirroring the latest state (not a closure snapshot), so a late-resolving `fetchSubtaskSteps` backfill merges into current state instead of clobbering SSE steps or sibling subtasks that arrived meanwhile. The owning `run_id` is carried onto history content messages in `buildVisibleHistoryMessages` so the card can resolve the events endpoint. diff --git a/frontend/src/core/threads/utils.ts b/frontend/src/core/threads/utils.ts index b1b8af119..ae543a6f2 100644 --- a/frontend/src/core/threads/utils.ts +++ b/frontend/src/core/threads/utils.ts @@ -21,6 +21,7 @@ export function pathOfThread( context?: Pick | null, ) { const threadId = typeof thread === "string" ? thread : thread.thread_id; + const encodedThreadId = encodeURIComponent(threadId); let agentName: string | undefined; if (typeof thread === "string") { agentName = context?.agent_name; @@ -35,8 +36,8 @@ export function pathOfThread( } return agentName - ? `/workspace/agents/${encodeURIComponent(agentName)}/chats/${threadId}` - : `/workspace/chats/${threadId}`; + ? `/workspace/agents/${encodeURIComponent(agentName)}/chats/${encodedThreadId}` + : `/workspace/chats/${encodedThreadId}`; } export function textOfMessage(message: Message) { diff --git a/frontend/tests/unit/core/threads/utils.test.ts b/frontend/tests/unit/core/threads/utils.test.ts index a55d47ca0..e51967d73 100644 --- a/frontend/tests/unit/core/threads/utils.test.ts +++ b/frontend/tests/unit/core/threads/utils.test.ts @@ -16,6 +16,18 @@ test("uses standard chat route when thread has no agent context", () => { ).toBe("/workspace/chats/thread-123"); }); +test("encodes thread ids in standard chat routes", () => { + expect(pathOfThread("thread#1?draft")).toBe( + "/workspace/chats/thread%231%3Fdraft", + ); +}); + +test("encodes thread ids in agent chat routes", () => { + expect(pathOfThread("thread#1?draft", { agent_name: "researcher" })).toBe( + "/workspace/agents/researcher/chats/thread%231%3Fdraft", + ); +}); + test("uses agent chat route when thread context has agent_name", () => { expect( pathOfThread({