diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx
index da293c38ddc9..13606e75c870 100644
--- a/apps/web/src/components/chat/TraitsPicker.tsx
+++ b/apps/web/src/components/chat/TraitsPicker.tsx
@@ -17,7 +17,7 @@ import {
} from "@t3tools/shared/model";
import { memo, useCallback } from "react";
import type { VariantProps } from "class-variance-authority";
-import { ZapIcon } from "lucide-react";
+import { GaugeIcon, ZapIcon } from "lucide-react";
import { buttonVariants } from "../ui/button";
import {
Menu,
@@ -32,6 +32,7 @@ import { useComposerDraftStore, DraftId } from "../../composerDraftStore";
import { getProviderModelCapabilities } from "../../providerModels";
import { cn } from "~/lib/utils";
import { Badge } from "../ui/badge";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import {
ComposerControl,
ComposerControlChevron,
@@ -589,6 +590,7 @@ export const TraitsPicker = memo(function TraitsPicker({
primarySelectDescriptorId: primarySelectDescriptor?.id ?? null,
ultrathinkPromptControlled,
});
+ const accessibleLabel = showFastModeIcon ? `${triggerLabel}, Fast mode on` : triggerLabel;
const fastModeIcon = showFastModeIcon ? (
<>
-
- }
- >
- {isCodexStyle ? (
- // The label truncates itself; clipping the wrapper too would cut off
- // the chevron, whose negative end margin overhangs the wrapper edge.
-
- {fastModeIcon}
- {triggerLabel}
-
-
- ) : (
- <>
- {fastModeIcon}
- {triggerLabel}
-
- >
- )}
-
+
+
+ }
+ />
+ }
+ >
+ {isCodexStyle ? (
+ // The label truncates itself; clipping the wrapper too would cut off
+ // the chevron, whose negative end margin overhangs the wrapper edge.
+
+ {fastModeIcon ?? (
+
+
+
+ )}
+
+ {triggerLabel}
+
+
+
+ ) : (
+ <>
+ {fastModeIcon ?? (
+
+
+
+ )}
+ {triggerLabel}
+
+ >
+ )}
+
+ {accessibleLabel}
+
Branch
- {snapshot.branch}
+
+
+
>
) : null}
{snapshot.baseRef ? (
<>
Base
- {snapshot.baseRef}
+
+
+
>
) : null}
{snapshot.worktreePath ? (
<>
Path
- {snapshot.worktreePath}
+
+
+
>
) : null}
{snapshot.setupScript ? (
diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts
index 052c9b1d9f4d..8920bd52d2b1 100644
--- a/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts
+++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.test.ts
@@ -20,7 +20,13 @@ function measurePicker(input: { clientWidth: number; flexGrow: string; maxWidth?
}
return null;
},
- querySelectorAll: () => [{ getBoundingClientRect: () => ({ width: 140 }) }],
+ querySelectorAll: () => [
+ {
+ dataset: {},
+ querySelectorAll: () => [],
+ getBoundingClientRect: () => ({ width: 140 }),
+ },
+ ],
};
vi.stubGlobal("getComputedStyle", (element: unknown) => {
if (element === label) return { flexGrow: input.flexGrow };
@@ -40,6 +46,7 @@ describe("measureRestingComposerControls", () => {
expect(resolveRestingComposerControlsNaturalWidth(measurement)).toBe(196);
expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
hiddenCount: 0,
+ iconOnlyCount: 0,
visible: true,
});
});
@@ -50,6 +57,7 @@ describe("measureRestingComposerControls", () => {
expect(measurement.naturalFixedWidth).toBe(192);
expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
hiddenCount: 1,
+ iconOnlyCount: 1,
visible: true,
});
});
@@ -60,6 +68,7 @@ describe("measureRestingComposerControls", () => {
expect(measurement.naturalFixedWidth).toBe(212);
expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth: 200 })).toEqual({
hiddenCount: 1,
+ iconOnlyCount: 1,
visible: true,
});
});
diff --git a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts
index fc39951723fa..82aa882db53a 100644
--- a/apps/web/src/components/chat/restingComposerControlsMeasurement.ts
+++ b/apps/web/src/components/chat/restingComposerControlsMeasurement.ts
@@ -42,6 +42,33 @@ function providerModelPickerMinimumWidth(picker: HTMLElement): number {
return minWidth + elementInlineMarginWidth(picker);
}
+function controlBlockWidths(block: HTMLElement): { natural: number; iconOnly: number } {
+ const compact = block.dataset.composerBlockIconOnly === "true";
+ let natural = elementOuterWidth(block);
+ let iconOnly = natural;
+ for (const label of block.querySelectorAll("[data-composer-control-label]")) {
+ // Labels remain mounted at natural width when icons replace them. Reading
+ // both variants from one tree avoids duplicate controls or write/read probes.
+ const labelStyle = getComputedStyle(label);
+ const inFlow = labelStyle.position !== "absolute";
+ // Phone widths already hide the build label with sr-only. Its clipping
+ // remains in effect even when compact styles replace its one-pixel width.
+ if (!inFlow && (!compact || labelStyle.clip !== "auto")) continue;
+ const labelWidth = label.scrollWidth;
+ const renderedWidth = inFlow ? label.getBoundingClientRect().width : 0;
+ const gap = Number.parseFloat(getComputedStyle(label.parentElement!).columnGap) || 0;
+ natural += labelWidth - renderedWidth + (inFlow ? 0 : gap);
+ iconOnly -= renderedWidth + (inFlow ? gap : 0);
+ }
+ for (const icon of block.querySelectorAll("[data-composer-control-compact-icon]")) {
+ const width = elementOuterWidth(icon);
+ const gap = Number.parseFloat(getComputedStyle(icon.parentElement!).columnGap) || 0;
+ natural -= compact ? width + gap : 0;
+ iconOnly += compact ? 0 : width + gap;
+ }
+ return { natural, iconOnly: Math.min(natural, iconOnly) };
+}
+
/**
* Read the natural widths of the resting composer controls from the DOM.
*
@@ -67,6 +94,7 @@ export function measureRestingComposerControls(
const overflow = controls.querySelector("[data-resting-controls-overflow]");
const separatorAndGapWidth = separatorWidth > 0 ? separatorWidth + gap : 0;
const blocks = Array.from(controls.querySelectorAll("[data-resting-block]"));
+ const widths = blocks.map(controlBlockWidths);
return {
gap,
naturalFixedWidth:
@@ -75,7 +103,8 @@ export function measureRestingComposerControls(
minimumFixedWidth:
(picker ? providerModelPickerMinimumWidth(picker) : elementOuterWidth(leadingControl)) +
separatorAndGapWidth,
- blockWidths: blocks.map(elementOuterWidth),
+ blockWidths: widths.map((width) => width.natural),
+ iconOnlyBlockWidths: widths.map((width) => width.iconOnly),
overflowWidth: overflow ? elementOuterWidth(overflow) : 0,
};
}
diff --git a/apps/web/src/components/chat/useComposerTriggerState.test.tsx b/apps/web/src/components/chat/useComposerTriggerState.test.tsx
new file mode 100644
index 000000000000..6daf88bfbd67
--- /dev/null
+++ b/apps/web/src/components/chat/useComposerTriggerState.test.tsx
@@ -0,0 +1,145 @@
+import { act, StrictMode, useLayoutEffect } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import { detectComposerTrigger } from "../../composer-logic";
+import { useComposerTriggerState } from "./useComposerTriggerState";
+
+const command = "pnpm install -g @openai/codex@latest";
+const initialPrompt = "pnpm install -g @openai";
+let root: Root;
+let composer: ReturnType;
+
+function ComposerProbe() {
+ const state = useComposerTriggerState(() =>
+ detectComposerTrigger(initialPrompt, initialPrompt.length),
+ );
+ useLayoutEffect(() => {
+ composer = state;
+ });
+ return null;
+}
+
+async function updatePrompt(text: string, cursor = text.length) {
+ await act(() => composer.setTrigger(detectComposerTrigger(text, cursor)));
+}
+
+beforeEach(async () => {
+ // The probe renders no DOM nodes, but ReactDOM still needs an event target.
+ const document = {
+ nodeType: 9,
+ addEventListener() {},
+ removeEventListener() {},
+ };
+ const container = {
+ nodeType: 1,
+ tagName: "DIV",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ ownerDocument: document,
+ addEventListener() {},
+ removeEventListener() {},
+ };
+ vi.stubGlobal("document", document);
+ vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ root = createRoot(container as unknown as HTMLElement);
+ await act(() =>
+ root.render(
+
+
+ ,
+ ),
+ );
+});
+
+afterEach(async () => {
+ await act(() => root.unmount());
+ vi.unstubAllGlobals();
+});
+
+describe("composer suggestion dismissal", () => {
+ it("closes suggestions and rejects keyboard selection before the next render", async () => {
+ const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length);
+ expect(composer.trigger).toEqual(candidate);
+
+ await act(() => {
+ composer.dismissTrigger(candidate);
+ expect(composer.resolveTrigger(candidate)).toBeNull();
+ });
+ expect(composer.trigger).toBeNull();
+ });
+
+ it("stays dismissed while typing a scoped package, including its second @", async () => {
+ await act(() => composer.dismissTrigger(composer.trigger));
+
+ for (let cursor = initialPrompt.length; cursor <= command.length; cursor += 1) {
+ const text = command.slice(0, cursor);
+ await updatePrompt(text);
+ expect(composer.trigger).toBeNull();
+ expect(composer.resolveTrigger(detectComposerTrigger(text, cursor))).toBeNull();
+ }
+ });
+
+ it("stays dismissed while deleting characters or moving within the same word", async () => {
+ await act(() => composer.dismissTrigger(composer.trigger));
+
+ for (let cursor = initialPrompt.length - 1; cursor > initialPrompt.indexOf("@"); cursor -= 1) {
+ await updatePrompt(initialPrompt, cursor);
+ expect(composer.trigger).toBeNull();
+ await updatePrompt(initialPrompt.slice(0, cursor));
+ expect(composer.trigger).toBeNull();
+ }
+ });
+
+ it("opens suggestions for a new @ word after a space", async () => {
+ await act(() => composer.dismissTrigger(composer.trigger));
+ await updatePrompt(`${command} `);
+ await updatePrompt(`${command} @src`);
+
+ expect(composer.trigger?.query).toBe("src");
+ expect(composer.trigger?.rangeStart).toBe(command.length + 1);
+ });
+
+ it("opens a different token when the caret moves directly to it", async () => {
+ await act(() => composer.dismissTrigger(composer.trigger));
+ await updatePrompt(`${command} @src`);
+
+ expect(composer.trigger?.query).toBe("src");
+ });
+
+ it("can reopen after the caret leaves the dismissed word", async () => {
+ await act(() => composer.dismissTrigger(composer.trigger));
+ await updatePrompt(initialPrompt, 0);
+ await updatePrompt(initialPrompt);
+
+ expect(composer.trigger?.query).toBe("openai");
+ });
+
+ it("can reopen at the same position after deleting and retyping @", async () => {
+ await act(() => composer.dismissTrigger(composer.trigger));
+ const prefix = initialPrompt.slice(0, initialPrompt.indexOf("@"));
+ await updatePrompt(prefix);
+ await updatePrompt(`${prefix}@`);
+
+ expect(composer.trigger?.query).toBe("");
+ });
+
+ it("clears dismissal when switching drafts or pending questions", async () => {
+ await act(() => composer.dismissTrigger(composer.trigger));
+ const candidate = detectComposerTrigger(initialPrompt, initialPrompt.length);
+ await act(() => composer.resetTrigger(candidate));
+
+ expect(composer.trigger).toEqual(candidate);
+ expect(composer.resolveTrigger(candidate)).toEqual(candidate);
+ });
+
+ it.each(["/plan", "$skill", "#123"])("also dismisses %s suggestions", async (text) => {
+ await updatePrompt(text);
+ await act(() => composer.dismissTrigger(composer.trigger));
+ await updatePrompt(`${text}x`);
+
+ expect(composer.trigger).toBeNull();
+ await updatePrompt("@src");
+ expect(composer.trigger?.kind).toBe("path");
+ });
+});
diff --git a/apps/web/src/components/chat/useComposerTriggerState.ts b/apps/web/src/components/chat/useComposerTriggerState.ts
new file mode 100644
index 000000000000..255a8c43735c
--- /dev/null
+++ b/apps/web/src/components/chat/useComposerTriggerState.ts
@@ -0,0 +1,42 @@
+import { useCallback, useRef, useState } from "react";
+
+import type { ComposerTrigger } from "../../composer-logic";
+
+/** Keep a dismissed suggestion closed until the caret leaves its token. */
+export function useComposerTriggerState(initialTrigger: () => ComposerTrigger | null) {
+ const [trigger, setActiveTrigger] = useState(initialTrigger);
+ const dismissedTriggerRef = useRef(null);
+
+ const resolveTrigger = useCallback((candidate: ComposerTrigger | null) => {
+ const dismissed = dismissedTriggerRef.current;
+ return candidate &&
+ dismissed &&
+ candidate.kind === dismissed.kind &&
+ candidate.rangeStart === dismissed.rangeStart
+ ? null
+ : candidate;
+ }, []);
+
+ const setTrigger = useCallback(
+ (candidate: ComposerTrigger | null) => {
+ const activeTrigger = resolveTrigger(candidate);
+ if (candidate === null || activeTrigger !== null) {
+ dismissedTriggerRef.current = null;
+ }
+ setActiveTrigger(activeTrigger);
+ },
+ [resolveTrigger],
+ );
+
+ const dismissTrigger = useCallback((candidate: ComposerTrigger | null) => {
+ dismissedTriggerRef.current = candidate;
+ setActiveTrigger(null);
+ }, []);
+
+ const resetTrigger = useCallback((candidate: ComposerTrigger | null) => {
+ dismissedTriggerRef.current = null;
+ setActiveTrigger(candidate);
+ }, []);
+
+ return { trigger, setTrigger, resolveTrigger, dismissTrigger, resetTrigger };
+}
diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts
index 5c0e4327d959..6cb17427c02e 100644
--- a/apps/web/src/components/composerFooterLayout.test.ts
+++ b/apps/web/src/components/composerFooterLayout.test.ts
@@ -449,3 +449,79 @@ describe("resolveScrollToEndClearance", () => {
}
});
});
+
+describe("progressive composer controls", () => {
+ const measurement = {
+ gap: 4,
+ naturalFixedWidth: 140,
+ minimumFixedWidth: 80,
+ blockWidths: [80, 140],
+ iconOnlyBlockWidths: [40, 60],
+ overflowWidth: 24,
+ };
+
+ it("keeps labels while they fit and removes trailing labels before controls", () => {
+ for (const [hostWidth, iconOnlyCount, hiddenCount] of [
+ [368, 0, 0],
+ [367, 1, 0],
+ [288, 1, 0],
+ [287, 2, 0],
+ [248, 2, 0],
+ [247, 2, 1],
+ [211, 2, 2],
+ ] as const) {
+ expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth })).toEqual({
+ hiddenCount,
+ iconOnlyCount,
+ visible: true,
+ });
+ }
+ });
+
+ it("requires slack to restore labels and controls", () => {
+ for (const [hostWidth, previous, promoted] of [
+ [
+ 368,
+ { hiddenCount: 0, iconOnlyCount: 1, visible: true },
+ { hiddenCount: 0, iconOnlyCount: 0, visible: true },
+ ],
+ [
+ 288,
+ { hiddenCount: 0, iconOnlyCount: 2, visible: true },
+ { hiddenCount: 0, iconOnlyCount: 1, visible: true },
+ ],
+ [
+ 248,
+ { hiddenCount: 1, iconOnlyCount: 2, visible: true },
+ { hiddenCount: 0, iconOnlyCount: 2, visible: true },
+ ],
+ ] as const) {
+ expect(resolveRestingComposerControlsLayout({ ...measurement, hostWidth, previous })).toEqual(
+ previous,
+ );
+ expect(
+ resolveRestingComposerControlsLayout({
+ ...measurement,
+ hostWidth: hostWidth + 1,
+ previous,
+ }),
+ ).toEqual(promoted);
+ }
+ });
+
+ it("settles through fractional label-width changes at each threshold", () => {
+ for (const hostWidth of [368, 288, 248]) {
+ let previous = resolveRestingComposerControlsLayout({ ...measurement, hostWidth });
+ for (let index = 0; index < 10; index += 1) {
+ const next = resolveRestingComposerControlsLayout({
+ ...measurement,
+ hostWidth,
+ previous,
+ naturalFixedWidth: 140 + (index % 2) * 0.5,
+ });
+ if (index > 1) expect(next).toEqual(previous);
+ previous = next;
+ }
+ }
+ });
+});
diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts
index 42f466c2ae9d..c89edde9f449 100644
--- a/apps/web/src/components/composerFooterLayout.ts
+++ b/apps/web/src/components/composerFooterLayout.ts
@@ -113,18 +113,29 @@ export interface RestingComposerControlsMeasurement {
minimumFixedWidth: number;
blockWidths: readonly number[];
overflowWidth: number;
+ iconOnlyBlockWidths?: readonly number[];
}
function restingComposerControlsWidth(
input: RestingComposerControlsMeasurement,
hiddenCount: number,
fixedWidth = input.naturalFixedWidth,
+ iconOnlyCount = 0,
): number {
const { blockWidths, gap } = input;
const visibleCount = blockWidths.length - hiddenCount;
return (
fixedWidth +
- blockWidths.slice(0, visibleCount).reduce((sum, width) => sum + width, 0) +
+ blockWidths
+ .slice(0, visibleCount)
+ .reduce(
+ (sum, width, index) =>
+ sum +
+ (index >= blockWidths.length - iconOnlyCount
+ ? (input.iconOnlyBlockWidths?.[index] ?? width)
+ : width),
+ 0,
+ ) +
(hiddenCount > 0 ? input.overflowWidth : 0) +
gap * (visibleCount + (hiddenCount > 0 ? 1 : 0))
);
@@ -145,10 +156,11 @@ export function resolveRestingComposerControlsNaturalWidth(
}
/**
- * Decide how many trailing resting control blocks move into the overflow
- * menu, and whether the cluster can show at all, from natural widths.
+ * Fit footer controls using natural widths: remove trailing labels first,
+ * then move trailing blocks into overflow. Resting and expanded share this
+ * decision, including the slack needed to safely restore controls.
*
- * Trailing blocks hide before the model picker shrinks. Once they are all in
+ * Trailing blocks compact before the model picker shrinks. Once they are all in
* the overflow menu, the picker may contract to its minimum readable width;
* below that the whole cluster hides rather than clipping.
*/
@@ -157,39 +169,41 @@ const RESTING_CONTROLS_SLACK_PX = 1;
export function resolveRestingComposerControlsLayout(
input: RestingComposerControlsMeasurement & {
hostWidth: number;
- previous?: { hiddenCount: number; visible: boolean };
+ previous?: { hiddenCount: number; iconOnlyCount?: number; visible: boolean };
},
-): { hiddenCount: number; visible: boolean } {
+): { hiddenCount: number; iconOnlyCount?: number; visible: boolean } {
const { blockWidths, hostWidth, previous } = input;
- let hiddenCount = 0;
+ const iconSteps = input.iconOnlyBlockWidths ? blockWidths.length : 0;
+ const previousStep = previous
+ ? previous.hiddenCount > 0
+ ? iconSteps + Math.min(previous.hiddenCount, blockWidths.length)
+ : Math.min(previous.iconOnlyCount ?? 0, iconSteps)
+ : 0;
+ let step = 0;
+ const widthAtStep = (candidate: number, fixedWidth = input.naturalFixedWidth) =>
+ restingComposerControlsWidth(
+ input,
+ Math.max(0, candidate - iconSteps),
+ fixedWidth,
+ Math.min(candidate, iconSteps),
+ );
+ // Promotions need a pixel of slack: recovering a flexible picker's natural
+ // width can jitter by a fraction of a pixel across renders. Demotions are
+ // immediate so a threshold cannot clip or flip React between layouts.
while (
- hiddenCount < blockWidths.length &&
- restingComposerControlsWidth(input, hiddenCount) > hostWidth
+ step < iconSteps + blockWidths.length &&
+ widthAtStep(step) > hostWidth - (step < previousStep ? RESTING_CONTROLS_SLACK_PX : 0)
) {
- hiddenCount += 1;
+ step += 1;
}
- // Growing the overflow menu is unconditional, or the controls would clip.
- // Shrinking it has to earn a pixel of slack first: the picker is flexible,
- // so its natural width is recovered from a truncated label whose
- // scrollWidth is integral while the rendered box is fractional. The
- // composer re-measures on every render, so without that margin a host
- // sitting exactly on a threshold flips a block in and out until React
- // gives up with "Maximum update depth exceeded".
- if (previous) {
- const previousHiddenCount = Math.min(previous.hiddenCount, blockWidths.length);
- while (
- hiddenCount < previousHiddenCount &&
- restingComposerControlsWidth(input, hiddenCount) > hostWidth - RESTING_CONTROLS_SLACK_PX
- ) {
- hiddenCount += 1;
- }
- }
- const minimumWidth = restingComposerControlsWidth(input, hiddenCount, input.minimumFixedWidth);
+ const hiddenCount = Math.max(0, step - iconSteps);
+ const iconOnlyCount = Math.min(step, iconSteps);
+ const minimumWidth = widthAtStep(step, input.minimumFixedWidth);
const visible =
previous && !previous.visible
? minimumWidth <= hostWidth - RESTING_CONTROLS_SLACK_PX
: minimumWidth <= hostWidth;
- return { hiddenCount, visible };
+ return { hiddenCount, ...(input.iconOnlyBlockWidths ? { iconOnlyCount } : {}), visible };
}
export function resolveScrollToEndClearance(input: {
diff --git a/apps/web/src/components/device/DeviceHostUpdates.tsx b/apps/web/src/components/device/DeviceHostUpdates.tsx
new file mode 100644
index 000000000000..bda9fca88f93
--- /dev/null
+++ b/apps/web/src/components/device/DeviceHostUpdates.tsx
@@ -0,0 +1,67 @@
+import type { DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
+import { useState } from "react";
+import { Button } from "~/components/ui/button";
+import { deviceEnvironment } from "~/state/device";
+import { useAtomCommand } from "~/state/use-atom-command";
+
+/** Shared by setup, Settings, and the Device panel so automatic updates stay visible. */
+export function DeviceHostUpdates({
+ state,
+ environmentId,
+}: {
+ state: DeviceServiceState;
+ environmentId: EnvironmentId;
+}) {
+ const retry = useAtomCommand(deviceEnvironment.list);
+ const [pending, setPending] = useState(null);
+ if (state.hostStatus === "disabled") return null;
+ return (
+
+ {state.hosts.map((host) => {
+ const status = state.hostStatuses[host.id];
+ if (!status || !["installing", "starting", "failed"].includes(status.status)) return null;
+ const failed = status.status === "failed";
+ return (
+
+
+
{host.label}
+
+ {status.detail ??
+ (failed
+ ? "Device support could not start."
+ : status.status === "installing"
+ ? "Installing device tools…"
+ : "Starting device tools…")}
+
+ {failed ? (
+
+ Check the host connection and network access, then retry. Your device settings are
+ saved.
+
+ ) : null}
+
+ {failed && state.supportsHostRetry ? (
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx
index 6f1ae81eddb9..130353943352 100644
--- a/apps/web/src/components/device/DevicePanel.tsx
+++ b/apps/web/src/components/device/DevicePanel.tsx
@@ -1,3 +1,4 @@
+import { DeviceHostUpdates } from "./DeviceHostUpdates";
import type {
DevicePlatform,
DeviceServiceState,
@@ -253,6 +254,7 @@ export function DevicePanel(props: {
{state.hostStatusDetail}
Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute.
@@ -319,7 +321,7 @@ export function DevicePanel(props: {
? "Opening device…"
: "Starting device…"
: state.hostStatus === "installing"
- ? "Installing device support…"
+ ? (state.hostStatusDetail ?? "Installing device support…")
: "Finding devices…"
}
/>
diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx
index 67bf14181b21..5ef41b4cefd9 100644
--- a/apps/web/src/components/device/DeviceSetup.tsx
+++ b/apps/web/src/components/device/DeviceSetup.tsx
@@ -1,3 +1,4 @@
+import { DeviceHostUpdates } from "./DeviceHostUpdates";
import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts";
import { Check, CircleAlert } from "lucide-react";
import { useState } from "react";
@@ -89,6 +90,7 @@ export function DeviceSetup(props: {
+
{step === 0 ? (
Enable the device hub
diff --git a/apps/web/src/components/device/DeviceToolVersions.tsx b/apps/web/src/components/device/DeviceToolVersions.tsx
new file mode 100644
index 000000000000..d9a79f4bddb5
--- /dev/null
+++ b/apps/web/src/components/device/DeviceToolVersions.tsx
@@ -0,0 +1,89 @@
+import type { ReactNode } from "react";
+import type { DeviceToolVersions as ToolVersions } from "@t3tools/contracts";
+import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "~/components/ui/popover";
+
+export function DeviceToolVersions({
+ tools,
+ action,
+ kind,
+ owner,
+ error,
+}: {
+ tools: ToolVersions | undefined;
+ action?: ReactNode;
+ kind?: keyof ToolVersions;
+ owner?: string | undefined;
+ error?: string | undefined;
+}) {
+ const selected = kind ? tools?.[kind] : undefined;
+ const version =
+ selected?.runningVersion ??
+ (selected?.installedVersions.includes(selected.requiredVersion)
+ ? selected.requiredVersion
+ : selected?.installedVersions
+ .toSorted((a, b) => a.localeCompare(b, undefined, { numeric: true }))
+ .at(-1));
+ const label = kind === "hub" ? "Device hub" : "Agent device";
+ return (
+
+
+ {kind
+ ? version
+ ? `v${version}`
+ : selected
+ ? "Not installed"
+ : "Version unknown"
+ : error
+ ? "Versions unavailable"
+ : "Versions"}
+
+
+ {kind ? label : "Device tools"}
+ {tools ? (
+
+ {(
+ [
+ ["Device hub", tools.hub],
+ ["Agent device", tools.agent],
+ ] as const
+ )
+ .filter(([name]) => !kind || name === label)
+ .map(([name, tool]) => (
+
+ {!kind ?
{name}
: null}
+
+ - Running
+ - {tool.runningVersion ?? "Not running"}
+ - Required
+ - {tool.requiredVersion}
+ - Installed
+ -
+ {tool.installedVersions.join(", ") || "None"}
+
+
+
+ ))}
+
+ ) : (
+ Versions have not been checked.
+ )}
+
+ {owner ? `Managed by ${owner}. ` : ""}Tools update automatically on this host when needed.
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {action ? {action}
: null}
+
+
+ );
+}
diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
index 44b70f2e18ff..f19e41599043 100644
--- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
@@ -121,6 +121,7 @@ import {
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
+import { MiddleTruncate } from "../ui/middle-truncate";
import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
import { PullRequestCopyableCode } from "./PullRequestCopyableCode";
import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
@@ -848,6 +849,9 @@ export function PullRequestDetailPanel({
// and at worst answer from it.
const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });
const [isInvalidating, setIsInvalidating] = useState(false);
+ // One word for "the host is being asked again", whichever of the two halves is in flight:
+ // the invalidation round trip, then the detail read it kicks off.
+ const refreshing = isInvalidating || detailQuery.isPending;
const refreshFromHost = useCallback(async () => {
setIsInvalidating(true);
try {
@@ -1986,18 +1990,29 @@ export function PullRequestDetailPanel({
}
>
-
+ {/* The refresh lives in this menu, so while one runs the trigger wears
+ the spinning glyph in place of the dots: the reader sees the panel
+ is fetching without a control appearing or the row shifting. */}
+ {refreshing ? (
+
+ ) : (
+
+ )}
}
/>
- More pull request actions
+
+ {refreshing ? "Refreshing pull request" : "More pull request actions"}
+
-