authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-27 21:52:11+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-27 21:52:11+02:00
loge77f42c111dc40e0b208393957ebb23863330e2e
treed18baf32dbbfcf7e2cfe59054980a825764aea0b
parent6e3b73884bf40eeb2859cb16f1200c8e2f9cf7c3
signature Signed by PGP key B5690EEEBB952194

Prevent draft thread detail polling before shell registration (#4670)

Co-authored-by: codex <codex@users.noreply.github.com>

6 files changed, 79 insertions(+), 16 deletions(-)

apps/web/src/components/BranchToolbar.tsx+1-1
...@@ -234,10 +234,10 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -234,10 +234,10 @@ export const BranchToolbar = memo(function BranchToolbar({
234 () => scopeThreadRef(environmentId, threadId),234 () => scopeThreadRef(environmentId, threadId),
235 [environmentId, threadId],235 [environmentId, threadId],
236 );236 );
237 const serverThread = useThread(threadRef);
238 const draftThread = useComposerDraftStore((store) =>237 const draftThread = useComposerDraftStore((store) =>
239 draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),238 draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
240 );239 );
240 const serverThread = useThread(threadRef, { waitForShell: draftThread !== null });
241 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);241 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
242 const activeProjectRef = serverThread242 const activeProjectRef = serverThread
243 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)243 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
apps/web/src/components/BranchToolbarBranchSelector.tsx+2-2
...@@ -129,11 +129,11 @@ export function BranchToolbarBranchSelector({...@@ -129,11 +129,11 @@ export function BranchToolbarBranchSelector({
129 () => scopeThreadRef(environmentId, threadId),129 () => scopeThreadRef(environmentId, threadId),
130 [environmentId, threadId],130 [environmentId, threadId],
131 );131 );
132 const serverThread = useThread(threadRef);
133 const serverSession = serverThread?.session ?? null;
134 const draftThread = useComposerDraftStore((store) =>132 const draftThread = useComposerDraftStore((store) =>
135 draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),133 draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef),
136 );134 );
135 const serverThread = useThread(threadRef, { waitForShell: draftThread !== null });
136 const serverSession = serverThread?.session ?? null;
137 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);137 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
138138
139 const activeProjectRef = serverThread139 const activeProjectRef = serverThread
apps/web/src/components/ChatView.tsx+10-10
...@@ -620,8 +620,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -620,8 +620,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
620 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");620 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
621 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");621 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
622 const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");622 const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");
623 const serverThread = useThread(threadRef);
624 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));623 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
624 const serverThread = useThread(threadRef, { waitForShell: draftThread !== null });
625 const projectRef = serverThread625 const projectRef = serverThread
626 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)626 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
627 : draftThread627 : draftThread
...@@ -976,8 +976,8 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane...@@ -976,8 +976,8 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane
976 newShortcutLabel,976 newShortcutLabel,
977 closeShortcutLabel,977 closeShortcutLabel,
978}: PersistentThreadTerminalPanelProps) {978}: PersistentThreadTerminalPanelProps) {
979 const serverThread = useThread(threadRef);
980 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));979 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
980 const serverThread = useThread(threadRef, { waitForShell: draftThread !== null });
981 const projectRef = serverThread981 const projectRef = serverThread
982 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)982 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
983 : draftThread983 : draftThread
...@@ -1181,7 +1181,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1181,7 +1181,14 @@ function ChatViewContent(props: ChatViewProps) {
1181 );1181 );
1182 const composerDraftTarget: ScopedThreadRef | DraftId =1182 const composerDraftTarget: ScopedThreadRef | DraftId =
1183 routeKind === "server" ? routeThreadRef : props.draftId;1183 routeKind === "server" ? routeThreadRef : props.draftId;
1184 const serverThread = useThread(routeThreadRef);1184 const draftThread = useComposerDraftStore((store) =>
1185 routeKind === "server"
1186 ? store.getDraftSessionByRef(routeThreadRef)
1187 : draftId
1188 ? store.getDraftSession(draftId)
1189 : null,
1190 );
1191 const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null });
1185 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);1192 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
1186 const activeThreadLastVisitedAt = useUiStateStore(1193 const activeThreadLastVisitedAt = useUiStateStore(
1187 (store) => store.threadLastVisitedAtById[routeThreadKey],1194 (store) => store.threadLastVisitedAtById[routeThreadKey],
...@@ -1234,13 +1241,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1234,13 +1241,6 @@ function ChatViewContent(props: ChatViewProps) {
1234 const setLogicalProjectDraftThreadId = useComposerDraftStore(1241 const setLogicalProjectDraftThreadId = useComposerDraftStore(
1235 (store) => store.setLogicalProjectDraftThreadId,1242 (store) => store.setLogicalProjectDraftThreadId,
1236 );1243 );
1237 const draftThread = useComposerDraftStore((store) =>
1238 routeKind === "server"
1239 ? store.getDraftSessionByRef(routeThreadRef)
1240 : draftId
1241 ? store.getDraftSession(draftId)
1242 : null,
1243 );
1244 const promptRef = useRef("");1244 const promptRef = useRef("");
1245 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);1245 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
1246 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);1246 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
apps/web/src/components/GitActionsControl.tsx+3-1
...@@ -986,7 +986,6 @@ export default function GitActionsControl({...@@ -986,7 +986,6 @@ export default function GitActionsControl({
986 () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),986 () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),
987 [activeThreadRef],987 [activeThreadRef],
988 );988 );
989 const activeServerThread = useThread(activeThreadRef);
990 const activeDraftThread = useComposerDraftStore((store) =>989 const activeDraftThread = useComposerDraftStore((store) =>
991 draftId990 draftId
992 ? store.getDraftSession(draftId)991 ? store.getDraftSession(draftId)
...@@ -994,6 +993,9 @@ export default function GitActionsControl({...@@ -994,6 +993,9 @@ export default function GitActionsControl({
994 ? store.getDraftThreadByRef(activeThreadRef)993 ? store.getDraftThreadByRef(activeThreadRef)
995 : null,994 : null,
996 );995 );
996 const activeServerThread = useThread(activeThreadRef, {
997 waitForShell: activeDraftThread !== null,
998 });
997 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);999 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
998 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);1000 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
999 const [dialogCommitMessage, setDialogCommitMessage] = useState("");1001 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
apps/web/src/state/entities.test.ts created+36
...@@ -0,0 +1,36 @@
1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import { resolveThreadDetailRef } from "./entities";
6
7const threadRef = scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-1"));
8
9describe("resolveThreadDetailRef", () => {
10 it("does not subscribe to a reserved draft thread before it enters the shell index", () => {
11 expect(
12 resolveThreadDetailRef(threadRef, {
13 shellExists: false,
14 waitForShell: true,
15 }),
16 ).toBeNull();
17 });
18
19 it("subscribes once the reserved draft thread enters the shell index", () => {
20 expect(
21 resolveThreadDetailRef(threadRef, {
22 shellExists: true,
23 waitForShell: true,
24 }),
25 ).toBe(threadRef);
26 });
27
28 it("keeps direct server-thread lookups enabled when the shell has not loaded it", () => {
29 expect(
30 resolveThreadDetailRef(threadRef, {
31 shellExists: false,
32 waitForShell: false,
33 }),
34 ).toBe(threadRef);
35 });
36});
apps/web/src/state/entities.ts+27-2
...@@ -152,10 +152,35 @@ export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadS...@@ -152,10 +152,35 @@ export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadS
152 );152 );
153}153}
154154
155export function resolveThreadDetailRef(
156 ref: ScopedThreadRef | null,
157 options: {
158 shellExists: boolean;
159 waitForShell: boolean;
160 },
161): ScopedThreadRef | null {
162 return ref !== null && (!options.waitForShell || options.shellExists) ? ref : null;
163}
164
155/** Detail collections composed with shell-authoritative thread/workspace metadata. */165/** Detail collections composed with shell-authoritative thread/workspace metadata. */
156export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null {166export function useThread(
167 ref: ScopedThreadRef | null,
168 options?: {
169 /**
170 * Client-reserved draft thread ids do not exist on the server until the
171 * first send. Waiting for the shell index avoids polling the detail
172 * endpoint for an intentionally missing thread during that window.
173 */
174 waitForShell?: boolean;
175 },
176): EnvironmentThread | null {
157 const shell = useThreadShell(ref);177 const shell = useThreadShell(ref);
158 const detail = useThreadDetail(ref);178 const detail = useThreadDetail(
179 resolveThreadDetailRef(ref, {
180 shellExists: shell !== null,
181 waitForShell: options?.waitForShell === true,
182 }),
183 );
159 return useMemo(() => mergeEnvironmentThread(detail, shell), [detail, shell]);184 return useMemo(() => mergeEnvironmentThread(detail, shell), [detail, shell]);
160}185}
161186