From b6d4aa04b4941125defcfea6963a748de7e5d6d2 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:32:58 +0200 Subject: [PATCH 1/6] fix(data): a summary the console does not offer yet never tells the Mate to update Milo 0.15.18 runs zcp v9.200.3, whose data console has no /api/summary: its "/" serves the console's page, so the summary read came back 200 text/html, the broker failed it as an unreadable response (internal), and the panel matched the generic "Something went wrong." against its no-reason sentence and told the up-to-date Mate to update, repeating the failure under the list. The broker now reads a 2xx text/html answer as unsupported. The panel leaves an unoffered summary quiet (no error, no retry), says "update the Mate" only for a no-reason failure on a server older than 0.15.13 (the summary's first release), and keeps the summary's failure out of the list's error line. --- .../src/zerops/ZeropsDataConsole.test.ts | 21 +++++++++ apps/server/src/zerops/ZeropsDataConsole.ts | 14 ++++++ .../zerops/ZeropsDataPanel.test.tsx | 44 +++++++++++++++++++ .../src/components/zerops/ZeropsDataPanel.tsx | 24 +++++++--- .../src/data/projections/database.ts | 6 ++- 5 files changed, 101 insertions(+), 8 deletions(-) diff --git a/apps/server/src/zerops/ZeropsDataConsole.test.ts b/apps/server/src/zerops/ZeropsDataConsole.test.ts index b954111d7..edd932e6c 100644 --- a/apps/server/src/zerops/ZeropsDataConsole.test.ts +++ b/apps/server/src/zerops/ZeropsDataConsole.test.ts @@ -675,6 +675,27 @@ describe("ZeropsDataConsole", () => { }), ); + // Milo 0.15.18 on zcp v9.200.3 (stress run 3, 2026-10-09): the console has no /api/summary + // yet, its page answers 200 with index.html, and the panel told an up-to-date Mate to update. + it.effect( + "A read the Mate's console does not offer yet is unsupported, not a broken response.", + () => + Effect.gen(function* () { + const http = fakeHttpClient( + () => + new Response("", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }), + ); + const result = yield* withService( + { spawn: makeAutoReadySpawner().spawn, http }, + (service) => service.call({ kind: "summary", service: "db" }).pipe(Effect.flip), + ); + expect(result.code).toBe("unsupported"); + }), + ); + it.effect("re-reads services after a refresh", () => Effect.gen(function* () { const calls: Array = []; diff --git a/apps/server/src/zerops/ZeropsDataConsole.ts b/apps/server/src/zerops/ZeropsDataConsole.ts index c06d0e300..d7027ff6b 100644 --- a/apps/server/src/zerops/ZeropsDataConsole.ts +++ b/apps/server/src/zerops/ZeropsDataConsole.ts @@ -349,6 +349,11 @@ const TIMEOUT_ERROR = new ZeropsDataConsoleError({ const toRequestError = (cause: { readonly _tag: string }): ZeropsDataConsoleError => cause._tag === "TimeoutError" ? TIMEOUT_ERROR : toUnreachableError(); +const UNOFFERED_ERROR = new ZeropsDataConsoleError({ + code: "unsupported", + message: "the data console does not offer this read yet", +}); + const UNAUTHORIZED_ERROR = new ZeropsDataConsoleError({ code: "denied", message: "console rejected the session", @@ -920,6 +925,15 @@ export const make = (options: { readonly spawnDataConsole: SpawnDataConsole }) = ); return yield* toEnvelopeError(envelope, response.status); } + // A console older than the route answers it with its own page (its "/" serves the SPA): + // the read is not offered there yet, which is not a broken response. + if ( + Headers.get("content-type")(response.headers).pipe( + Option.exists((type) => type.startsWith("text/html")), + ) + ) { + return yield* UNOFFERED_ERROR; + } return yield* readConsoleJson(response); }); diff --git a/apps/web/src/components/zerops/ZeropsDataPanel.test.tsx b/apps/web/src/components/zerops/ZeropsDataPanel.test.tsx index 26f778508..80fff3f8c 100644 --- a/apps/web/src/components/zerops/ZeropsDataPanel.test.tsx +++ b/apps/web/src/components/zerops/ZeropsDataPanel.test.tsx @@ -970,6 +970,10 @@ describe("ZeropsDataPanel", () => { // Milo's 0.15.11 server could not decode the 0.15.14 client's summary // request (stress run 2, 2026-10-09); the panel said "unavailable" twice. it("A summary the Mate's server cannot answer says so once, with what fixes it.", async () => { + environmentState.environment = { + serverVersion: "0.15.11", + capabilities: { dataConsole: true }, + }; respond([SERVICE_SUPPORTED]); const answer = commandSpy.getMockImplementation()!; commandSpy.mockImplementation((args: { input: ZeropsDataConsoleRequest }) => @@ -987,6 +991,46 @@ describe("ZeropsDataPanel", () => { expect(text).not.toContain("unavailable"); }); + // Milo 0.15.18 (stress run 3, 2026-10-09): its zcp's console has no summary yet; the panel told + // the up-to-date Mate to update and said "Something went wrong." under the list. + it.each([ + ["the console does not offer it yet", "unsupported"], + ["the console fails it", "internal"], + ["fails with no reason", undefined], + ] as const)( + "A summary %s on an up-to-date Mate: it is never told to update, and the list carries no error.", + async (_case, code) => { + environmentState.environment = { + serverVersion: "0.15.18", + capabilities: { dataConsole: true }, + }; + respond([SERVICE_SUPPORTED]); + const answer = commandSpy.getMockImplementation()!; + commandSpy.mockImplementation((args: { input: ZeropsDataConsoleRequest }) => + args.input.kind === "summary" + ? Promise.resolve( + AsyncResult.failure( + code === undefined + ? Cause.die(new Error("boom")) + : Cause.fail(new ZeropsDataConsoleError({ code, message: "raw" })), + ), + ) + : answer(args), + ); + await serviceTab(); + await flush(); + const tree = render({ service: "db1", widthForTest: 420 }); + const text = textOf(tree); + expect(text).toContain("No connection summary"); + expect(text).not.toContain("Updating the Mate"); + expect(findByAttribute(tree, "data-zerops-data-error")).toBeNull(); + if (code === "unsupported") { + expect(text).not.toContain("Retry summary"); + expect(findByAttribute(tree, "data-zerops-data-summary-error")).toBeNull(); + } + }, + ); + it("Unknown totals stay unknown, and searching loaded items never claims a complete search.", async () => { respond([SERVICE_SUPPORTED], (request) => request.kind === "tree" ? { kind: "tree", nodes: [ORDERS], nextCursor: "more" } : undefined, diff --git a/apps/web/src/components/zerops/ZeropsDataPanel.tsx b/apps/web/src/components/zerops/ZeropsDataPanel.tsx index 653070041..2f318d0ea 100644 --- a/apps/web/src/components/zerops/ZeropsDataPanel.tsx +++ b/apps/web/src/components/zerops/ZeropsDataPanel.tsx @@ -60,7 +60,8 @@ import { useRef, useState } from "react"; import type { TerminalContextSelection } from "../../lib/terminalContext"; import { useEnvironment } from "../../state/environments"; import { useDatabasePanel, useDatabaseServices } from "../../zerops/useDatabase"; -import { DATABASE_FAILURE_UNEXPLAINED, databaseTreeTarget } from "@t3tools/client-runtime/data"; +import { databaseTreeTarget } from "@t3tools/client-runtime/data"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { useZeropsDataConsole } from "../../zerops/useZeropsFeeds"; import { Button } from "../ui/button"; import { Chip, FlatCard, MicroLabel, StatusDot } from "./primitives"; @@ -105,6 +106,8 @@ const STATUS_DOT_TONE: Record< }; /** Layout guess before the first measurement lands, so the panel does not flash the wrong shape. */ +/** The first Mate server that answers a connection summary. */ +const SUMMARY_SINCE = "0.15.13"; const ASSUMED_WIDTH_MAXIMIZED = 1200; const ASSUMED_WIDTH_INLINE = 420; @@ -140,7 +143,16 @@ export function ZeropsDataPanel({ service ?? null, ); const database = useDatabasePanel(dataConsoleSupported ? environmentId : null, service ?? null); - const summaryError = database.readStates.summary?.error; + const summaryRead = database.readStates.summary; + // A console that does not offer the summary yet leaves the identity quiet: nothing to retry. + const summaryUnoffered = summaryRead?.code === "unsupported"; + const summaryError = summaryUnoffered ? undefined : summaryRead?.error; + // A failure with no reason reads as a server older than the request only on a Mate that is. + const summaryNeedsUpdate = + summaryError !== undefined && + summaryRead?.code === undefined && + environment !== undefined && + compareSemverVersions(environment.serverVersion, SUMMARY_SINCE) < 0; const { services, tree, @@ -1272,7 +1284,7 @@ export function ZeropsDataPanel({ ? "Copy endpoint/path" : "Copy masked connection"} - ) : ( + ) : summaryUnoffered ? null : ( +

))} ); diff --git a/apps/web/src/components/zerops/ZeropsPanel.tsx b/apps/web/src/components/zerops/ZeropsPanel.tsx index 90d526b2f..fb007b4dc 100644 --- a/apps/web/src/components/zerops/ZeropsPanel.tsx +++ b/apps/web/src/components/zerops/ZeropsPanel.tsx @@ -63,6 +63,7 @@ export function ZeropsPanel({ agentAuthUnknown = null, agentAuthSnapshot, agentSignInDemanded = false, + conversationAgentId, runningToolLabel, }: { readonly threadRef: ScopedThreadRef | null; @@ -73,6 +74,8 @@ export function ZeropsPanel({ readonly agentAuthSnapshot?: ZeropsAgentAuthSnapshot | null | undefined; /** Whether the conversation's own agent needs a sign-in (`agentAdmission(...).attention`). */ readonly agentSignInDemanded?: boolean | undefined; + /** The agent the conversation runs on, where it is one the card signs in. */ + readonly conversationAgentId?: ZeropsAgentId | undefined; /** The caller's own reading of `ZeropsThreadModel.running` (`ChatView`) — the map never derives this itself. */ readonly runningToolLabel?: string | undefined; }) { @@ -165,6 +168,7 @@ export function ZeropsPanel({ ) : ( { if (threadRef === null) return; void checkAuth({ From 123952582ac2f9f8636c559aa6cdbaae56ba68b3 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:42:28 +0200 Subject: [PATCH 3/6] fix(composer): a keystroke reaches the keeper of the text it edited The composer hands its editor one text at a time, the draft or the open question's answer, with the handler that keeps it. The editor wrote the new text in a layout effect but took the new handler in a passive one, so a keystroke between the two (a throttled machine: stress run 3 A +12:45) went to the previous text's keeper: the draft handed back after a question became that question's answer, and a keystroke on a just-opened question replaced the draft. The handler now changes in the same commit, before the text is written. --- .../ComposerPromptEditor.controlled.test.tsx | 70 ++++++++++++++++++- .../src/components/ComposerPromptEditor.tsx | 5 +- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.controlled.test.tsx b/apps/web/src/components/ComposerPromptEditor.controlled.test.tsx index c574fd276..090b47164 100644 --- a/apps/web/src/components/ComposerPromptEditor.controlled.test.tsx +++ b/apps/web/src/components/ComposerPromptEditor.controlled.test.tsx @@ -12,7 +12,7 @@ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $getRoot, $getSelection, type LexicalEditor } from "lexical"; -import { act, createRef, useEffect, useState } from "react"; +import { act, createRef, useEffect, useLayoutEffect, useState } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -189,3 +189,71 @@ describe("the composer's controlled round trip", () => { expect(changes.at(-1)?.value).toBe("abc"); }); }); + +/** + * The composer hands the editor one text at a time — the thread's draft, or the answer to the + * question that just opened — with the handler that keeps it. A keystroke that lands on the text + * the editor holds must reach that text's handler, even in the moment a commit has swapped the + * text and the browser has not yet run the passive effects (a throttled machine, stress run 3). + */ +function OwnedHarness({ owner, text }: { readonly owner: string; readonly text: string }) { + return ( + <> + {}} + onChange={(nextValue) => { + owned.push({ owner, value: nextValue }); + }} + onPaste={() => {}} + editorRef={editorRef} + /> + + + ); +} + +const owned: Array<{ owner: string; value: string }> = []; +let keystrokeOnCommit: string | undefined; + +/** Types one character in the same commit that handed the editor a new text. */ +function KeystrokeOnCommit({ owner }: { readonly owner: string }) { + useLayoutEffect(() => { + if (keystrokeOnCommit === undefined) return; + const character = keystrokeOnCommit; + keystrokeOnCommit = undefined; + lexicalEditor.update(() => $typeInto(character), { discrete: true }); + }, [owner]); + return null; +} + +describe("an edit belongs to the text it edited", () => { + it("A keystroke on the text a question just put in the composer is the question's, never the draft's", async () => { + owned.length = 0; + await act(() => { + renderer = create(); + }); + keystrokeOnCommit = "e"; + await act(() => { + renderer!.update(); + }); + expect(owned).toEqual([{ owner: "question", value: "e" }]); + }); + + it("A keystroke on the draft a question hands back is the draft's, never the question's answer", async () => { + owned.length = 0; + await act(() => { + renderer = create(); + }); + keystrokeOnCommit = "!"; + await act(() => { + renderer!.update(); + }); + expect(owned).toEqual([{ owner: "draft", value: "also tell me the page title!" }]); + }); +}); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index d8658c98b..5009107ba 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -2412,7 +2412,10 @@ function ComposerPromptEditorInner({ [onRemoveTerminalContext], ); - useEffect(() => { + // The handler and the text it keeps change in one commit, before the text is written: a + // keystroke in the moment between that commit and the passive effects reaches the handler of + // the text it edited (a question's answer, never the draft it replaced). + useLayoutEffect(() => { onChangeRef.current = onChange; }, [onChange]); From e4495260cbeaa0c23de263912069ae8b28d0916f Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:51:20 +0200 Subject: [PATCH 4/6] feat(panel): tabs the strip cuts off are listed in a menu beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With ten tabs open the panel's strip cut the left ones off ("…ff", "…les") with only a fade and a sideways scroll to reach them (stress run 3). The strip now measures which tabs its edges cut, as it scrolls or resizes, and a chevron beside it lists them; choosing one opens it, and the strip scrolls it into view. The vault harness takes ?tabs=many to show ten tabs. --- .../components/RightPanelTabs.logic.test.ts | 24 +++++++ .../src/components/RightPanelTabs.logic.ts | 16 +++++ apps/web/src/components/RightPanelTabs.tsx | 64 +++++++++++++++++++ apps/web/src/design/vaultHarness.tsx | 34 ++++++++-- 4 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/RightPanelTabs.logic.test.ts create mode 100644 apps/web/src/components/RightPanelTabs.logic.ts diff --git a/apps/web/src/components/RightPanelTabs.logic.test.ts b/apps/web/src/components/RightPanelTabs.logic.test.ts new file mode 100644 index 000000000..7e68efbc0 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.logic.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { tabsOutOfView } from "./RightPanelTabs.logic"; + +describe("tabsOutOfView", () => { + // Ten tabs in a 540 px panel cut "…ff" and "…les" off with nothing to reach them (stress run 3). + it.each([ + ["every tab fits", { left: 0, right: 300 }, []], + ["a tab cut by the left edge", { left: 82, right: 300 }, ["a"]], + ["a tab cut by the right edge and one past it", { left: 0, right: 150 }, ["c", "d"]], + ["scrolled to the middle", { left: 60, right: 190 }, ["a", "d"]], + ] as const)( + "The strip's overflow menu lists the tabs its edges cut off: %s", + (_case, viewport, hidden) => { + const tabs = [ + { id: "a", left: 0, right: 80 }, + { id: "b", left: 84, right: 140 }, + { id: "c", left: 144, right: 190 }, + { id: "d", left: 194, right: 260 }, + ]; + expect(tabsOutOfView(viewport, tabs)).toEqual(hidden); + }, + ); +}); diff --git a/apps/web/src/components/RightPanelTabs.logic.ts b/apps/web/src/components/RightPanelTabs.logic.ts new file mode 100644 index 000000000..dc2a19b40 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.logic.ts @@ -0,0 +1,16 @@ +/** A tab's or the strip's horizontal extent, in one coordinate space. */ +export interface HorizontalExtent { + readonly left: number; + readonly right: number; +} + +/** The tabs the strip's edges cut off, in strip order: the ones its overflow menu lists. */ +export function tabsOutOfView( + viewport: HorizontalExtent, + tabs: ReadonlyArray, +): Id[] { + // A pixel of rounding is not a cut: layout boxes land on fractions. + return tabs + .filter((tab) => tab.left < viewport.left - 1 || tab.right > viewport.right + 1) + .map((tab) => tab.id); +} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 9dc6e9a64..d576a2844 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -2,6 +2,7 @@ import type { ContextMenuItem, ResolvedKeybindingsConfig } from "@t3tools/contra import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { Bot, + ChevronDown, Cloud, Database, FileDiff, @@ -48,6 +49,7 @@ import { useTheme } from "~/hooks/useTheme"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./RightPanelShell"; +import { tabsOutOfView } from "./RightPanelTabs.logic"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; interface RightPanelTabsProps { @@ -593,6 +595,42 @@ export function RightPanelTabs(props: RightPanelTabsProps) { activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); }, [props.activeSurfaceId]); + // The tabs the strip's edges cut off, re-read as it scrolls or resizes: its overflow menu + // lists them, so a tab out of view is a click away and never only a sideways scroll. + const [hiddenTabIds, setHiddenTabIds] = useState([]); + useEffect(() => { + const viewport = tabListRef.current?.querySelector( + "[data-slot='scroll-area-viewport']", + ); + if (!viewport) return; + const measure = () => { + const bounds = viewport.getBoundingClientRect(); + const next = tabsOutOfView( + bounds, + Array.from(viewport.querySelectorAll("[data-tab-id]"), (tab) => { + const box = tab.getBoundingClientRect(); + return { id: tab.dataset.tabId ?? "", left: box.left, right: box.right }; + }), + ); + setHiddenTabIds((current) => + current.length === next.length && current.every((id, index) => id === next[index]) + ? current + : next, + ); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(viewport); + if (viewport.firstElementChild) observer.observe(viewport.firstElementChild); + viewport.addEventListener("scroll", measure, { passive: true }); + return () => { + observer.disconnect(); + viewport.removeEventListener("scroll", measure); + }; + // The strip's row resizes as tabs open and close, so the observer re-reads then too. + }, []); + const hiddenSurfaces = props.surfaces.filter((surface) => hiddenTabIds.includes(surface.id)); + return ( handleTabAuxClick(event, surface)} @@ -709,6 +748,31 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ) : null} + {hiddenSurfaces.length > 0 ? ( + + + } + > + + + + {hiddenSurfaces.map((surface) => ( + props.onActivate(surface)}> + + {surfaceTitle(surface, props.terminalLabelsById)} + + ))} + + + ) : null} {props.layoutControls}
= { }; const VIEW = params.get("data") === "store" ? VAULT_STORE_FIXTURE : (VIEWS[STATE] ?? VAULT_FIXTURE); +/** `?tabs=many`: the ten tabs a stress run opened, more than the panel's strip holds. */ +const MANY_TABS = params.get("tabs") === "many"; +const TEN_TABS: RightPanelSurface[] = [ + { id: "browser", kind: "browser" }, + { + id: "terminal:1", + kind: "terminal", + resourceId: "1", + terminalIds: ["1"], + activeTerminalId: "1", + }, + { id: "files", kind: "files" }, + { id: "diff", kind: "diff" }, + { id: "git", kind: "git" }, + { id: "zerops", kind: "zerops" }, + { id: "data", kind: "data" }, + { id: "mcp", kind: "mcp" }, + { id: "vault", kind: "vault" }, + { id: "agents", kind: "agents" }, +]; + function Harness() { return (
@@ -65,10 +87,14 @@ function Harness() { onCloseSurfacesToRight={noop} onCopyFilePath={noop} pendingSurfaceIds={new Set()} - surfaces={[ - { id: "zerops", kind: "zerops" }, - { id: "vault", kind: "vault" }, - ]} + surfaces={ + MANY_TABS + ? TEN_TABS + : [ + { id: "zerops", kind: "zerops" }, + { id: "vault", kind: "vault" }, + ] + } terminalLabelsById={new Map()} widthStorageKey="mate:design-vault:panel-width" > From 807c646903150de48d891a1a9f7acc003e246394 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:51:56 +0200 Subject: [PATCH 5/6] fix(menu): a Mate's row keeps its height while a question waits The row's last line held 18 px while the Mate worked and grew to 24 px when it showed "Needs an answer": the marker sat in an inline box on the menu's taller line, so Milo's row went 76 -> 82 px and moved every row under it (stress run 3, +0:31 and +2:27). The status line now holds the other lines' 18 px. --- apps/web/src/components/zerops/SidebarZeropsTree.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/zerops/SidebarZeropsTree.tsx b/apps/web/src/components/zerops/SidebarZeropsTree.tsx index 69e645ffc..b6db09c96 100644 --- a/apps/web/src/components/zerops/SidebarZeropsTree.tsx +++ b/apps/web/src/components/zerops/SidebarZeropsTree.tsx @@ -3107,8 +3107,9 @@ function MateRowView({ )} {outsideHq ? null : status !== null && retainedReply === undefined ? ( - - + // Held to the other lines' height: a waiting question grew the row 76 -> 82 px. + + Date: Fri, 9 Oct 2026 21:52:10 +0200 Subject: [PATCH 6/6] fix(menu): a reload paints no change-only projects and does not slide to the open Mate Stress run 3's reload (+16:58.9) painted every project as its "Review" row alone, then the Mates' menu, then slid down to Milo: - the tree let a project's open changes bypass its unread gate, so with no Mate row yet it drew change-only sections the Mates' menu then replaced. A project's changes alone now draw only once the listing is complete. - the open Mate is found from the listing, which a reload reads after the menu drew; the tree took the late-found Mate for one opened afterwards and scrolled it into view. The Sidebar now says when the route's Mate is still being found, and the Mate a reload opened on is never revealed (openMateReveal). --- apps/web/src/components/Sidebar.tsx | 21 +++++++++--- .../zerops/SidebarProjects.logic.test.ts | 34 +++++++++++++++++++ .../zerops/SidebarProjects.logic.ts | 19 +++++++++++ .../zerops/SidebarZeropsTree.test.tsx | 34 +++++++++++++++++++ .../components/zerops/SidebarZeropsTree.tsx | 33 +++++++++++++----- 5 files changed, 128 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 57a3d4316..c5790e1eb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2353,16 +2353,24 @@ export default function Sidebar() { // The row for the environment whose conversation is open. A fresh draft // has no thread yet, but it knows its environment — and that is the one // the user is about to talk to. - const activeZeropsProjectId = useMemo(() => { - if (comingMateRoute !== undefined) return comingMateRoute.params.projectId; - if (newProjectRoute !== undefined) return newProjectRoute.params.birthId; + const activeZeropsProject = useMemo((): { + readonly id: string | null; + /** The route names a Mate the listing has not found yet: unknown, not none. */ + readonly resolving: boolean; + } => { + if (comingMateRoute !== undefined) + return { id: comingMateRoute.params.projectId, resolving: false }; + if (newProjectRoute !== undefined) + return { id: newProjectRoute.params.birthId, resolving: false }; const environmentId = routeThreadRef?.environmentId ?? routeDraftThread?.environmentId; - if (environmentId === undefined) return null; + if (environmentId === undefined) return { id: null, resolving: false }; const open = findCandidate( zeropsListing, (candidate) => candidate.environmentId === environmentId, ); - return open.kind === "found" ? open.row.project.id : null; + return open.kind === "found" + ? { id: open.row.project.id, resolving: false } + : { id: null, resolving: open.kind === "pending" || open.kind === "unknown" }; }, [ comingMateRoute, newProjectRoute, @@ -2370,6 +2378,8 @@ export default function Sidebar() { routeThreadRef?.environmentId, zeropsListing, ]); + const activeZeropsProjectId = activeZeropsProject.id; + const activeZeropsProjectResolving = activeZeropsProject.resolving; // Whose Mates the menu lists (the account menu's Mine / Everyone): the // tree and the waiting faces read the same answer. const [zeropsMateScope] = useMateScope(); @@ -4180,6 +4190,7 @@ export default function Sidebar() { { expect(newProjectOffered({ candidates, births, complete })).toBe(offered); }); }); + +describe("openMateReveal", () => { + // Stress run 3 (+17:00): the open Mate was found after the menu drew, the menu then slid to it. + it.each([ + ["a reload on an open Mate", { seen: "milo", open: "milo", resolving: false }, undefined], + [ + "a reload while the open Mate is still being found", + { seen: undefined, open: null, resolving: true }, + undefined, + ], + [ + "a reload's open Mate found after the menu drew", + { seen: undefined, open: "milo", resolving: false }, + undefined, + ], + ["a Mate opened from elsewhere", { seen: "milo", open: "fen", resolving: false }, "fen"], + ["a Mate opened where none was", { seen: null, open: "fen", resolving: false }, "fen"], + ["no Mate open", { seen: "milo", open: null, resolving: false }, undefined], + ] as const)( + "A reload leaves the menu where it was; only a Mate opened afterwards scrolls into view: %s", + (_case, input, reveal) => { + expect(openMateReveal(input).reveal).toBe(reveal); + }, + ); + + it("The open Mate a reload finds late is the one it opened on, not one opened afterwards", () => { + const found = openMateReveal({ seen: undefined, open: "milo", resolving: false }); + expect(openMateReveal({ seen: found.seen, open: "milo", resolving: false }).reveal).toBe( + undefined, + ); + expect(openMateReveal({ seen: found.seen, open: "fen", resolving: false }).reveal).toBe("fen"); + }); +}); diff --git a/apps/web/src/components/zerops/SidebarProjects.logic.ts b/apps/web/src/components/zerops/SidebarProjects.logic.ts index 1a9c67d9a..e87247869 100644 --- a/apps/web/src/components/zerops/SidebarProjects.logic.ts +++ b/apps/web/src/components/zerops/SidebarProjects.logic.ts @@ -183,3 +183,22 @@ function roomTheViewNeeds(scroll: ScrollRoom, natural: number): number { if (scroll.scrollTop <= 0) return 0; return Math.max(0, scroll.scrollTop + scroll.clientHeight - natural); } + +/** + * Which open Mate's row the menu scrolls into view, and the open Mate it has seen. A reload leaves + * the menu where it was: the Mate it opened on is not revealed, even when the route's Mate is found + * only after the menu drew (`resolving` until then; `seen` unset while nothing was found). Only a + * Mate opened afterwards — Add landing on a new Mate, a link, a page — is scrolled to; the caller + * marks it seen once its row is drawn. + */ +export function openMateReveal(input: { + readonly seen: string | null | undefined; + readonly open: string | null; + readonly resolving: boolean; +}): { readonly reveal: string | undefined; readonly seen: string | null | undefined } { + const { seen, open } = input; + if (input.resolving) return { reveal: undefined, seen }; + if (seen === undefined) return { reveal: undefined, seen: open }; + if (open === null || open === seen) return { reveal: undefined, seen }; + return { reveal: open, seen }; +} diff --git a/apps/web/src/components/zerops/SidebarZeropsTree.test.tsx b/apps/web/src/components/zerops/SidebarZeropsTree.test.tsx index c2304cf4e..61a043665 100644 --- a/apps/web/src/components/zerops/SidebarZeropsTree.test.tsx +++ b/apps/web/src/components/zerops/SidebarZeropsTree.test.tsx @@ -1287,6 +1287,40 @@ describe("a Mate's face follows its work in the menu", () => { registry.dispose(); }); + // Stress run 3 (+16:58.9): a reload painted every project as its "Review" row alone, then the + // Mates' menu replaced it. + it("A reload paints no project of change rows alone before its Mates are read", () => { + const registry = AtomRegistry.make(); + registry.set(zeropsSessionAtom, { + status: "signed-in", + organizationStatus: "selected", + activeOrganization: organization, + }); + mountHqNavigation(registry, organization.organizationId, { + structure: { apps: [{ id: "aaa", name: "Beviro CRM", projects: [] }], ungrouped: [] }, + }); + const html = renderToStaticMarkup( + + {}} + onSelect={() => {}} + getFlow={() => ({ + pullRequests: [pull(57, { mateProjectId: "beviro-dev" })], + environments: new Map(), + releaseOffered: false, + })} + /> + , + ); + expect(html).not.toContain("Beviro CRM"); + expect(html).not.toContain("#57"); + expect(html).toContain('data-zerops-surface="sidebar-environments-skeleton"'); + registry.dispose(); + }); + it("says a foreign Mate is outside this HQ instead of inventing a sign-in state", () => { const registry = AtomRegistry.make(); registry.set(zeropsSessionAtom, { diff --git a/apps/web/src/components/zerops/SidebarZeropsTree.tsx b/apps/web/src/components/zerops/SidebarZeropsTree.tsx index b6db09c96..9f3ae8ee5 100644 --- a/apps/web/src/components/zerops/SidebarZeropsTree.tsx +++ b/apps/web/src/components/zerops/SidebarZeropsTree.tsx @@ -200,6 +200,7 @@ import { import { headingFaces, landingAfterDraw, + openMateReveal, projectRoom, slackAfterScroll, slackForFold, @@ -486,6 +487,11 @@ export interface SidebarZeropsTreeProps { */ readonly onAskToFix?: ((mateProjectId: string, problem: FixProblem) => void) | undefined; readonly activeProjectId?: string | null; + /** + * The route names a Mate whose row is not found yet (its listing still read): the open Mate is + * unknown, not none. A reload's open Mate found late is still the one it opened on. + */ + readonly activeProjectResolving?: boolean | undefined; /** * What this agent is doing right now. * @@ -595,6 +601,7 @@ function SidebarZeropsTreeView({ onAskToFix, onOpenGroup, activeProjectId, + activeProjectResolving = false, getActivity, keyedReadings = false, menuOpen = true, @@ -674,16 +681,24 @@ function SidebarZeropsTreeView({ // A Mate opened from elsewhere — Add landing on the new Mate, a link, a // page — stands in view in the menu: its row scrolled to the nearest edge // once it is drawn (a new Mate's row comes a moment after its view, so every - // draw looks until it is there). Not the one open at mount: a reload leaves - // the menu where it was. - const shownActiveRef = useRef(activeProjectId); + // draw looks until it is there). Not the one open at mount, even found late: + // a reload leaves the menu where it was (`openMateReveal`). + const shownActiveRef = useRef( + activeProjectResolving ? undefined : (activeProjectId ?? null), + ); useEffect(() => { - if (activeProjectId == null || shownActiveRef.current === activeProjectId) return; + const next = openMateReveal({ + seen: shownActiveRef.current, + open: activeProjectId ?? null, + resolving: activeProjectResolving, + }); + shownActiveRef.current = next.seen; + if (next.reveal === undefined) return; const row = treeRef.current?.querySelector( - `[data-zerops-mate-row="${CSS.escape(activeProjectId)}"]`, + `[data-zerops-mate-row="${CSS.escape(next.reveal)}"]`, ); if (row == null) return; - shownActiveRef.current = activeProjectId; + shownActiveRef.current = next.reveal; const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; row.scrollIntoView({ block: "nearest", behavior: still ? "auto" : "smooth" }); }); @@ -861,9 +876,11 @@ function SidebarZeropsTreeView({ // Until the rows below are drawn, the jump box finds nothing here. jumpIndex.current = EMPTY_JUMP_INDEX; - // A Mate being created is one to draw, whatever the listing holds yet. + // A Mate being created is one to draw, whatever the listing holds yet. A project's open + // changes alone are drawn only once the listing is complete: before, its Mates are still read, + // and a reload would paint them as "Review" rows the Mates' menu then replaces. const nothing = - appsWithWork.length > 0 || births.some((birth) => birth.placement.kind === "mate") + (complete && appsWithWork.length > 0) || births.some((birth) => birth.placement.kind === "mate") ? undefined : emptyReason;