From c82d9cefdf87c99778c54c45031819a1dae2687c Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 18 Aug 2026 15:56:59 -0500 Subject: [PATCH 1/2] fix(web): prevent message sync pill layout flicker - Delay brief sync indicators and anchor the pill above the composer - Reset the pill when switching threads --- apps/web/src/components/ChatView.tsx | 2 +- .../chat/ThreadSyncStatusPill.test.tsx | 17 +++++--- .../components/chat/ThreadSyncStatusPill.tsx | 39 +++++++++++++++---- apps/web/src/threadSync.test.ts | 35 ++++++++++++++++- apps/web/src/threadSync.ts | 7 ++++ 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 83672d659da4..e70588fe4f30 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6703,7 +6703,7 @@ function ChatViewContent(props: ChatViewProps) { )} {threadSyncPhase && !activeEnvironmentUnavailable ? ( - + ) : null}
{ - it.each([ - ["loading", "Loading messages..."], - ["syncing", "Syncing messages..."], - ] as const)("renders the %s message sync phase", (phase, label) => { - const markup = renderToStaticMarkup(); + it("renders loading immediately without participating in composer layout", () => { + const markup = renderToStaticMarkup(); expect(markup).toContain('role="status"'); - expect(markup).toContain(label); + expect(markup).toContain("Loading messages..."); + expect(markup).toContain("absolute"); + expect(markup).toContain("bottom-full"); expect(markup).not.toContain("animate-"); }); + + it("withholds the cached-thread syncing phase initially", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toBe(""); + }); }); diff --git a/apps/web/src/components/chat/ThreadSyncStatusPill.tsx b/apps/web/src/components/chat/ThreadSyncStatusPill.tsx index d920a6d1953d..5fd2021c8438 100644 --- a/apps/web/src/components/chat/ThreadSyncStatusPill.tsx +++ b/apps/web/src/components/chat/ThreadSyncStatusPill.tsx @@ -1,18 +1,41 @@ import { LoaderCircleIcon } from "lucide-react"; +import { useEffect, useState } from "react"; -import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync"; +import { + scheduleThreadSyncStatusReveal, + threadSyncLabel, + type ThreadSyncPhase, +} from "../../threadSync"; export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhase }) { + const [revealedPhase, setRevealedPhase] = useState(() => + phase === "loading" ? phase : null, + ); + + useEffect(() => { + if (phase === "loading") { + setRevealedPhase("loading"); + return; + } + return scheduleThreadSyncStatusReveal(() => setRevealedPhase("syncing")); + }, [phase]); + + if (phase !== "loading" && revealedPhase !== phase) { + return null; + } + const label = threadSyncLabel(phase); return ( -
- - {label} +
+
+ + {label} +
); } diff --git a/apps/web/src/threadSync.test.ts b/apps/web/src/threadSync.test.ts index ba91e33078f7..b478b96d0cf5 100644 --- a/apps/web/src/threadSync.test.ts +++ b/apps/web/src/threadSync.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { resolveThreadSyncPhase, threadSyncLabel } from "./threadSync"; +import { + resolveThreadSyncPhase, + scheduleThreadSyncStatusReveal, + THREAD_SYNC_STATUS_REVEAL_DELAY_MS, + threadSyncLabel, +} from "./threadSync"; describe("resolveThreadSyncPhase", () => { it("loads when only shell data is available", () => { @@ -47,3 +52,29 @@ describe("threadSyncLabel", () => { expect(threadSyncLabel("syncing")).toBe("Syncing messages..."); }); }); + +describe("scheduleThreadSyncStatusReveal", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("withholds brief foreground syncs", () => { + const reveal = vi.fn(); + scheduleThreadSyncStatusReveal(reveal); + + vi.advanceTimersByTime(THREAD_SYNC_STATUS_REVEAL_DELAY_MS - 1); + expect(reveal).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(reveal).toHaveBeenCalledOnce(); + }); + + it("cancels the reveal when synchronization finishes first", () => { + const reveal = vi.fn(); + const cancel = scheduleThreadSyncStatusReveal(reveal); + + cancel(); + vi.advanceTimersByTime(THREAD_SYNC_STATUS_REVEAL_DELAY_MS); + + expect(reveal).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/threadSync.ts b/apps/web/src/threadSync.ts index a8b5446add2c..64529d166424 100644 --- a/apps/web/src/threadSync.ts +++ b/apps/web/src/threadSync.ts @@ -2,6 +2,8 @@ import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/thre export type ThreadSyncPhase = "loading" | "syncing"; +export const THREAD_SYNC_STATUS_REVEAL_DELAY_MS = 300; + export function resolveThreadSyncPhase(input: { readonly detailExists: boolean; readonly shellExists: boolean; @@ -25,3 +27,8 @@ export function resolveThreadSyncPhase(input: { export function threadSyncLabel(phase: ThreadSyncPhase): string { return phase === "loading" ? "Loading messages..." : "Syncing messages..."; } + +export function scheduleThreadSyncStatusReveal(reveal: () => void): () => void { + const timeoutId = globalThis.setTimeout(reveal, THREAD_SYNC_STATUS_REVEAL_DELAY_MS); + return () => globalThis.clearTimeout(timeoutId); +} From 4bb8670d5cbb2936fa8f78bbe9f2194761c31002 Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Tue, 18 Aug 2026 16:44:21 -0500 Subject: [PATCH 2/2] fix(web): address message sync status edge cases --- apps/web/src/components/ChatView.tsx | 6 ++- .../chat/ThreadSyncStatusPill.test.tsx | 12 +++-- .../components/chat/ThreadSyncStatusPill.tsx | 47 +++++++++---------- apps/web/src/threadSync.test.ts | 35 +------------- apps/web/src/threadSync.ts | 7 --- 5 files changed, 37 insertions(+), 70 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e70588fe4f30..ca7ce3a6b3f8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6703,7 +6703,11 @@ function ChatViewContent(props: ChatViewProps) { )} {threadSyncPhase && !activeEnvironmentUnavailable ? ( - + ) : null}
{ it("renders loading immediately without participating in composer layout", () => { - const markup = renderToStaticMarkup(); + const markup = renderToStaticMarkup(); expect(markup).toContain('role="status"'); expect(markup).toContain("Loading messages..."); expect(markup).toContain("absolute"); - expect(markup).toContain("bottom-full"); + expect(markup).toContain("calc(100% + 0.5rem)"); expect(markup).not.toContain("animate-"); }); it("withholds the cached-thread syncing phase initially", () => { - const markup = renderToStaticMarkup(); + const markup = renderToStaticMarkup(); expect(markup).toBe(""); }); + + it("moves above the scroll-to-end control when it is visible", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("calc(100% + 2.75rem)"); + }); }); diff --git a/apps/web/src/components/chat/ThreadSyncStatusPill.tsx b/apps/web/src/components/chat/ThreadSyncStatusPill.tsx index 5fd2021c8438..02bbee8d191f 100644 --- a/apps/web/src/components/chat/ThreadSyncStatusPill.tsx +++ b/apps/web/src/components/chat/ThreadSyncStatusPill.tsx @@ -1,41 +1,36 @@ import { LoaderCircleIcon } from "lucide-react"; import { useEffect, useState } from "react"; -import { - scheduleThreadSyncStatusReveal, - threadSyncLabel, - type ThreadSyncPhase, -} from "../../threadSync"; +import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync"; -export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhase }) { - const [revealedPhase, setRevealedPhase] = useState(() => - phase === "loading" ? phase : null, - ); +export function ThreadSyncStatusPill({ + phase, + raised, +}: { + readonly phase: ThreadSyncPhase; + readonly raised: boolean; +}) { + const [syncingVisible, setSyncingVisible] = useState(phase === "loading"); useEffect(() => { - if (phase === "loading") { - setRevealedPhase("loading"); - return; - } - return scheduleThreadSyncStatusReveal(() => setRevealedPhase("syncing")); + if (phase !== "syncing") return; + const timeoutId = globalThis.setTimeout(() => setSyncingVisible(true), 300); + return () => globalThis.clearTimeout(timeoutId); }, [phase]); - if (phase !== "loading" && revealedPhase !== phase) { - return null; - } + if (phase === "syncing" && !syncingVisible) return null; const label = threadSyncLabel(phase); return ( -
-
- - {label} -
+
+ + {label}
); } diff --git a/apps/web/src/threadSync.test.ts b/apps/web/src/threadSync.test.ts index b478b96d0cf5..ba91e33078f7 100644 --- a/apps/web/src/threadSync.test.ts +++ b/apps/web/src/threadSync.test.ts @@ -1,11 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; -import { - resolveThreadSyncPhase, - scheduleThreadSyncStatusReveal, - THREAD_SYNC_STATUS_REVEAL_DELAY_MS, - threadSyncLabel, -} from "./threadSync"; +import { resolveThreadSyncPhase, threadSyncLabel } from "./threadSync"; describe("resolveThreadSyncPhase", () => { it("loads when only shell data is available", () => { @@ -52,29 +47,3 @@ describe("threadSyncLabel", () => { expect(threadSyncLabel("syncing")).toBe("Syncing messages..."); }); }); - -describe("scheduleThreadSyncStatusReveal", () => { - beforeEach(() => vi.useFakeTimers()); - afterEach(() => vi.useRealTimers()); - - it("withholds brief foreground syncs", () => { - const reveal = vi.fn(); - scheduleThreadSyncStatusReveal(reveal); - - vi.advanceTimersByTime(THREAD_SYNC_STATUS_REVEAL_DELAY_MS - 1); - expect(reveal).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(1); - expect(reveal).toHaveBeenCalledOnce(); - }); - - it("cancels the reveal when synchronization finishes first", () => { - const reveal = vi.fn(); - const cancel = scheduleThreadSyncStatusReveal(reveal); - - cancel(); - vi.advanceTimersByTime(THREAD_SYNC_STATUS_REVEAL_DELAY_MS); - - expect(reveal).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/threadSync.ts b/apps/web/src/threadSync.ts index 64529d166424..a8b5446add2c 100644 --- a/apps/web/src/threadSync.ts +++ b/apps/web/src/threadSync.ts @@ -2,8 +2,6 @@ import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/thre export type ThreadSyncPhase = "loading" | "syncing"; -export const THREAD_SYNC_STATUS_REVEAL_DELAY_MS = 300; - export function resolveThreadSyncPhase(input: { readonly detailExists: boolean; readonly shellExists: boolean; @@ -27,8 +25,3 @@ export function resolveThreadSyncPhase(input: { export function threadSyncLabel(phase: ThreadSyncPhase): string { return phase === "loading" ? "Loading messages..." : "Syncing messages..."; } - -export function scheduleThreadSyncStatusReveal(reveal: () => void): () => void { - const timeoutId = globalThis.setTimeout(reveal, THREAD_SYNC_STATUS_REVEAL_DELAY_MS); - return () => globalThis.clearTimeout(timeoutId); -}