Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/server/src/zerops/ZeropsDataConsole.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!doctype html><html></html>", {
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<string> = [];
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/zerops/ZeropsDataConsole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
});

Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
resolveZeropsProviderAvailability,
isZeropsInstanceRunnable,
} from "@t3tools/client-runtime/data";
import { OrchestrationDispatchCommandError } from "@t3tools/contracts";
import {
OrchestrationDispatchCommandError,
agentIdForDriverKind,
agentIdForProviderInstance,
} from "@t3tools/contracts";
import { mateDiagnostics } from "@t3tools/client-runtime/zerops/diagnostics";
import { useMateRecoveryAction } from "../zerops/useMateRecoveryAction";
import { useEngineRunCards } from "../zerops/useEngineCardPaging";
Expand Down Expand Up @@ -3219,6 +3223,13 @@ export default function ChatView(props: ChatViewProps) {
return mate.kind === "mate" ? mate.mate.name : "This Mate";
})(),
});
// The agent the conversation runs on, where it is one the agents card signs in.
const conversationInstanceId =
activeProviderInstanceId ?? activeThread?.modelSelection.instanceId;
const conversationAgentId =
agentIdForDriverKind(
providerStatuses.find((provider) => provider.instanceId === conversationInstanceId)?.driver,
) ?? agentIdForProviderInstance(conversationInstanceId);
const providerStatusBannerKey = getProviderStatusBannerKey(admission.providerStatus);
useEffect(() => {
if (providerStatusBannerKey === null && dismissedProviderStatusBannerKey !== null) {
Expand Down Expand Up @@ -8283,6 +8294,7 @@ export default function ChatView(props: ChatViewProps) {
agentAuthUnknown={zeropsChrome.agentAuthUnknown}
agentAuthSnapshot={zeropsAgentAuth.snapshot}
agentSignInDemanded={admission.attention !== null}
conversationAgentId={conversationAgentId}
runningToolLabel={zeropsThreadModel.running?.kicker}
threadRef={zeropsChrome.threadRef}
/>
Expand Down
70 changes: 69 additions & 1 deletion apps/web/src/components/ComposerPromptEditor.controlled.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 (
<>
<ComposerPromptEditor
value={text}
cursor={text.length}
terminalContexts={[]}
skills={[]}
disabled={false}
placeholder="Write a prompt"
onRemoveTerminalContext={() => {}}
onChange={(nextValue) => {
owned.push({ owner, value: nextValue });
}}
onPaste={() => {}}
editorRef={editorRef}
/>
<KeystrokeOnCommit owner={owner} />
</>
);
}

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(<OwnedHarness owner="draft" text="also tell me the page title" />);
});
keystrokeOnCommit = "e";
await act(() => {
renderer!.update(<OwnedHarness owner="question" text="" />);
});
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(<OwnedHarness owner="question" text="List" />);
});
keystrokeOnCommit = "!";
await act(() => {
renderer!.update(<OwnedHarness owner="draft" text="also tell me the page title" />);
});
expect(owned).toEqual([{ owner: "draft", value: "also tell me the page title!" }]);
});
});
5 changes: 4 additions & 1 deletion apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/RightPanelTabs.logic.test.ts
Original file line number Diff line number Diff line change
@@ -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);
},
);
});
16 changes: 16 additions & 0 deletions apps/web/src/components/RightPanelTabs.logic.ts
Original file line number Diff line number Diff line change
@@ -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<Id extends string>(
viewport: HorizontalExtent,
tabs: ReadonlyArray<HorizontalExtent & { readonly id: Id }>,
): 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);
}
64 changes: 64 additions & 0 deletions apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ContextMenuItem, ResolvedKeybindingsConfig } from "@t3tools/contra
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
import {
Bot,
ChevronDown,
Cloud,
Database,
FileDiff,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<readonly string[]>([]);
useEffect(() => {
const viewport = tabListRef.current?.querySelector<HTMLElement>(
"[data-slot='scroll-area-viewport']",
);
if (!viewport) return;
const measure = () => {
const bounds = viewport.getBoundingClientRect();
const next = tabsOutOfView(
bounds,
Array.from(viewport.querySelectorAll<HTMLElement>("[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 (
<PreviewPanelShell
mode={props.mode}
Expand Down Expand Up @@ -628,6 +666,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
return (
<div
key={surface.id}
data-tab-id={surface.id}
data-active-tab={active}
onMouseDown={handleTabMouseDown}
onAuxClick={(event) => handleTabAuxClick(event, surface)}
Expand Down Expand Up @@ -709,6 +748,31 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
) : null}
</div>
</ScrollArea>
{hiddenSurfaces.length > 0 ? (
<Menu>
<MenuTrigger
render={
<Button
aria-label={`${hiddenSurfaces.length} more tabs`}
className="shrink-0"
data-right-panel-hidden-tabs
size="icon-xs"
variant="ghost-muted"
/>
}
>
<ChevronDown className="size-3.5" />
</MenuTrigger>
<MenuPopup align="end" side="bottom" sideOffset={6}>
{hiddenSurfaces.map((surface) => (
<MenuItem key={surface.id} onClick={() => props.onActivate(surface)}>
<SurfaceIcon surface={surface} theme={resolvedTheme} />
{surfaceTitle(surface, props.terminalLabelsById)}
</MenuItem>
))}
</MenuPopup>
</Menu>
) : null}
{props.layoutControls}
</div>
<div
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2353,23 +2353,33 @@ 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,
routeDraftThread?.environmentId,
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();
Expand Down Expand Up @@ -4180,6 +4190,7 @@ export default function Sidebar() {
<SidebarZeropsTree
menuOpen={isMobile ? openMobile : open}
activeProjectId={activeZeropsProjectId}
activeProjectResolving={activeZeropsProjectResolving}
births={zeropsPlacedBirths}
candidates={zeropsCandidates}
complete={zeropsMenuComplete}
Expand Down
Loading
Loading