authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-09 12:59:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 19:59:30+00:00
logb547fee77e77b3e813ce81284aea25781fec28b3
treefb19f6812045917b0c01749a97339bbbab5710fe
parent7b3cdc6a8c69224aa2314e4969579c3e82b6b0ba
signature Signed by PGP key B5690EEEBB952194

Scope git toast state by thread ref (#1855)


8 files changed, 352 insertions(+), 79 deletions(-)

apps/web/src/components/BranchToolbarBranchSelector.tsx+1-1
...@@ -144,7 +144,7 @@ export function BranchToolbarBranchSelector({...@@ -144,7 +144,7 @@ export function BranchToolbarBranchSelector({
144 });144 });
145 }145 }
146 if (hasServerThread) {146 if (hasServerThread) {
147 setThreadBranchAction(activeThreadId, branch, worktreePath);147 setThreadBranchAction(threadRef, branch, worktreePath);
148 return;148 return;
149 }149 }
150 const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({150 const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({
apps/web/src/components/GitActionsControl.browser.tsx+133-26
...@@ -4,9 +4,9 @@ import { useState } from "react";...@@ -4,9 +4,9 @@ import { useState } from "react";
4import { afterEach, describe, expect, it, vi } from "vitest";4import { afterEach, describe, expect, it, vi } from "vitest";
5import { render } from "vitest-browser-react";5import { render } from "vitest-browser-react";
66
7const THREAD_A = ThreadId.makeUnsafe("thread-a");7const SHARED_THREAD_ID = ThreadId.makeUnsafe("thread-shared");
8const THREAD_B = ThreadId.makeUnsafe("thread-b");8const ENVIRONMENT_A = "environment-local" as never;
9const ENVIRONMENT_ID = "environment-local" as never;9const ENVIRONMENT_B = "environment-remote" as never;
10const GIT_CWD = "/repo/project";10const GIT_CWD = "/repo/project";
11const BRANCH_NAME = "feature/toast-scope";11const BRANCH_NAME = "feature/toast-scope";
1212
...@@ -24,9 +24,12 @@ function createDeferredPromise<T>() {...@@ -24,9 +24,12 @@ function createDeferredPromise<T>() {
2424
25const {25const {
26 activeRunStackedActionDeferredRef,26 activeRunStackedActionDeferredRef,
27 activeDraftThreadRef,
28 hasServerThreadRef,
27 invalidateGitQueriesSpy,29 invalidateGitQueriesSpy,
28 refreshGitStatusSpy,30 refreshGitStatusSpy,
29 runStackedActionMutateAsyncSpy,31 runStackedActionMutateAsyncSpy,
32 setDraftThreadContextSpy,
30 setThreadBranchSpy,33 setThreadBranchSpy,
31 toastAddSpy,34 toastAddSpy,
32 toastCloseSpy,35 toastCloseSpy,
...@@ -34,9 +37,12 @@ const {...@@ -34,9 +37,12 @@ const {
34 toastUpdateSpy,37 toastUpdateSpy,
35} = vi.hoisted(() => ({38} = vi.hoisted(() => ({
36 activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },39 activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },
40 activeDraftThreadRef: { current: null as unknown },
41 hasServerThreadRef: { current: true },
37 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),42 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),
38 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),43 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),
39 runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),44 runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),
45 setDraftThreadContextSpy: vi.fn(),
40 setThreadBranchSpy: vi.fn(),46 setThreadBranchSpy: vi.fn(),
41 toastAddSpy: vi.fn(() => "toast-1"),47 toastAddSpy: vi.fn(() => "toast-1"),
42 toastCloseSpy: vi.fn(),48 toastCloseSpy: vi.fn(),
...@@ -123,6 +129,36 @@ vi.mock("~/localApi", () => ({...@@ -123,6 +129,36 @@ vi.mock("~/localApi", () => ({
123 readLocalApi: vi.fn(() => null),129 readLocalApi: vi.fn(() => null),
124}));130}));
125131
132vi.mock("~/composerDraftStore", async () => {
133 const draftStoreState = {
134 getDraftThreadByRef: () => activeDraftThreadRef.current,
135 getDraftSession: () => activeDraftThreadRef.current,
136 getDraftThread: () => activeDraftThreadRef.current,
137 getDraftSessionByLogicalProjectKey: () => null,
138 setDraftThreadContext: setDraftThreadContextSpy,
139 setLogicalProjectDraftThreadId: vi.fn(),
140 setProjectDraftThreadId: vi.fn(),
141 hasDraftThreadsInEnvironment: () => false,
142 clearDraftThread: vi.fn(),
143 };
144
145 return {
146 DraftId: {
147 makeUnsafe: (value: string) => value,
148 },
149 useComposerDraftStore: Object.assign(
150 (selector: (state: unknown) => unknown) => selector(draftStoreState),
151 { getState: () => draftStoreState },
152 ),
153 markPromotedDraftThread: vi.fn(),
154 markPromotedDraftThreadByRef: vi.fn(),
155 markPromotedDraftThreads: vi.fn(),
156 markPromotedDraftThreadsByRef: vi.fn(),
157 finalizePromotedDraftThreadByRef: vi.fn(),
158 finalizePromotedDraftThreadsByRef: vi.fn(),
159 };
160});
161
126vi.mock("~/store", () => ({162vi.mock("~/store", () => ({
127 selectEnvironmentState: (163 selectEnvironmentState: (
128 state: { environmentStateById: Record<string, unknown> },164 state: { environmentStateById: Record<string, unknown> },
...@@ -154,11 +190,37 @@ vi.mock("~/store", () => ({...@@ -154,11 +190,37 @@ vi.mock("~/store", () => ({
154 selector({190 selector({
155 setThreadBranch: setThreadBranchSpy,191 setThreadBranch: setThreadBranchSpy,
156 environmentStateById: {192 environmentStateById: {
157 [ENVIRONMENT_ID]: {193 [ENVIRONMENT_A]: {
158 threadShellById: {194 threadShellById: hasServerThreadRef.current
159 [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null },195 ? {
160 [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null },196 [SHARED_THREAD_ID]: {
161 },197 id: SHARED_THREAD_ID,
198 branch: BRANCH_NAME,
199 worktreePath: null,
200 },
201 }
202 : {},
203 threadSessionById: {},
204 threadTurnStateById: {},
205 messageIdsByThreadId: {},
206 messageByThreadId: {},
207 activityIdsByThreadId: {},
208 activityByThreadId: {},
209 proposedPlanIdsByThreadId: {},
210 proposedPlanByThreadId: {},
211 turnDiffIdsByThreadId: {},
212 turnDiffSummaryByThreadId: {},
213 },
214 [ENVIRONMENT_B]: {
215 threadShellById: hasServerThreadRef.current
216 ? {
217 [SHARED_THREAD_ID]: {
218 id: SHARED_THREAD_ID,
219 branch: BRANCH_NAME,
220 worktreePath: null,
221 },
222 }
223 : {},
162 threadSessionById: {},224 threadSessionById: {},
163 threadTurnStateById: {},225 threadTurnStateById: {},
164 messageIdsByThreadId: {},226 messageIdsByThreadId: {},
...@@ -187,17 +249,19 @@ function findButtonByText(text: string): HTMLButtonElement | null {...@@ -187,17 +249,19 @@ function findButtonByText(text: string): HTMLButtonElement | null {
187}249}
188250
189function Harness() {251function Harness() {
190 const [activeThreadId, setActiveThreadId] = useState(THREAD_A);252 const [activeThreadRef, setActiveThreadRef] = useState(
253 scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID),
254 );
191255
192 return (256 return (
193 <>257 <>
194 <button type="button" onClick={() => setActiveThreadId(THREAD_B)}>258 <button
195 Switch thread259 type="button"
260 onClick={() => setActiveThreadRef(scopeThreadRef(ENVIRONMENT_B, SHARED_THREAD_ID))}
261 >
262 Switch environment
196 </button>263 </button>
197 <GitActionsControl264 <GitActionsControl gitCwd={GIT_CWD} activeThreadRef={activeThreadRef} />
198 gitCwd={GIT_CWD}
199 activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, activeThreadId)}
200 />
201 </>265 </>
202 );266 );
203}267}
...@@ -207,10 +271,12 @@ describe("GitActionsControl thread-scoped progress toast", () => {...@@ -207,10 +271,12 @@ describe("GitActionsControl thread-scoped progress toast", () => {
207 vi.useRealTimers();271 vi.useRealTimers();
208 vi.clearAllMocks();272 vi.clearAllMocks();
209 activeRunStackedActionDeferredRef.current = createDeferredPromise<never>();273 activeRunStackedActionDeferredRef.current = createDeferredPromise<never>();
274 activeDraftThreadRef.current = null;
275 hasServerThreadRef.current = true;
210 document.body.innerHTML = "";276 document.body.innerHTML = "";
211 });277 });
212278
213 it("keeps an in-flight git action toast pinned to the thread that started it", async () => {279 it("keeps an in-flight git action toast pinned to the thread ref that started it", async () => {
214 vi.useFakeTimers();280 vi.useFakeTimers();
215281
216 const host = document.createElement("div");282 const host = document.createElement("div");
...@@ -227,7 +293,7 @@ describe("GitActionsControl thread-scoped progress toast", () => {...@@ -227,7 +293,7 @@ describe("GitActionsControl thread-scoped progress toast", () => {
227293
228 expect(toastAddSpy).toHaveBeenCalledWith(294 expect(toastAddSpy).toHaveBeenCalledWith(
229 expect.objectContaining({295 expect.objectContaining({
230 data: { threadId: THREAD_A },296 data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) },
231 title: "Pushing...",297 title: "Pushing...",
232 type: "loading",298 type: "loading",
233 }),299 }),
...@@ -238,24 +304,27 @@ describe("GitActionsControl thread-scoped progress toast", () => {...@@ -238,24 +304,27 @@ describe("GitActionsControl thread-scoped progress toast", () => {
238 expect(toastUpdateSpy).toHaveBeenLastCalledWith(304 expect(toastUpdateSpy).toHaveBeenLastCalledWith(
239 "toast-1",305 "toast-1",
240 expect.objectContaining({306 expect.objectContaining({
241 data: { threadId: THREAD_A },307 data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) },
242 title: "Pushing...",308 title: "Pushing...",
243 type: "loading",309 type: "loading",
244 }),310 }),
245 );311 );
246312
247 const switchThreadButton = findButtonByText("Switch thread");313 const switchEnvironmentButton = findButtonByText("Switch environment");
248 expect(switchThreadButton, 'Unable to find button containing "Switch thread"').toBeTruthy();314 expect(
249 if (!(switchThreadButton instanceof HTMLButtonElement)) {315 switchEnvironmentButton,
250 throw new Error('Unable to find button containing "Switch thread"');316 'Unable to find button containing "Switch environment"',
317 ).toBeTruthy();
318 if (!(switchEnvironmentButton instanceof HTMLButtonElement)) {
319 throw new Error('Unable to find button containing "Switch environment"');
251 }320 }
252 switchThreadButton.click();321 switchEnvironmentButton.click();
253 await vi.advanceTimersByTimeAsync(1_000);322 await vi.advanceTimersByTimeAsync(1_000);
254323
255 expect(toastUpdateSpy).toHaveBeenLastCalledWith(324 expect(toastUpdateSpy).toHaveBeenLastCalledWith(
256 "toast-1",325 "toast-1",
257 expect.objectContaining({326 expect.objectContaining({
258 data: { threadId: THREAD_A },327 data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) },
259 title: "Pushing...",328 title: "Pushing...",
260 type: "loading",329 type: "loading",
261 }),330 }),
...@@ -284,7 +353,7 @@ describe("GitActionsControl thread-scoped progress toast", () => {...@@ -284,7 +353,7 @@ describe("GitActionsControl thread-scoped progress toast", () => {
284 const screen = await render(353 const screen = await render(
285 <GitActionsControl354 <GitActionsControl
286 gitCwd={GIT_CWD}355 gitCwd={GIT_CWD}
287 activeThreadRef={scopeThreadRef(ENVIRONMENT_ID, THREAD_A)}356 activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)}
288 />,357 />,
289 {358 {
290 container: host,359 container: host,
...@@ -304,7 +373,7 @@ describe("GitActionsControl thread-scoped progress toast", () => {...@@ -304,7 +373,7 @@ describe("GitActionsControl thread-scoped progress toast", () => {
304 await vi.advanceTimersByTimeAsync(1);373 await vi.advanceTimersByTimeAsync(1);
305 expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1);374 expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1);
306 expect(refreshGitStatusSpy).toHaveBeenCalledWith({375 expect(refreshGitStatusSpy).toHaveBeenCalledWith({
307 environmentId: ENVIRONMENT_ID,376 environmentId: ENVIRONMENT_A,
308 cwd: GIT_CWD,377 cwd: GIT_CWD,
309 });378 });
310 } finally {379 } finally {
...@@ -316,4 +385,42 @@ describe("GitActionsControl thread-scoped progress toast", () => {...@@ -316,4 +385,42 @@ describe("GitActionsControl thread-scoped progress toast", () => {
316 host.remove();385 host.remove();
317 }386 }
318 });387 });
388
389 it("syncs the live branch into the active draft thread when no server thread exists", async () => {
390 hasServerThreadRef.current = false;
391 activeDraftThreadRef.current = {
392 threadId: SHARED_THREAD_ID,
393 environmentId: ENVIRONMENT_A,
394 branch: null,
395 worktreePath: null,
396 };
397
398 const host = document.createElement("div");
399 document.body.append(host);
400 const screen = await render(
401 <GitActionsControl
402 gitCwd={GIT_CWD}
403 activeThreadRef={scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID)}
404 />,
405 {
406 container: host,
407 },
408 );
409
410 try {
411 await Promise.resolve();
412
413 expect(setDraftThreadContextSpy).toHaveBeenCalledWith(
414 scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID),
415 {
416 branch: BRANCH_NAME,
417 worktreePath: null,
418 },
419 );
420 expect(setThreadBranchSpy).not.toHaveBeenCalled();
421 } finally {
422 await screen.unmount();
423 host.remove();
424 }
425 });
319});426});
apps/web/src/components/GitActionsControl.tsx+44-18
...@@ -49,6 +49,7 @@ import {...@@ -49,6 +49,7 @@ import {
49import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";49import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
50import { newCommandId, randomUUID } from "~/lib/utils";50import { newCommandId, randomUUID } from "~/lib/utils";
51import { resolvePathLinkTarget } from "~/terminal-links";51import { resolvePathLinkTarget } from "~/terminal-links";
52import { useComposerDraftStore } from "~/composerDraftStore";
52import { readEnvironmentApi } from "~/environmentApi";53import { readEnvironmentApi } from "~/environmentApi";
53import { readLocalApi } from "~/localApi";54import { readLocalApi } from "~/localApi";
54import { useStore } from "~/store";55import { useStore } from "~/store";
...@@ -209,17 +210,20 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {...@@ -209,17 +210,20 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
209}210}
210211
211export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) {212export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) {
212 const activeThreadId = activeThreadRef?.threadId ?? null;
213 const activeEnvironmentId = activeThreadRef?.environmentId ?? null;213 const activeEnvironmentId = activeThreadRef?.environmentId ?? null;
214 const threadToastData = useMemo(214 const threadToastData = useMemo(
215 () => (activeThreadId ? { threadId: activeThreadId } : undefined),215 () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined),
216 [activeThreadId],216 [activeThreadRef],
217 );217 );
218 const activeServerThreadSelector = useMemo(218 const activeServerThreadSelector = useMemo(
219 () => createThreadSelectorByRef(activeThreadRef),219 () => createThreadSelectorByRef(activeThreadRef),
220 [activeThreadRef],220 [activeThreadRef],
221 );221 );
222 const activeServerThread = useStore(activeServerThreadSelector);222 const activeServerThread = useStore(activeServerThreadSelector);
223 const activeDraftThread = useComposerDraftStore((store) =>
224 activeThreadRef ? store.getDraftThreadByRef(activeThreadRef) : null,
225 );
226 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
223 const setThreadBranch = useStore((store) => store.setThreadBranch);227 const setThreadBranch = useStore((store) => store.setThreadBranch);
224 const queryClient = useQueryClient();228 const queryClient = useQueryClient();
225 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);229 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
...@@ -247,27 +251,49 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction...@@ -247,27 +251,49 @@ export default function GitActionsControl({ gitCwd, activeThreadRef }: GitAction
247251
248 const persistThreadBranchSync = useCallback(252 const persistThreadBranchSync = useCallback(
249 (branch: string | null) => {253 (branch: string | null) => {
250 if (!activeThreadId || !activeServerThread || activeServerThread.branch === branch) {254 if (!activeThreadRef) {
255 return;
256 }
257
258 if (activeServerThread) {
259 if (activeServerThread.branch === branch) {
260 return;
261 }
262
263 const worktreePath = activeServerThread.worktreePath;
264 const api = readEnvironmentApi(activeThreadRef.environmentId);
265 if (api) {
266 void api.orchestration
267 .dispatchCommand({
268 type: "thread.meta.update",
269 commandId: newCommandId(),
270 threadId: activeThreadRef.threadId,
271 branch,
272 worktreePath,
273 })
274 .catch(() => undefined);
275 }
276
277 setThreadBranch(activeThreadRef, branch, worktreePath);
251 return;278 return;
252 }279 }
253280
254 const worktreePath = activeServerThread.worktreePath;281 if (!activeDraftThread || activeDraftThread.branch === branch) {
255 const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined;282 return;
256 if (api) {
257 void api.orchestration
258 .dispatchCommand({
259 type: "thread.meta.update",
260 commandId: newCommandId(),
261 threadId: activeThreadId,
262 branch,
263 worktreePath,
264 })
265 .catch(() => undefined);
266 }283 }
267284
268 setThreadBranch(activeThreadId, branch, worktreePath);285 setDraftThreadContext(activeThreadRef, {
286 branch,
287 worktreePath: activeDraftThread.worktreePath,
288 });
269 },289 },
270 [activeEnvironmentId, activeServerThread, activeThreadId, setThreadBranch],290 [
291 activeDraftThread,
292 activeServerThread,
293 activeThreadRef,
294 setDraftThreadContext,
295 setThreadBranch,
296 ],
271 );297 );
272298
273 const syncThreadBranchAfterGitAction = useCallback(299 const syncThreadBranchAfterGitAction = useCallback(
apps/web/src/components/ui/toast.logic.test.ts+52-1
...@@ -1,5 +1,10 @@...@@ -1,5 +1,10 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";
1import { assert, describe, it } from "vitest";2import { assert, describe, it } from "vitest";
2import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";3import {
4 buildVisibleToastLayout,
5 shouldHideCollapsedToastContent,
6 shouldRenderThreadScopedToast,
7} from "./toast.logic";
38
4describe("shouldHideCollapsedToastContent", () => {9describe("shouldHideCollapsedToastContent", () => {
5 it("keeps a single visible toast readable", () => {10 it("keeps a single visible toast readable", () => {
...@@ -61,3 +66,49 @@ describe("buildVisibleToastLayout", () => {...@@ -61,3 +66,49 @@ describe("buildVisibleToastLayout", () => {
61 );66 );
62 });67 });
63});68});
69
70describe("shouldRenderThreadScopedToast", () => {
71 const activeThreadRef = {
72 environmentId: "environment-a",
73 threadId: "thread-1",
74 } as ScopedThreadRef;
75
76 it("renders a toast scoped to the active thread ref", () => {
77 assert.equal(
78 shouldRenderThreadScopedToast(
79 {
80 threadRef: activeThreadRef,
81 },
82 activeThreadRef,
83 ),
84 true,
85 );
86 });
87
88 it("hides a scoped toast when the environment differs", () => {
89 assert.equal(
90 shouldRenderThreadScopedToast(
91 {
92 threadRef: {
93 environmentId: "environment-b",
94 threadId: "thread-1",
95 } as ScopedThreadRef,
96 },
97 activeThreadRef,
98 ),
99 false,
100 );
101 });
102
103 it("keeps legacy thread-id scoped toasts working", () => {
104 assert.equal(
105 shouldRenderThreadScopedToast(
106 {
107 threadId: "thread-1" as never,
108 },
109 activeThreadRef,
110 ),
111 true,
112 );
113 });
114});
apps/web/src/components/ui/toast.logic.ts+27
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
2
1export function shouldHideCollapsedToastContent(3export function shouldHideCollapsedToastContent(
2 visibleToastIndex: number,4 visibleToastIndex: number,
3 visibleToastCount: number,5 visibleToastCount: number,
...@@ -44,3 +46,28 @@ export function buildVisibleToastLayout<TToast extends object>(...@@ -44,3 +46,28 @@ export function buildVisibleToastLayout<TToast extends object>(
44function normalizeToastHeight(height: number | null | undefined): number {46function normalizeToastHeight(height: number | null | undefined): number {
45 return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0;47 return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0;
46}48}
49
50export function shouldRenderThreadScopedToast(
51 data:
52 | {
53 threadRef?: ScopedThreadRef | null;
54 threadId?: ThreadId | null;
55 }
56 | undefined,
57 activeThreadRef: ScopedThreadRef | null,
58): boolean {
59 if (data?.threadRef) {
60 return (
61 activeThreadRef !== null &&
62 data.threadRef.environmentId === activeThreadRef.environmentId &&
63 data.threadRef.threadId === activeThreadRef.threadId
64 );
65 }
66
67 const toastThreadId = data?.threadId;
68 if (!toastThreadId) {
69 return true;
70 }
71
72 return activeThreadRef?.threadId === toastThreadId;
73}
apps/web/src/components/ui/toast.tsx+33-20
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1"use client";1"use client";
22
3import { Toast } from "@base-ui/react/toast";3import { Toast } from "@base-ui/react/toast";
4import { useEffect, type CSSProperties } from "react";4import { useEffect, useMemo, type CSSProperties } from "react";
5import { useParams } from "@tanstack/react-router";5import { useParams } from "@tanstack/react-router";
6import { ThreadId } from "@t3tools/contracts";6import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
7import {7import {
8 CheckIcon,8 CheckIcon,
9 CircleAlertIcon,9 CircleAlertIcon,
...@@ -16,10 +16,17 @@ import {...@@ -16,10 +16,17 @@ import {
1616
17import { cn } from "~/lib/utils";17import { cn } from "~/lib/utils";
18import { buttonVariants } from "~/components/ui/button";18import { buttonVariants } from "~/components/ui/button";
19import { useComposerDraftStore } from "~/composerDraftStore";
19import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";20import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
20import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";21import { resolveThreadRouteTarget } from "~/threadRoutes";
22import {
23 buildVisibleToastLayout,
24 shouldHideCollapsedToastContent,
25 shouldRenderThreadScopedToast,
26} from "./toast.logic";
2127
22export type ThreadToastData = {28export type ThreadToastData = {
29 threadRef?: ScopedThreadRef | null;
23 threadId?: ThreadId | null;30 threadId?: ThreadId | null;
24 tooltipStyle?: boolean;31 tooltipStyle?: boolean;
25 dismissAfterVisibleMs?: number;32 dismissAfterVisibleMs?: number;
...@@ -70,21 +77,27 @@ interface ToastProviderProps extends Toast.Provider.Props {...@@ -70,21 +77,27 @@ interface ToastProviderProps extends Toast.Provider.Props {
70 position?: ToastPosition;77 position?: ToastPosition;
71}78}
7279
73function shouldRenderForActiveThread(80function useActiveThreadRefFromRoute(): ScopedThreadRef | null {
74 data: ThreadToastData | undefined,81 const routeTarget = useParams({
75 activeThreadId: ThreadId | null,
76): boolean {
77 const toastThreadId = data?.threadId;
78 if (!toastThreadId) return true;
79 return toastThreadId === activeThreadId;
80}
81
82function useActiveThreadIdFromRoute(): ThreadId | null {
83 return useParams({
84 strict: false,82 strict: false,
85 select: (params) =>83 select: (params) => resolveThreadRouteTarget(params),
86 typeof params.threadId === "string" ? ThreadId.makeUnsafe(params.threadId) : null,
87 });84 });
85 const activeDraftSession = useComposerDraftStore((store) =>
86 routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
87 );
88
89 return useMemo(() => {
90 if (routeTarget?.kind === "server") {
91 return routeTarget.threadRef;
92 }
93 if (routeTarget?.kind === "draft" && activeDraftSession) {
94 return {
95 environmentId: activeDraftSession.environmentId,
96 threadId: activeDraftSession.threadId,
97 };
98 }
99 return null;
100 }, [activeDraftSession, routeTarget]);
88}101}
89102
90function ThreadToastVisibleAutoDismiss({103function ThreadToastVisibleAutoDismiss({
...@@ -176,10 +189,10 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv...@@ -176,10 +189,10 @@ function ToastProvider({ children, position = "top-right", ...props }: ToastProv
176189
177function Toasts({ position = "top-right" }: { position: ToastPosition }) {190function Toasts({ position = "top-right" }: { position: ToastPosition }) {
178 const { toasts } = Toast.useToastManager<ThreadToastData>();191 const { toasts } = Toast.useToastManager<ThreadToastData>();
179 const activeThreadId = useActiveThreadIdFromRoute();192 const activeThreadRef = useActiveThreadRefFromRoute();
180 const isTop = position.startsWith("top");193 const isTop = position.startsWith("top");
181 const visibleToasts = toasts.filter((toast) =>194 const visibleToasts = toasts.filter((toast) =>
182 shouldRenderForActiveThread(toast.data, activeThreadId),195 shouldRenderThreadScopedToast(toast.data, activeThreadRef),
183 );196 );
184 const visibleToastLayout = buildVisibleToastLayout(visibleToasts);197 const visibleToastLayout = buildVisibleToastLayout(visibleToasts);
185198
...@@ -350,13 +363,13 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) {...@@ -350,13 +363,13 @@ function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) {
350363
351function AnchoredToasts() {364function AnchoredToasts() {
352 const { toasts } = Toast.useToastManager<ThreadToastData>();365 const { toasts } = Toast.useToastManager<ThreadToastData>();
353 const activeThreadId = useActiveThreadIdFromRoute();366 const activeThreadRef = useActiveThreadRefFromRoute();
354367
355 return (368 return (
356 <Toast.Portal data-slot="toast-portal-anchored">369 <Toast.Portal data-slot="toast-portal-anchored">
357 <Toast.Viewport className="outline-none" data-slot="toast-viewport-anchored">370 <Toast.Viewport className="outline-none" data-slot="toast-viewport-anchored">
358 {toasts371 {toasts
359 .filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId))372 .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef))
360 .map((toast) => {373 .map((toast) => {
361 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;374 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
362 const tooltipStyle = toast.data?.tooltipStyle ?? false;375 const tooltipStyle = toast.data?.tooltipStyle ?? false;
apps/web/src/store.test.ts+51-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { scopeThreadRef } from "@t3tools/client-runtime";
1import {2import {
2 CheckpointRef,3 CheckpointRef,
3 DEFAULT_MODEL_BY_PROVIDER,4 DEFAULT_MODEL_BY_PROVIDER,
...@@ -17,6 +18,7 @@ import {...@@ -17,6 +18,7 @@ import {
17 applyOrchestrationEvents,18 applyOrchestrationEvents,
18 selectEnvironmentState,19 selectEnvironmentState,
19 selectProjectsAcrossEnvironments,20 selectProjectsAcrossEnvironments,
21 setThreadBranch,
20 selectThreadsAcrossEnvironments,22 selectThreadsAcrossEnvironments,
21 syncServerReadModel,23 syncServerReadModel,
22 type AppState,24 type AppState,
...@@ -25,6 +27,7 @@ import {...@@ -25,6 +27,7 @@ import {
25import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";27import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types";
2628
27const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");29const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local");
30const remoteEnvironmentId = EnvironmentId.makeUnsafe("environment-remote");
2831
29function withActiveEnvironmentState(32function withActiveEnvironmentState(
30 environmentState: EnvironmentState,33 environmentState: EnvironmentState,
...@@ -86,7 +89,7 @@ function makeState(thread: Thread): AppState {...@@ -86,7 +89,7 @@ function makeState(thread: Thread): AppState {
86 const projectId = ProjectId.makeUnsafe("project-1");89 const projectId = ProjectId.makeUnsafe("project-1");
87 const project = {90 const project = {
88 id: projectId,91 id: projectId,
89 environmentId: localEnvironmentId,92 environmentId: thread.environmentId,
90 name: "Project",93 name: "Project",
91 cwd: "/tmp/project",94 cwd: "/tmp/project",
92 defaultModelSelection: {95 defaultModelSelection: {
...@@ -171,7 +174,9 @@ function makeState(thread: Thread): AppState {...@@ -171,7 +174,9 @@ function makeState(thread: Thread): AppState {
171 sidebarThreadSummaryById: {},174 sidebarThreadSummaryById: {},
172 bootstrapComplete: true,175 bootstrapComplete: true,
173 };176 };
174 return withActiveEnvironmentState(environmentState);177 return withActiveEnvironmentState(environmentState, {
178 activeEnvironmentId: thread.environmentId,
179 });
175}180}
176181
177function makeEmptyState(overrides: Partial<AppState & EnvironmentState> = {}): AppState {182function makeEmptyState(overrides: Partial<AppState & EnvironmentState> = {}): AppState {
...@@ -201,6 +206,10 @@ function localEnvironmentStateOf(state: AppState): EnvironmentState {...@@ -201,6 +206,10 @@ function localEnvironmentStateOf(state: AppState): EnvironmentState {
201 return selectEnvironmentState(state, localEnvironmentId);206 return selectEnvironmentState(state, localEnvironmentId);
202}207}
203208
209function environmentStateOf(state: AppState, environmentId: EnvironmentId): EnvironmentState {
210 return selectEnvironmentState(state, environmentId);
211}
212
204function projectsOf(state: AppState) {213function projectsOf(state: AppState) {
205 return selectProjectsAcrossEnvironments(state);214 return selectProjectsAcrossEnvironments(state);
206}215}
...@@ -305,6 +314,46 @@ function makeReadModelProject(...@@ -305,6 +314,46 @@ function makeReadModelProject(
305 };314 };
306}315}
307316
317describe("setThreadBranch", () => {
318 it("updates only the scoped thread environment", () => {
319 const sharedThreadId = ThreadId.makeUnsafe("thread-shared");
320 const localThread = makeThread({
321 id: sharedThreadId,
322 environmentId: localEnvironmentId,
323 branch: "local-branch",
324 });
325 const remoteThread = makeThread({
326 id: sharedThreadId,
327 environmentId: remoteEnvironmentId,
328 branch: "remote-branch",
329 });
330 const state: AppState = {
331 activeEnvironmentId: localEnvironmentId,
332 environmentStateById: {
333 [localEnvironmentId]: environmentStateOf(makeState(localThread), localEnvironmentId),
334 [remoteEnvironmentId]: environmentStateOf(makeState(remoteThread), remoteEnvironmentId),
335 },
336 };
337
338 const next = setThreadBranch(
339 state,
340 scopeThreadRef(remoteEnvironmentId, sharedThreadId),
341 "remote-next",
342 "/tmp/remote-worktree",
343 );
344
345 expect(
346 environmentStateOf(next, localEnvironmentId).threadShellById[sharedThreadId]?.branch,
347 ).toBe("local-branch");
348 expect(
349 environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.branch,
350 ).toBe("remote-next");
351 expect(
352 environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.worktreePath,
353 ).toBe("/tmp/remote-worktree");
354 });
355});
356
308describe("store read model sync", () => {357describe("store read model sync", () => {
309 it("marks bootstrap complete after snapshot sync", () => {358 it("marks bootstrap complete after snapshot sync", () => {
310 const initialState = withActiveEnvironmentState(359 const initialState = withActiveEnvironmentState(
apps/web/src/store.ts+11-11
...@@ -1737,17 +1737,13 @@ export function setActiveEnvironmentId(state: AppState, environmentId: Environme...@@ -1737,17 +1737,13 @@ export function setActiveEnvironmentId(state: AppState, environmentId: Environme
17371737
1738export function setThreadBranch(1738export function setThreadBranch(
1739 state: AppState,1739 state: AppState,
1740 threadId: ThreadId,1740 threadRef: ScopedThreadRef,
1741 branch: string | null,1741 branch: string | null,
1742 worktreePath: string | null,1742 worktreePath: string | null,
1743): AppState {1743): AppState {
1744 if (state.activeEnvironmentId === null) {
1745 return state;
1746 }
1747
1748 const nextEnvironmentState = updateThreadState(1744 const nextEnvironmentState = updateThreadState(
1749 getStoredEnvironmentState(state, state.activeEnvironmentId),1745 getStoredEnvironmentState(state, threadRef.environmentId),
1750 threadId,1746 threadRef.threadId,
1751 (thread) => {1747 (thread) => {
1752 if (thread.branch === branch && thread.worktreePath === worktreePath) return thread;1748 if (thread.branch === branch && thread.worktreePath === worktreePath) return thread;
1753 const cwdChanged = thread.worktreePath !== worktreePath;1749 const cwdChanged = thread.worktreePath !== worktreePath;
...@@ -1759,7 +1755,7 @@ export function setThreadBranch(...@@ -1759,7 +1755,7 @@ export function setThreadBranch(
1759 };1755 };
1760 },1756 },
1761 );1757 );
1762 return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState);1758 return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState);
1763}1759}
17641760
1765interface AppStore extends AppState {1761interface AppStore extends AppState {
...@@ -1771,7 +1767,11 @@ interface AppStore extends AppState {...@@ -1771,7 +1767,11 @@ interface AppStore extends AppState {
1771 environmentId: EnvironmentId,1767 environmentId: EnvironmentId,
1772 ) => void;1768 ) => void;
1773 setError: (threadId: ThreadId, error: string | null) => void;1769 setError: (threadId: ThreadId, error: string | null) => void;
1774 setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void;1770 setThreadBranch: (
1771 threadRef: ScopedThreadRef,
1772 branch: string | null,
1773 worktreePath: string | null,
1774 ) => void;
1775}1775}
17761776
1777export const useStore = create<AppStore>((set) => ({1777export const useStore = create<AppStore>((set) => ({
...@@ -1785,6 +1785,6 @@ export const useStore = create<AppStore>((set) => ({...@@ -1785,6 +1785,6 @@ export const useStore = create<AppStore>((set) => ({
1785 applyOrchestrationEvents: (events, environmentId) =>1785 applyOrchestrationEvents: (events, environmentId) =>
1786 set((state) => applyOrchestrationEvents(state, events, environmentId)),1786 set((state) => applyOrchestrationEvents(state, events, environmentId)),
1787 setError: (threadId, error) => set((state) => setError(state, threadId, error)),1787 setError: (threadId, error) => set((state) => setError(state, threadId, error)),
1788 setThreadBranch: (threadId, branch, worktreePath) =>1788 setThreadBranch: (threadRef, branch, worktreePath) =>
1789 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),1789 set((state) => setThreadBranch(state, threadRef, branch, worktreePath)),
1790}));1790}));