diff --git a/apps/desktop/src/entities/session/session-projections-store.test.tsx b/apps/desktop/src/entities/session/session-projections-store.test.tsx index 1b672c3b..82c03da2 100644 --- a/apps/desktop/src/entities/session/session-projections-store.test.tsx +++ b/apps/desktop/src/entities/session/session-projections-store.test.tsx @@ -1,4 +1,5 @@ import { act, render } from "@testing-library/react"; +import { Profiler } from "react"; import { describe, expect, it } from "vitest"; import type { AgentRuntimeEventEntry, @@ -13,11 +14,15 @@ import { createSessionProjection, type SessionProjection, } from "@/entities/session/session-projection"; -import { createSessionProjectionsStore } from "@/entities/session/session-projections-store"; +import { + createSessionProjectionsStore, + type SessionProjectionsStore, +} from "@/entities/session/session-projections-store"; import { SessionProjectionsProvider, useLiveSession, useSessionProjections, + useViewedSession, } from "@/entities/session/use-session-projections"; type Listener = (...args: T) => void; @@ -553,3 +558,140 @@ describe("Session Projections store", () => { }); }); }); + +function assistantMessage(id: string, piSessionId: string, seq: number): PiRuntimeEvent { + return { + id, + piSessionId, + kind: "message", + role: "assistant", + body: `Reply ${seq}`, + timestamp: `2026-09-24T10:00:0${seq}.000Z`, + }; +} + +describe("viewed Sessions", () => { + it("reads results as they land on a viewed Session and keeps them unread once it is released", () => { + const fake = createFakeBridge(); + const store = createStore(fake); + store.insert(createSessionProjection({ + id: "viewed", + projectId: "pig", + initialPrompt: "Prompt", + createdAt: "2026-09-24T09:00:00.000Z", + })); + store.apply("viewed", { + type: "runtime-bound", + stage: "starting runtime", + runtimeId: "runtime:viewed", + piSessionId: "pi:viewed", + occurredAt: "2026-09-24T09:00:01.000Z", + }); + + const notifies: string[] = []; + store.subscribe(() => notifies.push("notify")); + const release = store.view("viewed"); + + fake.emitLegacy(assistantMessage("m1", "pi:viewed", 1)); + + // The unread write lands inside the event's own commit, not after it. + expect(store.get("viewed")?.unreadResult).toBe(false); + expect(notifies).toHaveLength(1); + + release(); + fake.emitLegacy(assistantMessage("m2", "pi:viewed", 2)); + + expect(store.get("viewed")?.unreadResult).toBe(true); + expect(notifies).toHaveLength(2); + }); + + it("viewing an unread Session reads it once", () => { + const fake = createFakeBridge(); + const store = createStore(fake); + store.insert({ ...boundSession("unread"), unreadResult: true }); + + const notifies: string[] = []; + store.subscribe(() => notifies.push("notify")); + store.view("unread"); + + expect(store.get("unread")?.unreadResult).toBe(false); + expect(notifies).toHaveLength(1); + }); + + it("reads a Session that arrives after its view started", () => { + const store = createStore(); + const release = store.view("late"); + + // Insert bypasses apply(): the read marker has to come from commit itself. + store.insert({ ...boundSession("late"), unreadResult: true }); + + expect(store.get("late")?.unreadResult).toBe(false); + release(); + }); + + it("reads a viewed Session that rehydrate brings in unread", async () => { + const fake = createFakeBridge(); + const store = createStore(fake, async () => [ + coldSession("hydrated", { unreadResult: true }), + ]); + const release = store.view("hydrated"); + + await store.rehydrate(); + + expect(store.get("hydrated")?.unreadResult).toBe(false); + release(); + }); + + it("commits once per assistant event while the Session is on screen", () => { + const fake = createFakeBridge(); + let store!: SessionProjectionsStore; + let commits = 0; + + function LiveView() { + const { projection } = useLiveSession("onscreen"); + useViewedSession("onscreen"); + return {String(projection?.unreadResult)}; + } + function StoreProbe() { + store = useSessionProjections().store; + return null; + } + + render( + + + { commits += 1; }}> + + + , + ); + + act(() => { + store.insert(createSessionProjection({ + id: "onscreen", + projectId: "pig", + initialPrompt: "Prompt", + createdAt: "2026-09-24T09:00:00.000Z", + })); + store.apply("onscreen", { + type: "runtime-bound", + stage: "starting runtime", + runtimeId: "runtime:onscreen", + piSessionId: "pi:onscreen", + occurredAt: "2026-09-24T09:00:01.000Z", + }); + }); + commits = 0; + + for (let seq = 1; seq <= 5; seq += 1) { + act(() => { + fake.emitLegacy(assistantMessage(`m${seq}`, "pi:onscreen", seq)); + }); + } + + // One commit per event: the old pattern added a second commit per event + // from a passive effect writing "read" back after each render. + expect(commits).toBe(5); + expect(store.get("onscreen")?.unreadResult).toBe(false); + }); +}); diff --git a/apps/desktop/src/entities/session/session-projections-store.ts b/apps/desktop/src/entities/session/session-projections-store.ts index 8335d6c7..d774a0f3 100644 --- a/apps/desktop/src/entities/session/session-projections-store.ts +++ b/apps/desktop/src/entities/session/session-projections-store.ts @@ -34,6 +34,12 @@ export type SessionProjectionsStore = { remove(sessionId: string): void; rehydrate(): Promise; subscribe(listener: () => void): () => void; + /** + * Marks a Session as on screen until the returned release runs. Its results + * are read as they land, in the same store commit, so a view never writes + * "read" back after rendering (which cost one extra commit per event). + */ + view(sessionId: string): () => void; }; // A projection this process has advanced past its persisted record. Replacing @@ -63,16 +69,30 @@ export function createSessionProjectionsStore(options: { let projections: SessionProjection[] = []; const listeners = new Set<() => void>(); const subscriptions = new Map void }>(); + // Sessions currently on screen, ref-counted per `view()` caller. Their + // results are already consumed by definition, so `seen` folds the read + // marker into the same projection write instead of a second apply later. + const viewed = new Map(); const history = new Map< string, { key: string; piSessionId: string; state: Exclude } >(); + const seen = (projection: SessionProjection) => + viewed.has(projection.id) && projection.unreadResult + ? applySessionProjectionEvent(projection, { + type: "latest-message-rendered", + occurredAt: new Date().toISOString(), + }) + : projection; + const notify = () => { for (const listener of listeners) listener(); }; const commit = (next: SessionProjection[]) => { - projections = next; + // Every write passes here, so a viewed Session is read however its + // projection arrives (event, history read, insert, rehydrate). + projections = viewed.size > 0 ? next.map(seen) : next; notify(); }; const get = (sessionId: string) => @@ -119,7 +139,7 @@ export function createSessionProjectionsStore(options: { // The reducer throws on events it cannot place (an unknown queued id); // that propagates before anything is stored. - const next = applySessionProjectionEvent(current, event); + const next = seen(applySessionProjectionEvent(current, event)); replace(next); if (event.type === "runtime-bound") { @@ -247,5 +267,30 @@ export function createSessionProjectionsStore(options: { listeners.delete(listener); }; }, + view(sessionId) { + viewed.set(sessionId, (viewed.get(sessionId) ?? 0) + 1); + // An already-unread Session is caught up now: one notify, only on view + // start — nothing writes "read" back while events land afterwards. + const current = get(sessionId); + if (current) { + const next = seen(current); + if (next !== current) { + replace(next); + } + } + let released = false; + return () => { + if (released) { + return; + } + released = true; + const count = (viewed.get(sessionId) ?? 0) - 1; + if (count <= 0) { + viewed.delete(sessionId); + } else { + viewed.set(sessionId, count); + } + }; + }, }; } diff --git a/apps/desktop/src/entities/session/use-session-projections.tsx b/apps/desktop/src/entities/session/use-session-projections.tsx index d8e53918..f2a4b711 100644 --- a/apps/desktop/src/entities/session/use-session-projections.tsx +++ b/apps/desktop/src/entities/session/use-session-projections.tsx @@ -304,3 +304,18 @@ export function useLiveSession(sessionId: string | null) { return { projection, history, apply, retryHistory }; } + +/** Keeps a Session read while it is on screen (SessionProjectionsStore.view). */ +export function useViewedSession(sessionId: string | null) { + const context = useContext(SessionProjectionsStoreContext); + + if (!context) { + throw new Error("useViewedSession requires SessionProjectionsProvider"); + } + + const { store } = context; + useEffect( + () => (sessionId ? store.view(sessionId) : undefined), + [sessionId, store], + ); +} diff --git a/apps/desktop/src/shared/ui/astryx-text-ref.test.tsx b/apps/desktop/src/shared/ui/astryx-text-ref.test.tsx new file mode 100644 index 00000000..6bd59494 --- /dev/null +++ b/apps/desktop/src/shared/ui/astryx-text-ref.test.tsx @@ -0,0 +1,54 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { Heading, Text } from "@astryxdesign/core/Text"; + +/** + * Guard for the @astryxdesign/core patch: Text/Heading merge their forwarded + * ref, useTruncation's callback ref, and an internal object ref into one + * callback built fresh on every render. A new callback identity makes React + * detach+reattach the ref on every commit — and useTruncation's attach calls + * setState inside the commit, which is what let nested updates accumulate + * into React #185 under dense Session events. + */ +describe("Astryx ref stability", () => { + it("Text keeps the forwarded callback ref attached across re-renders", () => { + const spy = vi.fn(); + function Parent({ label }: { label: string }) { + return {label}; + } + + const { rerender } = render(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith(expect.any(HTMLElement)); + + rerender(); + rerender(); + rerender(); + + // No detach (spy(null)) and no re-attach: identity must be stable. + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).not.toHaveBeenCalledWith(null); + }); + + it("Heading keeps the forwarded callback ref attached across re-renders", () => { + const spy = vi.fn(); + function Parent({ label }: { label: string }) { + return ( + + {label} + + ); + } + + const { rerender } = render(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith(expect.any(HTMLElement)); + + rerender(); + rerender(); + rerender(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).not.toHaveBeenCalledWith(null); + }); +}); diff --git a/apps/desktop/src/shared/ui/chat/chat-inline-pager.test.tsx b/apps/desktop/src/shared/ui/chat/chat-inline-pager.test.tsx index c9a2ea82..61415696 100644 --- a/apps/desktop/src/shared/ui/chat/chat-inline-pager.test.tsx +++ b/apps/desktop/src/shared/ui/chat/chat-inline-pager.test.tsx @@ -1,3 +1,4 @@ +import { Profiler } from "react"; import { act, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ChatInlinePager } from "@/shared/ui/chat/chat-inline-pager"; @@ -104,6 +105,30 @@ describe("ChatInlinePager", () => { expect(screen.getByText("Running bash script…")).toBeInTheDocument(); }); + // A parent re-render with the same page key used to cost a second commit + // from the pager's passive effect — under dense Session events that extra + // commit per event is what let React's nested-update counter overflow (React error #185). + it("lands same-page content without an extra commit", () => { + let commits = 0; + const { rerender } = render( + { commits += 1; }}> + Running bash… + , + ); + + commits = 0; + for (const label of ["Running bash script…", "Running bash -lc…", "Running test…"]) { + rerender( + { commits += 1; }}> + {label} + , + ); + } + + expect(commits).toBe(3); + expect(screen.getByText("Running test…")).toBeInTheDocument(); + }); + it("swaps pages without motion when reduced motion is preferred", () => { vi.useFakeTimers(); useReducedMotion(); diff --git a/apps/desktop/src/shared/ui/chat/chat-inline-pager.tsx b/apps/desktop/src/shared/ui/chat/chat-inline-pager.tsx index 9147377b..4f5a28f1 100644 --- a/apps/desktop/src/shared/ui/chat/chat-inline-pager.tsx +++ b/apps/desktop/src/shared/ui/chat/chat-inline-pager.tsx @@ -53,10 +53,10 @@ export function ChatInlinePager({ latestRef.current = { key: pageKey, node: children }; // Same page, new content (streaming args, a ticking duration): land it in - // place rather than turning the page on itself. + // place rather than turning the page on itself. Rendered straight from + // children below, so a parent re-render costs no extra commit. if (pageKey === shownRef.current.key) { shownRef.current = latestRef.current; - setShown(latestRef.current); return; } @@ -122,7 +122,7 @@ export function ChatInlinePager({ } }} > - {shown.node} + {pageKey === shown.key ? children : shown.node} diff --git a/apps/desktop/src/widgets/app-frame/app-frame.tsx b/apps/desktop/src/widgets/app-frame/app-frame.tsx index 2d79a2b4..4c00fa20 100644 --- a/apps/desktop/src/widgets/app-frame/app-frame.tsx +++ b/apps/desktop/src/widgets/app-frame/app-frame.tsx @@ -1245,30 +1245,31 @@ export function AppFrame({ }); }; - useEffect(() => { - if (!effectiveSelectedSessionId) { - return; - } - - const selectedProjection = effectiveSessionProjections.find( - (projection) => projection.id === effectiveSelectedSessionId, - ); + // The whole projections array gets a new identity on every store commit — + // keying the effect on it cost one pending update per Session event (React error #185). + // Only the selected Session's project can change what this effect does. + const selectedProjectId = effectiveSelectedSessionId + ? effectiveSessionProjections.find( + (projection) => projection.id === effectiveSelectedSessionId, + )?.projectId + : undefined; - if (!selectedProjection) { + useEffect(() => { + if (!selectedProjectId) { return; } updateExpandedProjects((currentExpandedProjects) => { - if (currentExpandedProjects[selectedProjection.projectId] === true) { + if (currentExpandedProjects[selectedProjectId] === true) { return currentExpandedProjects; } return { ...currentExpandedProjects, - [selectedProjection.projectId]: true, + [selectedProjectId]: true, }; }); - }, [effectiveSelectedSessionId, effectiveSessionProjections]); + }, [selectedProjectId]); useLayoutEffect(() => { const root = layoutRef.current; diff --git a/apps/desktop/src/widgets/live-chat/live-session-column.tsx b/apps/desktop/src/widgets/live-chat/live-session-column.tsx index 00e2257a..8f8b0f75 100644 --- a/apps/desktop/src/widgets/live-chat/live-session-column.tsx +++ b/apps/desktop/src/widgets/live-chat/live-session-column.tsx @@ -51,7 +51,10 @@ import { type SessionChangesView } from "@/entities/session/use-session-changes" import { saveLastModelSelection } from "@/entities/session/last-model-preference"; import { useVisibleModels } from "@/entities/model/visible-models"; import { usePromptCommands } from "@/entities/prompt-command"; -import { useLiveSession } from "@/entities/session/use-session-projections"; +import { + useLiveSession, + useViewedSession, +} from "@/entities/session/use-session-projections"; import { type SessionProjectionsStore } from "@/entities/session/session-projections-store"; import { isAssistantAnswerMessage, @@ -189,6 +192,9 @@ export function LiveSessionColumn({ apply, retryHistory, } = useLiveSession(showDraft ? creatingSessionId : sessionId); + // The store reads a viewed Session's results as they land (same commit), + // where a passive-effect write-back cost one extra commit per event (React error #185). + useViewedSession(showDraft ? null : sessionId); const promptCommands = usePromptCommands( liveProjection ? { sessionId: liveProjection.id } : null, ); @@ -284,12 +290,6 @@ export function LiveSessionColumn({ return () => clearTimeout(timer); }, [draftHandoff]); - useEffect(() => { - if (!showDraft && liveProjection?.unreadResult) { - apply({ type: "latest-message-rendered", occurredAt: new Date().toISOString() }); - } - }, [apply, liveProjection?.unreadResult, showDraft]); - const handleDraftChange = (prompt: string) => { setSessionDraft(saveSessionDraft(sessionDraft?.projectId ?? null, prompt)); }; diff --git a/bun.lock b/bun.lock index ef84d7c5..875433ad 100644 --- a/bun.lock +++ b/bun.lock @@ -136,6 +136,9 @@ }, }, }, + "patchedDependencies": { + "@astryxdesign/core@0.3.0": "patches/@astryxdesign%2Fcore@0.3.0.patch", + }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], diff --git a/package.json b/package.json index f9acf3be..3c9e843f 100644 --- a/package.json +++ b/package.json @@ -71,5 +71,8 @@ "typescript-eslint": "^8.70.1", "vite": "^5.4.2", "vitest": "^4.1.9" + }, + "patchedDependencies": { + "@astryxdesign/core@0.3.0": "patches/@astryxdesign%2Fcore@0.3.0.patch" } } diff --git a/patches/@astryxdesign%2Fcore@0.3.0.patch b/patches/@astryxdesign%2Fcore@0.3.0.patch new file mode 100644 index 00000000..0b0b5fa9 --- /dev/null +++ b/patches/@astryxdesign%2Fcore@0.3.0.patch @@ -0,0 +1,66 @@ +diff --git a/dist/Heading/Heading.js b/dist/Heading/Heading.js +index b10c5c13134234791b3653078c1bd943675d45c2..832bb50cf5985935b2c61d8530f5de4d461088d1 100644 +--- a/dist/Heading/Heading.js ++++ b/dist/Heading/Heading.js +@@ -16,7 +16,7 @@ + * - /packages/cli/assets/templates/blocks/components/Heading/ (showcase blocks) + * - /packages/cli/assets/templates/blocks/components/Text/ (showcase blocks) + */ +-import { lazy, Suspense, useRef } from 'react'; ++import { lazy, Suspense, useMemo, useRef } from 'react'; + import * as stylex from '@stylexjs/stylex'; + import { colorStyles, sizeByLevelStyles, sizeByTypeStyles, defaultWeightByTypeStyles, displayStyles, justifyStyles, truncationStyles, wordBreakStyles, textWrapStyles, capsizeStyles, decorationStyles, truncationTooltipStyles } from "../Text/text.stylex.js"; + import { useTruncation } from "../Text/useTruncation.js"; +@@ -107,13 +107,18 @@ export function Heading({ + // Ref for the heading element (used as tooltip anchor) + const headingRef = useRef(null); + ++ // Pace patch: keep the merged ref stable across renders so React does not ++ // detach/reattach it (and re-run useTruncation's ref) on every commit. ++ // Upstream fixed this in 0.6.x with useMergedRefs. ++ const mergedRef = useMemo(() => mergeRefs(ref, truncation.ref, headingRef), [ref, truncation.ref]); ++ + // Build inline style for -webkit-line-clamp (dynamic value) + const inlineStyle = maxLines > 1 ? { + WebkitLineClamp: maxLines + } : undefined; + return /*#__PURE__*/_jsxs(_Fragment, { + children: [/*#__PURE__*/_jsx(Component, { +- ref: mergeRefs(ref, truncation.ref, headingRef), ++ ref: mergedRef, + ...mergeProps(themeProps('heading', { + level, + color, +diff --git a/dist/Text/Text.js b/dist/Text/Text.js +index 4f10ae885ff32b7fb79d804fd728546f4484f836..45712717d7c80e2f31377bb16e0a701e580ecf65 100644 +--- a/dist/Text/Text.js ++++ b/dist/Text/Text.js +@@ -15,7 +15,7 @@ + * - /apps/storybook/stories/Text.stories.tsx (storybook stories) + * - /packages/cli/assets/templates/blocks/components/Text/ (showcase blocks) + */ +-import { lazy, Suspense, useRef } from 'react'; ++import { lazy, Suspense, useMemo, useRef } from 'react'; + import * as stylex from '@stylexjs/stylex'; + import { colorStyles, defaultWeightByTypeStyles, sizeStyles, sizeByTypeStyles, weightStyles, displayStyles, justifyStyles, truncationStyles, wordBreakStyles, textWrapStyles, capsizeStyles, decorationStyles, tabularNumbersStyle, truncationTooltipStyles } from "./text.stylex.js"; + import { useTruncation } from "./useTruncation.js"; +@@ -134,13 +134,18 @@ export function Text({ + // Ref for the text element (used as tooltip anchor) + const textRef = useRef(null); + ++ // Pace patch: keep the merged ref stable across renders so React does not ++ // detach/reattach it (and re-run useTruncation's ref) on every commit. ++ // Upstream fixed this in 0.6.x with useMergedRefs. ++ const mergedRef = useMemo(() => mergeRefs(ref, truncation.ref, textRef), [ref, truncation.ref]); ++ + // Build inline style for -webkit-line-clamp (dynamic value) + const inlineStyle = maxLines > 1 ? { + WebkitLineClamp: maxLines + } : undefined; + return /*#__PURE__*/_jsxs(_Fragment, { + children: [/*#__PURE__*/_jsx(Component, { +- ref: mergeRefs(ref, truncation.ref, textRef), ++ ref: mergedRef, + ...mergeProps(themeProps('text', { + type, + size,