diff --git a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx index 4b8eb4ccb3..bceb360a98 100644 --- a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -66,7 +66,7 @@ export function LinkedEnvironmentsSettings({ ) : links.data.links.length === 0 ? ( ) : ( links.data.links.map((link) => ( @@ -260,8 +260,8 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi ))}

- An agent here never gets more there than its own mode here either. Threads it starts - there cannot change that environment's own threads, projects or settings. + Agents never exceed their mode here and can't change that environment's own threads, + projects or settings.

diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index efc62b06fa..b4378236fd 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -235,7 +235,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting settingKeys={["worktreeAllocation"]} mixed={mixedAllocation} title="Worktree allocation" - description="On write starts with source tools. Attach a worktree for source changes or terminals. Explicit workspace choices still apply." + description="Attach a worktree at launch, or when a thread first changes source or opens a terminal." control={ , +})); import { FleetEnrollmentControls } from "./FleetEnrollmentControls"; const environmentId = EnvironmentId.make("selected-service"); const status: FleetHostStatus = { @@ -72,8 +96,12 @@ async function mount() { ); }); } -const button = (label: string) => - tree!.root.findAllByType("button").find((node) => node.children.join("") === label)!; +const button = (label: "Turn on" | "Turn off") => + tree!.root + .findAllByType("button") + .find( + (node) => node.props["aria-label"] === `${label} automatic updates for Selected service`, + )!; describe("fleet enrollment settings", () => { it("does not enroll from pairing or discovery and requires actual operation protocol support", async () => { @@ -83,6 +111,16 @@ describe("fleet enrollment settings", () => { expect(h.host.mock.calls.every(([, request]) => request.action === "status")).toBe(true); expect(h.bridge).not.toHaveBeenCalled(); expect(JSON.stringify(tree!.toJSON())).toContain("host setup required"); + // The row shows one compact trigger; the switch and explanations live behind it. + expect( + tree!.root + .findAllByType("button") + .some( + (node) => + node.props["aria-label"] === + "Automatic updates for Selected service: Off, host setup required", + ), + ).toBe(true); }); it("reports an unreachable host as unavailable rather than as needing setup", async () => { h.host.mockRejectedValue(new Error("offline")); diff --git a/apps/web/src/jones/fleetUpdates/FleetEnrollmentStatus.tsx b/apps/web/src/jones/fleetUpdates/FleetEnrollmentStatus.tsx index 248b779c32..74b4d014c3 100644 --- a/apps/web/src/jones/fleetUpdates/FleetEnrollmentStatus.tsx +++ b/apps/web/src/jones/fleetUpdates/FleetEnrollmentStatus.tsx @@ -1,12 +1,11 @@ import { useId, useState } from "react"; import { Button } from "../../components/ui/button"; +import { Checkbox } from "../../components/ui/checkbox"; +import { Popover, PopoverPopup, PopoverTrigger } from "../../components/ui/popover"; import { RefreshIcon } from "../../components/ui/refresh-icon"; +import { Switch } from "../../components/ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../../components/ui/tooltip"; -import { - DETAIL_PANEL_CLASSNAME, - DisclosureButton, - INLINE_ACTIONS_CLASSNAME, -} from "../updates/disclosure"; +import { DETAIL_PANEL_CLASSNAME, DisclosureButton } from "../updates/disclosure"; /** What this laptop knows about the host's ability to join automatic updates. */ export type FleetHostReadiness = "loading" | "unavailable" | "setup-required" | "qualified"; @@ -19,8 +18,8 @@ const READINESS_HINT: Record = { }; /** - * One line of automatic-update state and its primary action for a host. Policy and - * setup explanations stay behind the About disclosure. + * A host's automatic-update state as one compact action beside Check and Download. The switch, + * progress and policy explanations open from it, so the row itself stays one status line. */ export function FleetEnrollmentStatus({ label, @@ -54,101 +53,113 @@ export function FleetEnrollmentStatus({ const [aboutOpen, setAboutOpen] = useState(false); const aboutId = useId(); const hint = enrolled && readiness !== "unavailable" ? null : READINESS_HINT[readiness]; + const progress = + follower !== undefined + ? `Host: ${follower}` + : campaign !== undefined + ? `Laptop update: ${campaign.laptopPhase} · Host: ${campaign.hostPhase}${campaign.reason ? ` · ${campaign.reason}` : ""}` + : undefined; + const state = enrolled ? "On" : "Off"; return ( -
-
-
- Automatic updates - {enrolled ? "On" : "Off"} - {hint !== null ? · {hint} : null} - setAboutOpen((open) => !open)} - > - About - -
-
+ + + } + > + Auto-update {state} + + +
+
+
+

Automatic updates

+

+ {hint === null ? "Install this laptop's build after each laptop update." : hint} +

+
+
+ + + } + > + + + Refresh status + + +
+
{!enrolled && readiness === "qualified" ? ( - ) : null} -
- - - - } - > - - - Refresh status - -
-
-
-
- {follower !== undefined ? ( -

- Host: {follower} -

- ) : campaign !== undefined ? ( -

- Laptop update: {campaign.laptopPhase} · Host: {campaign.hostPhase} - {campaign.reason ? ` · ${campaign.reason}` : ""} -

- ) : null} - {notice ?

{notice}

: null} - {aboutOpen ? ( -
-

- Turning this on enrolls only {label}’s qualified service. It installs a build after - this laptop commits the same source; an offline host updates when it reconnects. -

-

- A host that updates itself prepares this laptop’s installed build as soon as it learns - of it, and installs it only after no client has had input for 30 minutes and nothing a - restart would cut is running. Automatic installs are at least 15 minutes apart. -

-

- Turning it off stops dispatch from this laptop first. Host transactions already - accepted still finish. + {progress !== undefined ? ( +

+ {progress}

- {readiness === "setup-required" && !enrolled ? ( -

Before enrolling, verify and adopt a source-qualified service on this host.

- ) : null} - {readiness === "unavailable" ? ( + ) : null} + {notice ?

{notice}

: null} + setAboutOpen((open) => !open)} + > + About + + {aboutOpen ? ( +

- This host didn’t answer the automatic-update status request. It may be offline, or - an older host may need explicit launcher setup. Refresh after it reconnects. + Turning this on enrolls only {label}’s qualified service. It installs a build after + this laptop commits the same source; an offline host updates when it reconnects.

- ) : null} -
- ) : null} -
-
+

+ A host that updates itself prepares this laptop’s installed build as soon as it + learns of it, and installs it only after no client has had input for 30 minutes and + nothing a restart would cut is running. Automatic installs are at least 15 minutes + apart. +

+

+ Turning it off stops dispatch from this laptop first. Host transactions already + accepted still finish. +

+ {readiness === "setup-required" && !enrolled ? ( +

Before enrolling, verify and adopt a source-qualified service on this host.

+ ) : null} + {readiness === "unavailable" ? ( +

+ This host didn’t answer the automatic-update status request. It may be offline, or + an older host may need explicit launcher setup. Refresh after it reconnects. +

+ ) : null} +
+ ) : null} +
+ + ); } diff --git a/apps/web/src/jones/presentationOrder/PresentationOrderSettings.tsx b/apps/web/src/jones/presentationOrder/PresentationOrderSettings.tsx index ef211919f2..f5246f76fd 100644 --- a/apps/web/src/jones/presentationOrder/PresentationOrderSettings.tsx +++ b/apps/web/src/jones/presentationOrder/PresentationOrderSettings.tsx @@ -49,9 +49,6 @@ export function PresentationOrderSettings() { const showEnvironments = projectGroupsSpanEnvironments(groups); return ( -

- Saved on this device across all connected environments. Other devices keep their own order. -

0 ? ( ({ + ...(await original()), + useSettingsScope: (() => ({ environments: [] })) as never, +})); +vi.mock(import("../../state/environments"), async (original) => ({ + ...(await original()), + useEnvironments: (() => ({ + environments: [{ environmentId, label: "VPS", entry: { enabled: true } }], + })) as never, +})); + +function companionState( + config: Partial, + status: DesktopCompanionState["status"], +): DesktopCompanionState { + return { + config: { + enabled: false, + environmentId: null, + hostId: "host-1" as DesktopCompanionState["config"]["hostId"], + label: "Desk", + browserOnly: false, + ...config, + }, + status, + connectionGeneration: null, + assignments: [], + }; +} + +async function renderWith(state: DesktopCompanionState) { + const configure = vi.fn(async () => state); + window.desktopBridge = { previewCompanion: { configure, retry: vi.fn(async () => {}) } } as never; + await act(async () => appAtomRegistry.set(companionStateAtom, state)); + const screen = await render( + + + , + ); + return { screen, configure }; +} + +describe("BrowserHostsSettings", () => { + it("shows only the host switch until hosting is enabled", async () => { + const { screen } = await renderWith(companionState({}, "disabled")); + await using _ = screen; + await expect + .element(page.getByRole("switch", { name: "Use this desktop as a browser host" })) + .toBeDisabled(); + expect(page.getByRole("textbox", { name: "Browser host label" }).query()).toBeNull(); + expect(page.getByRole("switch", { name: "Browser-only desktop" }).query()).toBeNull(); + expect(page.getByRole("button", { name: "Retry connection" }).query()).toBeNull(); + }); + + it("enables hosting for the chosen environment", async () => { + const { screen, configure } = await renderWith(companionState({ environmentId }, "disabled")); + await using _ = screen; + await userEvent.click(page.getByRole("switch", { name: "Use this desktop as a browser host" })); + expect(configure).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true, environmentId }), + ); + }); + + it("shows configuration and retry while enabled and not online", async () => { + const { screen } = await renderWith( + companionState({ enabled: true, environmentId }, "reconnecting"), + ); + await using _ = screen; + await expect.element(page.getByText("Reconnecting…")).toBeVisible(); + await expect.element(page.getByRole("textbox", { name: "Browser host label" })).toBeVisible(); + await expect.element(page.getByRole("switch", { name: "Browser-only desktop" })).toBeVisible(); + await expect.element(page.getByRole("button", { name: "Retry connection" })).toBeVisible(); + }); +}); diff --git a/apps/web/src/jones/previewCompanion/BrowserHostsSettings.tsx b/apps/web/src/jones/previewCompanion/BrowserHostsSettings.tsx index 367afb60aa..5727d1a5a9 100644 --- a/apps/web/src/jones/previewCompanion/BrowserHostsSettings.tsx +++ b/apps/web/src/jones/previewCompanion/BrowserHostsSettings.tsx @@ -1,10 +1,16 @@ import { useAtomValue } from "@effect/atom-react"; -import { type DesktopCompanionConfigureInput, type EnvironmentId } from "@t3tools/contracts"; +import { + type DesktopCompanionConfigureInput, + type DesktopCompanionState, + type EnvironmentId, +} from "@t3tools/contracts"; +import { InfoIcon } from "lucide-react"; import { useState } from "react"; import { useEnvironments } from "../../state/environments"; import { useSettingsScope } from "../../components/settings/SettingsScopeContext"; +import { SettingsRow } from "../../components/settings/settingsLayout"; import { Button } from "../../components/ui/button"; -import { Input } from "../../components/ui/input"; +import { DraftInput } from "../../components/ui/draft-input"; import { Select, SelectTrigger, @@ -12,18 +18,77 @@ import { SelectPopup, SelectItem, } from "../../components/ui/select"; +import { Switch } from "../../components/ui/switch"; import { toastManager } from "../../components/ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../../components/ui/tooltip"; import { companionStateAtom } from "./state.ts"; import { BrowserHostSelect } from "./HostControls.tsx"; +const HOSTS_DESCRIPTION = "Where new preview tabs run. Open tabs keep their host."; + +const STATUS_LABELS: Record = { + disabled: "Off", + awaiting_ticket: "Waiting for pairing", + connecting: "Connecting…", + online: "Online", + reconnecting: "Reconnecting…", + auth_required: "Sign-in required", + unavailable: "Unavailable", + unsupported: "Not supported by this environment", + superseded: "Replaced by another connection", + restart_required: "Restart required", +}; + export function BrowserHostsSettings() { + const { environments } = useSettingsScope(); + return ( + <> + {environments.length === 1 ? ( + } + /> + ) : environments.length > 1 ? ( + +
+ {environments.map((environment, index) => ( +
0 + ? "flex items-center gap-3 border-t border-border/60 px-3 py-2" + : "flex items-center gap-3 px-3 py-2" + } + > + + {environment.label} + + +
+ ))} +
+
+ ) : null} + + + ); +} + +// Configuration rows appear only while hosting is on; browser-only stays reachable while set. +function DesktopBrowserHostSettings() { const state = useAtomValue(companionStateAtom); const { environments } = useEnvironments(); - const scope = useSettingsScope(); const [pending, setPending] = useState(false); + if (!state) return null; const configure = async (change: Partial) => { const bridge = window.desktopBridge?.previewCompanion; - if (!bridge || !state) return; + if (!bridge) return; setPending(true); await (async () => { await bridge.configure({ @@ -41,100 +106,149 @@ export function BrowserHostsSettings() { setPending(false); }); }; + const { enabled, environmentId, browserOnly } = state.config; + const needsEnvironment = !enabled && !environmentId; + const enableSwitch = ( + void configure({ enabled: Boolean(checked) })} + /> + ); + return ( -
-

Browser hosts

-

- Choose where new preview tabs run. Existing tabs stay bound to their original host. -

- {scope.environments.map((environment) => ( -
- {environment.label} - -
- ))} - {state ? ( -
-
- - Use this desktop as a browser host · {state.status.replaceAll("_", " ")} + <> + + {STATUS_LABELS[state.status]} + {enabled && state.status !== "online" ? ( + + ) : null} - + } + /> + + Keeps the local backend off, even while disconnected or unpaired. Downloads, + uploads, recording, profile clearing and pop-up sign-in are unsupported. + + + + } + description={ + state.browserOnlyLocked + ? "Fixed for this app." + : "Run only the browser host here. Takes effect after restart." + } + control={ + void configure({ browserOnly: event.target.checked })} + onCheckedChange={(checked) => void configure({ browserOnly: Boolean(checked) })} /> - {state.browserOnlyLocked - ? "Browser-only desktop (fixed for this app)" - : "Browser-only desktop (requires restart)"} - -

- Browser-only mode keeps the local backend off, including while disconnected or unpaired. - Downloads, uploads, recording, profile clearing and pop-up sign-in are unsupported. -

- -
+ } + /> ) : null} -
+ ); } diff --git a/apps/web/src/jones/remoteAdmission/RemoteAdmissionSettings.browser.test.tsx b/apps/web/src/jones/remoteAdmission/RemoteAdmissionSettings.browser.test.tsx index 64254defd2..e0526ddc43 100644 --- a/apps/web/src/jones/remoteAdmission/RemoteAdmissionSettings.browser.test.tsx +++ b/apps/web/src/jones/remoteAdmission/RemoteAdmissionSettings.browser.test.tsx @@ -25,6 +25,7 @@ vi.mock("../../state/environments", () => ({ vi.mock("./useEvaluatorProviders", () => ({ useEvaluatorProviders: () => (providerDataKnown ? providers : null), })); +vi.mock("../../components/settings/settingsLayout", () => import("./settingsLayoutTestStub")); vi.mock("../../state/server", () => ({ EMPTY_SERVER_PROVIDERS: [] })); vi.mock("../../hooks/useSettings", () => ({ useEnvironmentSettings: () => ({ @@ -163,46 +164,64 @@ function setup(name: string) { }; } const button = (container: HTMLElement, text: string) => - [...container.querySelectorAll("button")].find((entry) => entry.textContent === text)!; + [...container.querySelectorAll("button")].find( + (entry) => entry.textContent === text || entry.getAttribute("aria-label") === text, + )!; +const saveButton = (container: HTMLElement) => + [...container.querySelectorAll("button")].find((entry) => entry.textContent === "Save model"); +const effort = (container: HTMLElement) => + container.querySelector('[aria-label="Evaluator effort"]')!; +const mediumSelection: ModelSelection = { + ...selection, + options: [ + { id: "reasoningEffort", value: "medium" }, + { id: "serviceTier", value: "default" }, + ], +}; it("saves the complete exact Codex account selection and keeps request articles and decisions out of Settings", async () => { using _fixture = setup("settings-exact"); await using screen = await render(); - expect(screen.container.textContent).toContain("Receiving environment: Synthetic receiver"); + expect(screen.container.textContent).toContain( + "Screens incoming tasks on Synthetic receiver before they start", + ); + expect(screen.container.textContent).toContain("Uses Synthetic Codex account."); expect(screen.container.querySelector("article")).toBeNull(); expect(screen.container.textContent).not.toContain("Inspect synthetic project"); + expect(screen.container.textContent).not.toContain("Evaluator policy is enabled"); const picker = screen.container.querySelector( '[aria-label="Evaluator model"]', )!; expect(picker.dataset.instances).toBe(instanceId); await act(async () => picker.click()); + expect(saveButton(screen.container)).toBeUndefined(); + await act(async () => effort(screen.container).click()); await act(async () => button(screen.container, "Save model").click()); + expect(request).toHaveBeenLastCalledWith(environmentId, { action: "snapshot" }); expect(request).toHaveBeenCalledWith(environmentId, { action: "policy", input: { enabled: true, expectedRevision: "synthetic-policy", - modelSelection: selection, + modelSelection: mediumSelection, }, }); - await act(async () => - screen.container.querySelector('[aria-label="Evaluator effort"]')!.click(), - ); +}); + +it("offers Save for an unsaved default selection", async () => { + using fixture = setup("settings-default"); + request.mockResolvedValue({ + ...fixture.snapshot, + policy: { ...fixture.snapshot.policy, enabled: false, modelSelection: null }, + }); + await using screen = await render(); await act(async () => button(screen.container, "Save model").click()); - expect(request).toHaveBeenLastCalledWith(environmentId, { action: "snapshot" }); expect(request).toHaveBeenCalledWith(environmentId, { action: "policy", - input: { - enabled: true, - expectedRevision: "synthetic-policy", - modelSelection: { - ...selection, - options: [ - { id: "reasoningEffort", value: "medium" }, - { id: "serviceTier", value: "default" }, - ], - }, - }, + input: expect.objectContaining({ + enabled: false, + modelSelection: expect.objectContaining({ instanceId, model: "gpt-6.1-sol" }), + }), }); }); @@ -221,7 +240,9 @@ it("shows a saved unavailable selection without replacing it or permitting Save" }); await using screen = await render(); expect(screen.container.textContent).toContain("retired-model (Unavailable)"); - expect(screen.container.textContent).toContain("missing-account / retired-model"); + expect(screen.container.textContent).toContain("missing-account / retired-model is unavailable"); + expect(saveButton(screen.container)).toBeUndefined(); + await act(async () => button(screen.container, "retired-model (Unavailable)").click()); const save = button(screen.container, "Save model"); expect(save.getAttribute("aria-disabled")).toBe("true"); await act(async () => save.click()); @@ -231,9 +252,7 @@ it("shows a saved unavailable selection without replacing it or permitting Save" it("keeps a safe load error and unsaved effort, disables writes, then recovers without hidden or overlapping polls", async () => { using fixture = setup("settings-recovery"); await using screen = await render(); - await act(async () => - screen.container.querySelector('[aria-label="Evaluator effort"]')!.click(), - ); + await act(async () => effort(screen.container).click()); fixture.visibility.mockReturnValue("hidden"); await fixture.poll(); expect(request).toHaveBeenCalledTimes(1); @@ -288,9 +307,8 @@ it.each([ }); await using screen = await render(); expect(screen.container.textContent).toContain(message); - expect(screen.container.textContent).toContain("Evaluator policy is unknown"); - expect(screen.container.textContent).toContain("Evaluator provider availability is unknown"); - expect(screen.container.textContent).not.toContain("No providers advertise"); + expect(screen.container.textContent).toContain("Unknown"); + expect(screen.container.textContent).not.toContain("No supported providers"); expect(screen.container.textContent).not.toContain("synthetic-detail"); expect(screen.container.textContent).not.toContain("Retrying automatically"); expect(screen.container.querySelector('[aria-label="Evaluate remote requests"]')).toBeNull(); @@ -310,12 +328,16 @@ it.each([ policy: { ...fixture.snapshot.policy, enabled: false }, }); await act(async () => button(screen.container, "Refresh").click()); - expect(screen.container.textContent).toContain("Evaluator policy is disabled"); + const toggle = screen.container.querySelector( + '[aria-label="Evaluate remote requests"]', + )!; + expect(toggle.getAttribute("aria-checked")).toBe("false"); expect(screen.container.querySelector('[role="alert"]')).toBeNull(); - await act(async () => button(screen.container, "Save model").click()); + expect(saveButton(screen.container)).toBeUndefined(); + await act(async () => toggle.click()); expect(request).toHaveBeenCalledWith(environmentId, { action: "policy", - input: { enabled: false, expectedRevision: "synthetic-policy", modelSelection: selection }, + input: { enabled: true, expectedRevision: "synthetic-policy", modelSelection: selection }, }); const beforePoll = request.mock.calls.length; await fixture.poll(); @@ -333,21 +355,20 @@ it("keeps provider absence unknown until a current provider source succeeds", as }; request.mockResolvedValue(emptyPolicy); await using screen = await render(); - expect(screen.container.textContent).toContain("Evaluator provider availability is unknown"); - expect(screen.container.textContent).not.toContain("No providers advertise"); - await act(async () => button(screen.container, "Save model").click()); + expect(screen.container.textContent).toContain("Provider availability is unknown"); + expect(screen.container.textContent).not.toContain("No supported providers"); + expect(saveButton(screen.container)).toBeUndefined(); expect(request).toHaveBeenCalledTimes(1); providers = []; providerDataKnown = true; await fixture.poll(); - expect(screen.container.textContent).toContain( - "No providers advertise remote evaluation support", - ); + expect(screen.container.textContent).toContain("No supported providers"); }); it("shows retained policy as last known and blocks settings during a current read", async () => { using fixture = setup("settings-last-known"); await using screen = await render(); + await act(async () => effort(screen.container).click()); let resolve!: (value: typeof fixture.snapshot) => void; request.mockImplementationOnce( () => @@ -356,13 +377,13 @@ it("shows retained policy as last known and blocks settings during a current rea }), ); await fixture.poll(); - expect(screen.container.textContent).toContain( - "Current evaluator policy is unknown. Last known: enabled", - ); + expect(screen.container.textContent).toContain("Last known: enabled"); + expect(button(screen.container, "Save model").getAttribute("aria-disabled")).toBe("true"); await act(async () => button(screen.container, "Save model").click()); expect(request).toHaveBeenCalledTimes(2); await act(async () => resolve(fixture.snapshot)); - expect(screen.container.textContent).toContain("Evaluator policy is enabled"); + expect(screen.container.textContent).not.toContain("Last known"); + expect(button(screen.container, "Save model").getAttribute("aria-disabled")).toBe("false"); }); it("explains unavailable owner controls on hover and focus and guards keyboard activation", async () => { @@ -388,9 +409,7 @@ it("explains unavailable owner controls on hover and focus and guards keyboard a expect(document.activeElement).toBe(control); await userEvent.keyboard("{Enter}"); await userEvent.keyboard(" "); - await act(async () => button(screen.container, "Save model").focus()); - await userEvent.keyboard("{Enter}"); - await userEvent.keyboard(" "); + expect(saveButton(screen.container)).toBeUndefined(); expect(request).toHaveBeenCalledTimes(1); }); @@ -402,18 +421,15 @@ it("explains missing remote evaluation support without inferring an integration policy: { ...fixture.snapshot.policy, enabled: false, modelSelection: null }, }); await using screen = await render(); - expect(screen.container.textContent).toContain( - "Only provider instances that advertise tool-free remote evaluation can be selected.", - ); - expect(screen.container.textContent).toContain("Does not advertise remote evaluation support."); + const disclosure = button(screen.container, "1 provider unavailable for remote evaluation"); + await act(async () => disclosure.click()); + expect(screen.container.textContent).toContain("Not supported."); expect(screen.container.textContent).not.toContain("This Codex CLI integration"); expect(screen.container.textContent).not.toContain("Managed Codex (ChatGPT authorization)"); expect(screen.container.textContent).not.toContain("qualified Claude integrations"); - expect(screen.container.textContent).toContain( - "No providers advertise remote evaluation support", - ); + expect(screen.container.textContent).toContain("No supported providers"); expect(screen.container.querySelector('[aria-label="Evaluator model"]')).toBeNull(); - await act(async () => button(screen.container, "Save model").click()); + expect(saveButton(screen.container)).toBeUndefined(); expect(request).toHaveBeenCalledTimes(1); }); @@ -429,18 +445,14 @@ it("lists only rejected instances with their first snapshot-supported reason onc enabled: false, installed: false, }, - "Does not advertise remote evaluation support.", + "Not supported.", ], [ "Unqualified Claude", { driver: ProviderDriverKind.make("claudeAgent"), supportsRemoteRequestEvaluation: false }, - "Does not advertise remote evaluation support.", - ], - [ - "Older instance", - { supportsRemoteRequestEvaluation: undefined }, - "Does not advertise remote evaluation support.", + "Not supported.", ], + ["Older instance", { supportsRemoteRequestEvaluation: undefined }, "Not supported."], [ "Unavailable instance", { @@ -506,6 +518,10 @@ it("lists only rejected instances with their first snapshot-supported reason onc expect(list()).toBeNull(); providerDataKnown = true; await fixture.poll(); + expect(list()).toBeNull(); + await act(async () => + button(screen.container, `${cases.length} providers unavailable for remote evaluation`).click(), + ); const rows = [...list()!.querySelectorAll("li")].map((row) => row.textContent); expect(rows).toHaveLength(cases.length); for (const [name, , reason] of cases) { @@ -516,4 +532,5 @@ it("lists only rejected instances with their first snapshot-supported reason onc providerDataKnown = false; await fixture.poll(); expect(list()).toBeNull(); + expect(screen.container.textContent).not.toContain("providers unavailable"); }); diff --git a/apps/web/src/jones/remoteAdmission/RemoteAdmissionSettings.tsx b/apps/web/src/jones/remoteAdmission/RemoteAdmissionSettings.tsx index ba5a220f95..a6621d8ef0 100644 --- a/apps/web/src/jones/remoteAdmission/RemoteAdmissionSettings.tsx +++ b/apps/web/src/jones/remoteAdmission/RemoteAdmissionSettings.tsx @@ -1,10 +1,19 @@ import { useState } from "react"; import { Link } from "@tanstack/react-router"; +import { ChevronDownIcon } from "lucide-react"; import type { EnvironmentId, ModelSelection } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; -import { Button } from "../../components/ui/button"; +import { Badge } from "../../components/ui/badge"; +import { Button, InlineButton } from "../../components/ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../../components/ui/collapsible"; +import { RefreshIcon } from "../../components/ui/refresh-icon"; import { Switch } from "../../components/ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../../components/ui/tooltip"; +import { + SETTINGS_PICKER_TRIGGER_CLASSNAME, + SettingsRow, + SettingsSection, +} from "../../components/settings/settingsLayout"; import { ProviderModelPicker } from "../../components/chat/ProviderModelPicker"; import { TraitsPicker } from "../../components/chat/TraitsPicker"; import { useEnvironment, useEnvironments } from "../../state/environments"; @@ -22,6 +31,8 @@ import { UnavailableControl } from "./UnavailableControl"; import { ownerLoadMessage } from "./availability"; import { useEvaluatorProviders } from "./useEvaluatorProviders"; +const SECTION_TITLE = "Background remote evaluation"; + export function RemoteAdmissionConfigurations() { const { environments } = useEnvironments(); return environments.map((environment) => ( @@ -40,6 +51,31 @@ export function RemoteAdmissionSettings({ return ; } +function sameSelection(a: ModelSelection | null | undefined, b: ModelSelection | null | undefined) { + if (!a || !b) return a === b; + return ( + a.instanceId === b.instanceId && + a.model === b.model && + JSON.stringify(a.options ?? []) === JSON.stringify(b.options ?? []) + ); +} + +function RefreshButton({ reason, onRefresh }: { reason: string | null; onRefresh: () => void }) { + return ( + + + + ); +} + function EvaluatorConfiguration({ environmentId }: { readonly environmentId: EnvironmentId }) { const { snapshot, loadFailed, loadFailure, loading, error, busy, store } = useAdmission(environmentId); @@ -58,10 +94,8 @@ function EvaluatorConfiguration({ environmentId }: { readonly environmentId: Env const entries = sortProviderInstanceEntries( applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, null), ); - const selected = - draft ?? - snapshot?.policy.modelSelection ?? - resolveDefaultProviderModelSelection(providers, null); + const savedSelection = snapshot?.policy.modelSelection ?? null; + const selected = draft ?? savedSelection ?? resolveDefaultProviderModelSelection(providers, null); const modelOptions = getCustomModelOptionsByInstance( settings, providers, @@ -80,6 +114,12 @@ function EvaluatorConfiguration({ environmentId }: { readonly environmentId: Env selectedModel && !selectedModel.isUnavailable, ); + // Save appears only when the shown selection differs from the saved policy. + const dirty = Boolean( + selected && + ((draft !== null && !sameSelection(draft, savedSelection)) || + (snapshot && savedSelection === null)), + ); const configuredProviders = providerData ?? EMPTY_SERVER_PROVIDERS; const configuredModelOptions = getCustomModelOptionsByInstance(settings, configuredProviders); const rejectedInstances = sortProviderInstanceEntries( @@ -92,7 +132,7 @@ function EvaluatorConfiguration({ environmentId }: { readonly environmentId: Env const provider = entry.snapshot; let reason: string; if (provider.supportsRemoteRequestEvaluation !== true) { - reason = "Does not advertise remote evaluation support."; + reason = "Not supported."; } else if (provider.availability === "unavailable") { reason = `Unavailable.${provider.unavailableReason ? ` ${provider.unavailableReason}` : ""}`; } else if (!entry.enabled) { @@ -110,6 +150,7 @@ function EvaluatorConfiguration({ environmentId }: { readonly environmentId: Env } return [{ entry, reason }]; }); + // Full explanation for disabled-control tooltips. const blocked = busy ? "Saving evaluator settings…" : loadFailed @@ -117,10 +158,28 @@ function EvaluatorConfiguration({ environmentId }: { readonly environmentId: Env : !snapshot || loading ? "Loading evaluator settings…" : !providersKnown - ? "Evaluator provider availability is unknown. Wait for current provider data from the receiving environment." + ? "Provider availability is unknown. Waiting for current provider data from the receiving environment." : !available - ? "Choose an available evaluator provider and model before saving or enabling." + ? "Choose an available evaluator model first." : null; + // Compact inline status; empty when the controls already say everything. + const statusText = busy + ? "Saving…" + : loadFailed + ? `Owner review is unavailable on ${receiver}. Retrying automatically.` + : !snapshot + ? "Loading…" + : loading + ? `Checking… Last known: ${snapshot.policy.enabled ? "enabled" : "disabled"}.` + : !providersKnown + ? "Provider availability is unknown." + : selected && !available + ? `Saved model ${selected.instanceId} / ${selected.model} is unavailable. No fallback will be saved.` + : null; + const statusId = `evaluator-status-${environmentId}`; + const refresh = () => { + void store.refresh(); + }; const save = async (enabled: boolean) => { if (!snapshot || !policyKnown || busy || (enabled && !available)) return; await store.savePolicy({ @@ -129,214 +188,202 @@ function EvaluatorConfiguration({ environmentId }: { readonly environmentId: Env modelSelection: selected, }); }; + if (loadFailure === "unauthorized" || loadFailure === "unsupported") { return ( -
+ } > -
-

Background remote evaluation · {receiver}

- - - -
-

- {ownerLoadMessage(loadFailure, receiver)} -

-

- Evaluator policy is unknown. Evaluator provider availability is unknown for this session. -

-
+ {ownerLoadMessage(loadFailure, receiver)}} + control={ + + Unknown + + } + /> + ); } + + const controlsDisabled = busy || !policyKnown || !providersKnown; + const toggleDisabled = busy || !policyKnown || (!snapshot?.policy.enabled && !available); return ( -
-
-
-

Background remote evaluation

-

- Receiving environment: {environment?.label ?? environmentId}. Evaluates incoming tasks - before they start. -

-
- - { - void save(enabled); - }} - /> - -
-
- {selected ? ( + onRefresh={refresh} + /> + } + > + + Screens incoming tasks on {receiver} before they start. Held tasks wait in{" "} + + Requests + + . + + } + status={ + statusText || error ? ( + <> + {statusText ? ( +

+ {statusText} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + ) : null + } + control={ - - setDraft( - selected.instanceId === instanceId && selected.model === model - ? selected - : { instanceId, model }, - ) - } + { + void save(enabled); + }} /> - ) : ( - <> - {!policyKnown || !providersKnown ? ( + } + /> + 0 ? ( + + }> + {rejectedInstances.length === 1 + ? "1 provider unavailable for remote evaluation" + : `${rejectedInstances.length} providers unavailable for remote evaluation`} + + + +
    + {rejectedInstances.map(({ entry, reason }) => ( +
  • + {entry.displayName} · {entry.instanceId}: {reason} +
  • + ))} +
+
+
+ ) : null + } + control={ +
+ {selected ? ( + + + setDraft( + selected.instanceId === instanceId && selected.model === model + ? selected + : { instanceId, model }, + ) + } + /> + + ) : !policyKnown || !providersKnown ? ( - ) : null} -

- {policyKnown && providersKnown - ? "No providers advertise remote evaluation support." - : "Evaluator provider availability is unknown."} -

- - )} - {selected && activeEntry ? ( - - - setDraft(createModelSelection(selected.instanceId, selected.model, options)) - } - /> - - ) : null} - - { - if (!blocked && snapshot) void save(snapshot.policy.enabled); - }} + ) : ( + No supported providers + )} + {selected && activeEntry ? ( + - Save model - - } - /> - {blocked ? {blocked} : null} - - - - -
-

- {!policyKnown - ? snapshot - ? `Current evaluator policy is unknown. Last known: ${snapshot.policy.enabled ? "enabled" : "disabled"}. ` - : "Evaluator policy is unknown. " - : `Evaluator policy is ${snapshot?.policy.enabled ? "enabled" : "disabled"}. `} - {blocked ?? - "Runs as a background service using this exact provider account, model and options."} - {selected && !available && providersKnown - ? ` Saved selection: ${selected.instanceId} / ${selected.model}. No fallback will be saved.` - : ""} -

- {selected ? ( -

- Account: {activeEntry?.displayName ?? (providersKnown ? "Unavailable" : "Unknown")} ·{" "} - {selected.instanceId} -

- ) : null} - {error ? ( -

- {error} -

- ) : null} - {providersKnown && rejectedInstances.length > 0 ? ( -
    - {rejectedInstances.map(({ entry, reason }) => ( -
  • - {entry.displayName} · {entry.instanceId}: {reason} -
  • - ))} -
- ) : null} -

- Only provider instances that advertise tool-free remote evaluation can be selected. Held - requests stay in the{" "} - - Requests inbox - - . Disabling evaluation does not resolve them. -

-
+ + setDraft(createModelSelection(selected.instanceId, selected.model, options)) + } + /> + + ) : null} + {dirty ? ( + + { + if (!blocked && snapshot) void save(snapshot.policy.enabled); + }} + > + Save model + + } + /> + {blocked ? {blocked} : null} + + ) : null} +
+ } + /> +
); } diff --git a/apps/web/src/jones/remoteAdmission/settingsLayoutTestStub.tsx b/apps/web/src/jones/remoteAdmission/settingsLayoutTestStub.tsx new file mode 100644 index 0000000000..dddb0cba64 --- /dev/null +++ b/apps/web/src/jones/remoteAdmission/settingsLayoutTestStub.tsx @@ -0,0 +1,46 @@ +// Light settingsLayout stand-ins for remote-admission browser tests. The real +// SettingsRow reads session and primary-environment state these tests mock away. +import type { ReactNode } from "react"; + +export const SETTINGS_PICKER_TRIGGER_CLASSNAME = ""; + +export function SettingsSection({ + title, + headerAction, + children, + ...props +}: { + title: string; + headerAction?: ReactNode; + children: ReactNode; + "aria-label"?: string; +}) { + return ( +
+

{title}

+ {headerAction} + {children} +
+ ); +} + +export function SettingsRow({ + title, + description, + status, + control, +}: { + title: ReactNode; + description?: ReactNode; + status?: ReactNode; + control?: ReactNode; +}) { + return ( +
+

{title}

+ {description ?

{description}

: null} + {status ?
{status}
: null} + {control} +
+ ); +} diff --git a/apps/web/src/jones/remoteAdmission/useEvaluatorProviders.browser.test.tsx b/apps/web/src/jones/remoteAdmission/useEvaluatorProviders.browser.test.tsx index d699efe3c4..c0fc4d49fe 100644 --- a/apps/web/src/jones/remoteAdmission/useEvaluatorProviders.browser.test.tsx +++ b/apps/web/src/jones/remoteAdmission/useEvaluatorProviders.browser.test.tsx @@ -11,6 +11,7 @@ import "../../index.css"; const request = vi.hoisted(() => vi.fn()); vi.mock("./runtime", () => ({ remoteAdmissionRequest: request })); +vi.mock("../../components/settings/settingsLayout", () => import("./settingsLayoutTestStub")); vi.mock("../../state/environments", () => ({ useEnvironment: () => ({ label: "Synthetic receiver", connection: { phase: "connected" } }), useEnvironmentIds: () => [], diff --git a/apps/web/src/jones/settings/DecisionSnapshotAccessSettings.tsx b/apps/web/src/jones/settings/DecisionSnapshotAccessSettings.tsx index 999eef66e7..2c8d476ea2 100644 --- a/apps/web/src/jones/settings/DecisionSnapshotAccessSettings.tsx +++ b/apps/web/src/jones/settings/DecisionSnapshotAccessSettings.tsx @@ -38,8 +38,8 @@ export function DecisionSnapshotAccessSettings() { {...searchableSetting("agent-decision-snapshot-access")} description={ isProjectScope - ? "Allow agents in this project to read the decision snapshot of thread, workstream and host usage. Applies when the agent session next starts." - : "Allow agents to read the decision snapshot of thread, workstream and host usage from this environment's installed collector. Off by default; projects can override it." + ? "Allow agents in this project to read thread, workstream and host usage. Applies when the agent session next starts." + : "Allow agents to read thread, workstream and host usage. Projects can override it." } resetAction={ enabled !== DEFAULT_SERVER_SETTINGS.enableAgentDecisionSnapshotAccess ? ( diff --git a/apps/web/src/jones/threadStatusFilter/ThreadActivitySettings.tsx b/apps/web/src/jones/threadStatusFilter/ThreadActivitySettings.tsx index 8883c95bc2..0fd7aac0d1 100644 --- a/apps/web/src/jones/threadStatusFilter/ThreadActivitySettings.tsx +++ b/apps/web/src/jones/threadStatusFilter/ThreadActivitySettings.tsx @@ -31,7 +31,7 @@ export function ThreadActivitySettings() { { await using screen = await renderStatus({ state: failed }); const { container, onRequest } = screen; expect(container.textContent).toContain("Previous attempt needs reconciliation"); - expect(container.querySelector('[aria-label*="attempt ID"]')).not.toBeNull(); + // The attempt's ID is detail, not status. + expect(container.querySelector('[aria-label*="update ID"]')).toBeNull(); + await act(async () => buttonNamed(container, "Details").click()); + expect(container.querySelector('[aria-label*="update ID"]')).not.toBeNull(); await act(async () => buttonNamed(container, "Reconcile").click()); expect(onRequest).toHaveBeenCalledTimes(1); expect(onRequest).toHaveBeenCalledWith({ @@ -178,6 +181,9 @@ describe("Jones build status", () => { }); const { container, onRequest } = screen; expect(buttonNamed(container, "Reconcile")).toBeUndefined(); + // The unreconciled attempt outranks the launcher refresh, which stays in Details. + expect(container.textContent).not.toContain("Launcher refresh recommended"); + await act(async () => buttonNamed(container, "Details").click()); expect(container.textContent).toContain("Launcher refresh recommended"); await screen.rerender(statusUi({ state: available }, onRequest)); expect(buttonNamed(container, "Reconcile")).toBeUndefined(); @@ -197,7 +203,10 @@ describe("Jones build status", () => { "Update did not start: backup refused. Nothing changed.", ); expect(buttonNamed(container, "Reconcile")).toBeUndefined(); - expect(container.textContent).toContain("No changes:"); + // The settlement already says nothing changed; the outcome is not repeated beside it. + expect(container.textContent).not.toContain("No changes:"); + await act(async () => buttonNamed(container, "Details").click()); + expect(container.textContent).toContain("Last update"); await screen.rerender(statusUi({ state: { ...failed, settlementRefusal: "restore-pending" } })); expect(container.querySelector('[role="alert"]')?.textContent).toBe( "Recovery is still pending.", @@ -378,7 +387,7 @@ describe("Jones build status", () => { expect(buttonNamed(container, "Install").disabled).toBe(true); }); - it("keeps a failed install outcome and stale status visible without opening details", async () => { + it("keeps a failed install outcome visible and stale status one disclosure away", async () => { await using screen = await renderStatus({ state: { ...available, @@ -392,7 +401,9 @@ describe("Jones build status", () => { expect(text).toContain( "Rolled back: 0.0.45-preview.20261011.38178738344.1 → 0.0.45-preview.20261013.38343490676.1 · health check failed", ); - expect(text).toContain("Update status is temporarily unavailable; retrying."); + expect(text).not.toContain("Update status is temporarily unavailable; retrying."); + await act(async () => buttonNamed(container, "Details").click()); + expect(container.textContent).toContain("Update status is temporarily unavailable; retrying."); expect(buttonNamed(container, "Download").getAttribute("aria-disabled")).toBe("true"); expect(buttonNamed(container, "Check").disabled).toBe(true); }); @@ -577,6 +588,53 @@ describe("Jones build status", () => { }); }); +describe("one status line", () => { + const operation = { + headline: "A newer main build is available.", + operationId: "297432ad-1111-4111-8111-111111111111", + held: false, + unknown: false, + planCorrelated: true, + }; + + it("shows the most actionable message once and keeps the rest, the operation and plan in Details", async () => { + await using screen = await renderStatus({ + state: { ...available, launcherRefresh: "recommended" }, + check: { kind: "available", text: "A qualified package is available." }, + operation, + }); + const { container } = screen; + const line = container.textContent!; + expect(line).toContain("Launcher refresh recommended"); + expect(line).not.toContain("A newer main build is available."); + expect(line).not.toContain("A qualified package is available."); + expect(line).not.toContain(operation.operationId); + expect(line).not.toContain("update plan"); + + await act(async () => buttonNamed(container, "Details").click()); + const opened = container.textContent!; + expect(opened.split("A newer main build is available.")).toHaveLength(2); + expect(opened).toContain("A qualified package is available."); + expect(opened).toContain(operation.operationId); + expect(opened).toContain("Part of this laptop's update plan"); + }); + + it("puts a failed check ahead of a launcher refresh", async () => { + await using screen = await renderStatus({ + state: { ...available, launcherRefresh: "recommended" }, + check: { + kind: "failure", + text: "The update check failed. Check the connection or authentication.", + }, + }); + const { container } = screen; + expect(container.querySelector('[role="status"]')?.textContent).toBe( + "The update check failed. Check the connection or authentication.", + ); + expect(container.textContent).not.toContain("Launcher refresh recommended"); + }); +}); + describe("a host that follows its laptop", () => { const staged = (sourceSha: string, leaderSource: string | null) => ({ @@ -591,6 +649,8 @@ describe("a host that follows its laptop", () => { await using screen = await renderStatus({ state: staged(offeredSha, offeredSha) }); const { container } = screen; expect(buttonNamed(container, "Install").disabled).toBe(false); + expect(container.textContent).not.toContain("Follows its laptop"); + await act(async () => buttonNamed(container, "Details").click()); expect(container.textContent).toContain("Follows its laptop · takes only source 0368cbb"); await screen.rerender(statusUi({ state: staged(offeredSha, installedSha) })); @@ -601,6 +661,6 @@ describe("a host that follows its laptop", () => { await screen.rerender(statusUi({ state: staged(offeredSha, null) })); expect(buttonNamed(container, "Install").disabled).toBe(true); - expect(container.textContent).toContain("waiting for the laptop's installed build"); + expect(container.textContent).toContain("Waiting for the laptop's installed build"); }); }); diff --git a/apps/web/src/jones/updates/JonesBuildStatus.tsx b/apps/web/src/jones/updates/JonesBuildStatus.tsx index c7b8dab09a..211c5750f3 100644 --- a/apps/web/src/jones/updates/JonesBuildStatus.tsx +++ b/apps/web/src/jones/updates/JonesBuildStatus.tsx @@ -31,7 +31,7 @@ import { INLINE_ACTIONS_CLASSNAME, } from "./disclosure"; import { BuildVersion, CopyableValue } from "./updateDisplay"; -import { randomUUID } from "../../lib/utils"; +import { cn, randomUUID } from "../../lib/utils"; import { ProductionExactSourceUpdateAction } from "./ProductionExactSourceUpdateAction"; export type JonesBuildRequest = @@ -40,6 +40,33 @@ export type JonesBuildRequest = | { readonly action: "reconcile"; readonly input: JonesUpdateReconcileInput } | { readonly action: "install"; readonly input: JonesUpdateInstallInput }; +export type JonesBuildCheck = { readonly kind: string; readonly text: string }; + +export type JonesBuildOperation = { + readonly headline: string; + readonly operationId: string; + readonly held: boolean; + readonly unknown: boolean; + readonly planCorrelated: boolean; +}; + +const FAILED_CHECKS: ReadonlySet = new Set(["failure", "qualification-failed"]); + +type StatusNote = { + readonly text: string; + readonly tone: "muted" | "warning" | "error"; + /** Alerts are announced; other notes are polite status. */ + readonly alert?: boolean; + /** Replaces the plain text on the status line, keeping `text` for Details. */ + readonly node?: ReactNode; +}; + +const NOTE_TONE = { + muted: "text-muted-foreground", + warning: "text-warning", + error: "text-destructive", +} as const; + const LAYOUTS = { // A direct row in a grouped settings section, aligned with its SettingsRow siblings. row: { @@ -133,6 +160,8 @@ export function JonesBuildStatus({ actionError = null, layout = "inline", extraAction, + check, + operation, onRequest, }: { readonly environmentId: EnvironmentId; @@ -147,6 +176,10 @@ export function JonesBuildStatus({ readonly actionError?: string | null; readonly layout?: JonesBuildStatusLayout; readonly extraAction?: ReactNode; + /** The latest package check. Only a failed check reaches the status line; the rest is detail. */ + readonly check?: JonesBuildCheck | undefined; + /** The host's current update operation receipt. Its ID and plan stay in Details. */ + readonly operation?: JonesBuildOperation | undefined; readonly onRequest: (request: JonesBuildRequest) => void; }) { const [detailsOpen, setDetailsOpen] = useState(false); @@ -311,13 +344,91 @@ export function JonesBuildStatus({ ) : null; + // Every message this host could show, most actionable first. The status line shows only the + // first; the rest stay in Details so the row never repeats itself. + const outcomeFailed = outcome !== undefined && outcome.status !== "committed"; + const candidates: ReadonlyArray = [ + actionError ? { text: actionError, tone: "error", alert: true } : null, + presentation.refusalMessage !== undefined && { + text: presentation.refusalMessage, + tone: "error", + alert: true, + }, + operation !== undefined && + (operation.held || operation.unknown) && { text: operation.headline, tone: "warning" }, + presentation.busy && { text: presentation.message, tone: "muted" }, + presentation.settlementMessage !== undefined && { + text: presentation.settlementMessage, + tone: presentation.settled ? "muted" : "error", + }, + outcomeFailed && + presentation.settlementMessage === undefined && { + text: `${presentation.settled ? "No changes" : outcome.status === "rolled-back" ? "Rolled back" : "Blocked"}: ${outcomeText}`, + tone: presentation.settled ? "muted" : "error", + node: ( + + + } + > + {presentation.settled + ? "No changes" + : outcome.status === "rolled-back" + ? "Rolled back" + : "Blocked"} + : {outcomeText} + + {outcomeBuildDetail(state)} + + ), + }, + freshnessReason !== undefined && { text: freshnessReason, tone: "muted" }, + check !== undefined && FAILED_CHECKS.has(check.kind) && { text: check.text, tone: "error" }, + // An operation's own phase (stopped, restarting); its idle headline repeats the host message. + operation !== undefined && + operation.headline !== presentation.message && { text: operation.headline, tone: "muted" }, + followerHold !== undefined && + candidate !== undefined && { + text: + state.policy?.leaderSource === null + ? "Waiting for the laptop's installed build" + : `${candidate.label} build is not the laptop's, so it will not install`, + tone: "muted", + }, + (state.launcherRefresh === "recommended" || state.launcherSettlement === "unsupported") && { + text: "Launcher refresh recommended", + tone: "muted", + }, + state.capability.reason === "bootstrap-required" && { + text: "Installing needs a qualified launcher on this host first.", + tone: "muted", + }, + { text: presentation.message, tone: "muted" }, + check !== undefined && { text: check.text, tone: "muted" }, + ]; + const notes: StatusNote[] = []; + for (const note of candidates) { + if (note && note.text !== "" && !notes.some((seen) => seen.text === note.text)) + notes.push(note); + } + const [primary, ...otherNotes] = notes; + const statusLine = (
- - {presentation.busy - ? presentation.message - : (presentation.settlementMessage ?? presentation.message)} - + {primary !== undefined ? ( + + {primary.node ?? primary.text} + + ) : null} + {primary !== undefined ? : null}
- {presentation.settlementMessage !== undefined && state.updateId !== undefined ? ( -

- Attempt{" "} - -

- ) : null} - {state.launcherRefresh === "recommended" || state.launcherSettlement === "unsupported" ? ( -

Launcher refresh recommended

- ) : null} - {presentation.refusalMessage !== undefined && - actionError !== presentation.refusalMessage ? ( -

- {presentation.refusalMessage} -

- ) : null} - {freshness !== "fresh" ? ( -

- {freshnessMessage} -

- ) : null} - {followerSummary !== undefined ? ( -

- {followerSummary} - {followerHold !== undefined && - candidate !== undefined && - state.policy?.leaderSource !== null - ? ` · ${candidate.label} build is not the laptop's, so it will not install` - : ""} -

- ) : null} - {actionError ? ( -

- {actionError} -

- ) : null} - {outcome !== undefined && outcome.status !== "committed" ? ( - - - } - > - {presentation.settled - ? "No changes" - : outcome.status === "rolled-back" - ? "Rolled back" - : "Blocked"} - : {outcomeText} - - {outcomeBuildDetail(state)} - - ) : null} - {state.capability.reason === "bootstrap-required" ? ( -

- Installing needs a qualified launcher on this host first. -

- ) : null} {detailsOpen ? (
+ {otherNotes.length > 0 ? ( + + + {otherNotes.map((note) => ( + + {note.text} + + ))} + + + ) : null} + {followerSummary !== undefined ? ( + {followerSummary} + ) : null} + {operation !== undefined ? ( + + + {operation.planCorrelated ? Part of this laptop's update plan : null} + + ) : null} {state.launcherVersion === undefined ? null : ( {state.launcherVersion} )} diff --git a/apps/web/src/jones/updates/JonesUpdateControls.tsx b/apps/web/src/jones/updates/JonesUpdateControls.tsx index 02bf6d61c5..7bde52b2e5 100644 --- a/apps/web/src/jones/updates/JonesUpdateControls.tsx +++ b/apps/web/src/jones/updates/JonesUpdateControls.tsx @@ -33,7 +33,7 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as Effect from "effect/Effect"; import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc"; import { AsyncResult, Atom } from "effect/reactivity"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { ProductionExactSourceUpdateAction } from "./ProductionExactSourceUpdateAction"; import { jonesUpdates } from "./jonesUpdates"; @@ -115,11 +115,14 @@ export function JonesUpdateControls({ environmentId, layout = "inline", compareWithLaptop = false, + extraAction, }: { readonly environmentId: EnvironmentId; readonly layout?: JonesBuildStatusLayout; /** Compares this host with the verified laptop build and offers an explicit matching download. */ readonly compareWithLaptop?: boolean; + /** A compact control placed before Check and Download, such as automatic updates. */ + readonly extraAction?: ReactNode; }) { const observed = useAtomValue(jonesUpdates.observation(environmentId)); const journal = useJonesExecutionJournal(); @@ -309,95 +312,101 @@ export function JonesUpdateControls({ } actionError={actionError} layout={layout} + check={status.check} + operation={ + status.operation === undefined + ? undefined + : { ...status.operation, headline: status.headline } + } extraAction={ - showMatch ? ( - { - const latest = appAtomRegistry.get(laptopInstalledSourceAtom); - return latest !== undefined && latest.environmentId === laptop?.environmentId - ? latest.source.sha - : undefined; - }} - subject="this laptop" - allowInstall={false} - buttonLabel="Match laptop" - compact - /> + showMatch || extraAction !== undefined ? ( + <> + {extraAction} + {showMatch ? ( + { + const latest = appAtomRegistry.get(laptopInstalledSourceAtom); + return latest !== undefined && latest.environmentId === laptop?.environmentId + ? latest.source.sha + : undefined; + }} + subject="this laptop" + allowInstall={false} + buttonLabel="Match laptop" + compact + /> + ) : null} + ) : undefined } onRequest={(request) => void run(request)} /> - {status.check ?

{status.check.text}

: null} - {status.operation ? ( -

- {status.headline} · Operation {status.operation.operationId} - {status.operation.planCorrelated ? " · Part of the Laptop's update plan" : ""} -

- ) : null} - {status.actions.stop ? ( - - ) : null} - {journal.duties.some((d) => d.environmentId === environmentId && d.kind !== "legacy") ? ( - - ) : null} +
+ {status.actions.stop ? ( + + ) : null} + {journal.duties.some((d) => d.environmentId === environmentId && d.kind !== "legacy") ? ( + + ) : null} +
); } diff --git a/apps/web/src/jones/updates/disclosure.tsx b/apps/web/src/jones/updates/disclosure.tsx index 0ec5108b24..f1af82bb00 100644 --- a/apps/web/src/jones/updates/disclosure.tsx +++ b/apps/web/src/jones/updates/disclosure.tsx @@ -61,11 +61,6 @@ export function DetailItem({ export const DETAIL_PANEL_CLASSNAME = "space-y-2 rounded-md bg-muted/40 px-2.5 py-2 text-muted-foreground"; -/** - * The two button slots that end each update line in an environment row. Stacked lines put - * their secondary action (Check, Turn on/off) in the first slot, so their buttons share - * edges whatever the labels; a longer label widens only its own line. Kept right-aligned - * when the line wraps. - */ +/** The buttons that end an update line in an environment row, kept right-aligned when it wraps. */ export const INLINE_ACTIONS_CLASSNAME = - "ml-auto grid shrink-0 grid-cols-[minmax(4.5rem,auto)_minmax(5.25rem,auto)] items-center gap-1.5 sm:grid-cols-[minmax(4rem,auto)_minmax(4.75rem,auto)]"; + "ml-auto flex shrink-0 flex-wrap items-center justify-end gap-1.5"; diff --git a/apps/web/src/jones/workQueue/PromptsSettings.browser.test.tsx b/apps/web/src/jones/workQueue/PromptsSettings.browser.test.tsx index cefd1b8476..e28e8d3637 100644 --- a/apps/web/src/jones/workQueue/PromptsSettings.browser.test.tsx +++ b/apps/web/src/jones/workQueue/PromptsSettings.browser.test.tsx @@ -15,7 +15,6 @@ vi.mock("~/localApi", async (importOriginal) => ({ vi.mock("../../components/settings/settingsLayout", async (importOriginal) => ({ ...(await importOriginal()), SETTINGS_PICKER_TRIGGER_CLASSNAME: "", - SettingsPageContainer: ({ children }: { children: ReactNode }) => children, SettingsSection: ({ children, title }: { children: ReactNode; title: string }) => (

{title}

@@ -94,7 +93,7 @@ describe("PromptsSettings persistence", () => { await root.rerender(); }); expect(container.querySelector("select")?.value).toBe("pending"); - expect(container.querySelector("h2")?.textContent).toBe("Jones Code"); + expect(container.querySelector("h2")?.textContent).toBe("Prompts"); const control = container.querySelector("select")!; const saved = new Promise((resolve) => { storage.setClientSettings.mockImplementationOnce(async (settings) => { diff --git a/apps/web/src/jones/workQueue/PromptsSettings.tsx b/apps/web/src/jones/workQueue/PromptsSettings.tsx index 73501c9a2b..9c14ab5afe 100644 --- a/apps/web/src/jones/workQueue/PromptsSettings.tsx +++ b/apps/web/src/jones/workQueue/PromptsSettings.tsx @@ -1,11 +1,7 @@ import { ThreadActivitySettings } from "../threadStatusFilter/ThreadActivitySettings"; import { PresentationOrderSettings } from "../presentationOrder/PresentationOrderSettings"; import { persistClientSettingsPatch, useClientSettings } from "../../hooks/useSettings"; -import { - SettingsPageContainer, - SettingsRow, - SettingsSection, -} from "../../components/settings/settingsLayout"; +import { SettingsRow, SettingsSection } from "../../components/settings/settingsLayout"; import { searchableSetting } from "../../components/settings/settingsSearch"; import { Select, @@ -20,11 +16,11 @@ const stageLabels = { pending: "Pending", queued: "Queued", sent: "Sent" } as co export function PromptsSettings() { const stage = useClientSettings((settings) => settings.promptsDefaultStage); return ( - - + <> + - + {stageLabels[stage]} - - Pending - Queued - Sent + + + Pending + + + Queued + + + Sent + } @@ -51,6 +50,6 @@ export function PromptsSettings() { - + ); } diff --git a/apps/web/src/jones/workstreams/SetupStatusContent.tsx b/apps/web/src/jones/workstreams/SetupStatusContent.tsx index 4296280fab..8b5ba88ce8 100644 --- a/apps/web/src/jones/workstreams/SetupStatusContent.tsx +++ b/apps/web/src/jones/workstreams/SetupStatusContent.tsx @@ -1,9 +1,12 @@ +import { ChevronRightIcon } from "lucide-react"; import { isNativeWorkstreams, nativeWorkstreamsLabel, type NativeWorkstreamsCapability, } from "@t3tools/client-runtime/state/workstreams"; import type { WorkstreamSetupStatus } from "@t3tools/contracts/jones/workstreamOperations"; +import { Badge } from "../../components/ui/badge"; +import { useRelativeTimeState } from "../../hooks/useRelativeTimeState"; export const sourceReasonLabels = { descriptor_absent: "Source descriptor unavailable. Check native source enrollment.", @@ -16,6 +19,82 @@ export const sourceReasonLabels = { authority_unavailable: "Source authority is unavailable. Restore the source connection.", } as const; +export type SetupStatusTone = "success" | "warning" | "error"; + +export function SetupStatusLine({ + tone, + badge, + children, +}: { + tone: SetupStatusTone; + badge: string; + children: string; +}) { + return ( +

+ + {badge} + + {children} +

+ ); +} + +function sourceIssue(status: WorkstreamSetupStatus, native: boolean): string | null { + const reason = status.t3_source.reason; + if (status.t3_source.state === "ready" || reason === null) return null; + if (!native) return sourceReasonLabels[reason]; + if (reason === "authority_fenced") return sourceReasonLabels.authority_fenced; + if (reason === "generation_mismatch") + return "Source generation changed. Refresh its native authority binding."; + return "Source authority is unavailable. Restore the source connection and refresh native setup."; +} + +function summarize( + status: WorkstreamSetupStatus, + native: boolean, + issue: string | null, +): { tone: SetupStatusTone; badge: string; sentence: string } { + if (status.registry.state === "not_configured") + return { + tone: "warning", + badge: "Not configured", + sentence: native + ? "Configure native Workstreams on this server." + : "Configure Workstreams activation on this server.", + }; + if (status.registry.state === "unavailable") + return { + tone: "error", + badge: "Offline", + sentence: "Registry unreachable. Restore its connection, then refresh.", + }; + if (issue) return { tone: "warning", badge: "Needs attention", sentence: issue }; + if (status.activation.missing_keys.length > 0) + return { + tone: "warning", + badge: "Needs attention", + sentence: "Some activation settings are missing.", + }; + return { + tone: "success", + badge: "Ready", + sentence: status.registry.can_write + ? "Registry connected with write access." + : "Registry connected, read-only.", + }; +} + +const guideLabel = (code: string, native: boolean) => + code === "configure_activation" + ? native + ? "Native setup guide" + : "Activation guide" + : code === "enroll_native_source" + ? "Native enrollment guide" + : "Verification guide"; + +/** Read-only setup diagnosis: badge and top issue first; codes, guides and timestamps in Details. */ export function SetupStatusContent({ status, capability, @@ -25,68 +104,94 @@ export function SetupStatusContent({ }) { const native = isNativeWorkstreams(capability); const label = nativeWorkstreamsLabel(capability); + const issue = sourceIssue(status, native); + const summary = summarize(status, native, issue); + const checked = useRelativeTimeState(status.observed_at); + const checkedLabel = + checked.status === "relative" + ? `Checked ${checked.suffix ? `${checked.value} ${checked.suffix}` : checked.value}` + : null; + const guides = status.next_steps.filter( + (step) => !native || step.code !== "enroll_native_source", + ); return ( -
- {label ?

{label}

: null} +
+ + {summary.sentence} +

- {status.registry.state === "not_configured" - ? native - ? "Not configured: configure native Workstreams on this server." - : "Not configured: configure Workstreams activation on this server." - : status.registry.state === "unavailable" - ? "Offline. restore the registry connection, then refresh." - : `Registry ready · ${status.registry.can_write ? "Write access" : "Read-only"}`} -

- {status.activation.missing_keys.length > 0 ? ( -

Missing configuration: {status.activation.missing_keys.join(", ")}

- ) : null} -

- {status.t3_source.state === "ready" - ? "Native source ready" - : `${status.t3_source.reason}: ${ - native - ? status.t3_source.reason === "authority_fenced" - ? "Source authority is fenced. Resolve the fence before retrying." - : status.t3_source.reason === "generation_mismatch" - ? "Source generation changed. Refresh its native authority binding." - : "Source authority is unavailable. Restore the source connection and refresh native setup." - : sourceReasonLabels[status.t3_source.reason] - }`} -

-

- Placements: {status.placements.readiness} · Open changes: {status.operations.open} -

- - {native ? ( -

- Native setup uses this environment’s authority. No control-plane source enrollment is - required. -

- ) : null} -

- Read-only diagnosis. Last checked {status.observed_at}. + Placements {status.placements.readiness} · {status.operations.open} open{" "} + {status.operations.open === 1 ? "change" : "changes"} + {checkedLabel ? ( + <> + {" · "} + + + ) : null}

+
+ + Details + + +
+ {label ? ( + <> +
Mode
+
{label}
+ + ) : null} +
Registry
+
+ {status.registry.state.replace("_", " ")} + {status.registry.state === "ready" + ? ` · ${status.registry.can_write ? "write access" : "read-only"}` + : null} +
+
Source
+
+ {issue ? ( + <> + {issue} {status.t3_source.reason} + + ) : ( + "Native source ready" + )} +
+ {status.activation.missing_keys.length > 0 ? ( + <> +
Missing
+
{status.activation.missing_keys.join(", ")}
+ + ) : null} + {native ? ( + <> +
Authority
+
This environment’s own; no source enrollment needed.
+ + ) : null} + {guides.length > 0 ? ( + <> +
Guides
+
+ {guides.map((step) => ( + + {guideLabel(step.code, native)} + + ))} +
+ + ) : null} +
Checked
+
{status.observed_at} · read-only diagnosis
+
+
); } diff --git a/apps/web/src/jones/workstreams/WorkstreamsSetupStatus.tsx b/apps/web/src/jones/workstreams/WorkstreamsSetupStatus.tsx index 8201b79eed..f5bb2c2f4a 100644 --- a/apps/web/src/jones/workstreams/WorkstreamsSetupStatus.tsx +++ b/apps/web/src/jones/workstreams/WorkstreamsSetupStatus.tsx @@ -6,11 +6,13 @@ import { supportsWorkstreamOperations } from "@t3tools/client-runtime/jones/work import { primaryEnvironmentIdAtom } from "../../state/primaryEnvironment"; import { environmentSession } from "../../state/session"; import { Atom } from "effect/reactivity"; -import { SettingsPageContainer, SettingsSection } from "../../components/settings/settingsLayout"; +import { SettingsRow, SettingsSection } from "../../components/settings/settingsLayout"; import { Button } from "../../components/ui/button"; +import { RefreshIcon } from "../../components/ui/refresh-icon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../../components/ui/tooltip"; import { environmentOperationsTransport } from "./environmentWorkstreamsRequest"; -import { SetupStatusContent } from "./SetupStatusContent"; +import { SetupStatusContent, SetupStatusLine } from "./SetupStatusContent"; import { nativeWorkstreamsCapability, type NativeWorkstreamsCapability, @@ -24,7 +26,7 @@ function SetupStatusRequest({ capability: NativeWorkstreamsCapability | null; }) { const [status, setStatus] = useState(null); - const [message, setMessage] = useState(null); + const [failed, setFailed] = useState(false); // oxlint-disable-next-line t3code/react-effect-budget -- Read setup capability for the selected environment and abort when its identity changes. useEffect(() => { if (!environmentId) return; @@ -35,17 +37,40 @@ function SetupStatusRequest({ if (!abort.signal.aborted) setStatus(next); }) .catch(() => { - if (!abort.signal.aborted) - setMessage("Offline or unavailable. Check this environment's connection, then refresh."); + if (!abort.signal.aborted) setFailed(true); }); return () => abort.abort(); }, [environmentId]); - return status ? ( - + if (status) return ; + return failed ? ( + + Couldn’t reach this environment. Check its connection, then refresh. + ) : ( -

{message ?? "Checking Workstreams setup…"}

+

Checking setup…

); } + +function RefreshSetupButton({ onClick }: { onClick: () => void }) { + return ( + + + + + } + /> + Refresh setup status + + ); +} + export function WorkstreamsSetupStatus() { const environmentId = useAtomValue(primaryEnvironmentIdAtom); const config = useAtomValue( @@ -54,28 +79,25 @@ export function WorkstreamsSetupStatus() { const supported = supportsWorkstreamOperations(config?.environment.capabilities); const [revision, setRevision] = useState(0); return ( - - - {!supported ? ( -

- Setup diagnosis requires a server with Workstream operations support. Legacy Workstreams - remain available. -

- ) : ( - <> + + {!supported ? ( + + ) : ( + -
- -
- - )} -
-
+ } + control={ setRevision((value) => value + 1)} />} + /> + )} + ); } diff --git a/apps/web/src/routes/_chat.settings.jones-code.tsx b/apps/web/src/routes/_chat.settings.jones-code.tsx index d800764ac2..c16925aff8 100644 --- a/apps/web/src/routes/_chat.settings.jones-code.tsx +++ b/apps/web/src/routes/_chat.settings.jones-code.tsx @@ -1,14 +1,15 @@ import { createFileRoute } from "@tanstack/react-router"; +import { SettingsPageContainer } from "../components/settings/settingsLayout"; import { PromptsSettings } from "../jones/workQueue/PromptsSettings"; import { WorkstreamsSetupStatus } from "../jones/workstreams/WorkstreamsSetupStatus"; function JonesCodeSettings() { return ( -
+ -
+ ); }