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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 75 additions & 40 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1883,55 +1883,91 @@ function ChatViewContent(props: ChatViewProps) {
);
const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
const items: ComposerBannerStackItem[] = [];
const resumingServerUpdate =
serverUpdateState.status === "running" && serverUpdateState.stage === "resuming";
if (activeEnvironmentUnavailableState && !resumingServerUpdate) {
const connection = activeEnvironmentUnavailableState.connection;
const isReconnecting =
connection.phase === "connecting" || connection.phase === "reconnecting";
items.push({
id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
variant: connection.phase === "error" ? "error" : "warning",
icon: <WifiOffIcon />,
title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(connection)}`,
description:
connection.error ??
"Reconnect this environment before sending messages or running actions.",
actions: (
<>
<Button
size="xs"
disabled={isReconnecting}
onClick={() =>
void handleReconnectActiveEnvironment(
activeEnvironmentUnavailableState.environmentId,
)
}
>
{isReconnecting ? "Reconnecting..." : "Reconnect"}
</Button>
<Button
size="xs"
variant="outline"
onClick={() => void navigate({ to: "/settings/connections" })}
>
Connections
</Button>
</>
),
});
const updateRunning = serverUpdateState.status === "running";
const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null;
const environmentReconnecting =
unavailableConnection !== null &&
(unavailableConnection.phase === "connecting" ||
unavailableConnection.phase === "reconnecting");
// Reconnecting to a version-skewed server with no update in flight
// usually means the server is restarting mid-update and a refresh wiped
// the in-memory update state. Fold the reconnect and version banners
// into one calm line instead of stacking "Failed to connect" on
// "versions differ". A failed update never folds: its error and retry
// action must stay visible.
const reconnectingThroughVersionSkew =
serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null;
// While an update runs, transient connect blips are expected (the server
// restarts) and the update banner already shows progress. Hard failure
// phases still surface so the Reconnect action stays reachable.
const suppressUnavailableBanner = updateRunning && environmentReconnecting;
if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) {
if (reconnectingThroughVersionSkew) {
items.push({
id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
variant: "default",
icon: (
<span
className="size-1.5 animate-status-pulse rounded-full bg-foreground"
aria-hidden="true"
/>
),
title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`,
description: "It may be finishing an update. One moment.",
Comment thread
cursor[bot] marked this conversation as resolved.
});
} else {
items.push({
id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
variant: unavailableConnection.phase === "error" ? "error" : "warning",
icon: <WifiOffIcon />,
title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(unavailableConnection)}`,
description:
unavailableConnection.error ??
"Reconnect this environment before sending messages or running actions.",
actions: (
<>
<Button
size="xs"
disabled={environmentReconnecting}
onClick={() =>
void handleReconnectActiveEnvironment(
activeEnvironmentUnavailableState.environmentId,
)
}
>
{environmentReconnecting ? "Reconnecting..." : "Reconnect"}
</Button>
<Button
size="xs"
variant="outline"
onClick={() => void navigate({ to: "/settings/connections" })}
>
Connections
</Button>
</>
),
});
}
}
if (
serverUpdateEnvironmentId &&
!reconnectingThroughVersionSkew &&
Comment thread
cursor[bot] marked this conversation as resolved.
(serverUpdateState.status !== "idle" ||
(showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))
) {
const updateInProgress = serverUpdateState.status === "running";
const updateFailed = serverUpdateState.status === "failed";
items.push({
id: `server-version:${serverUpdateEnvironmentId}`,
variant: updateFailed ? "error" : "warning",
icon: <TriangleAlertIcon />,
variant: updateFailed ? "error" : updateInProgress ? "default" : "warning",
icon: updateInProgress ? (
<span
className="size-1.5 animate-status-pulse rounded-full bg-foreground"
aria-hidden="true"
/>
) : (
<TriangleAlertIcon />
),
title:
updateInProgress || updateFailed
? `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
Expand All @@ -1940,7 +1976,6 @@ function ChatViewContent(props: ChatViewProps) {
updateInProgress || updateFailed ? (
<ServerUpdateProgress
fromVersion={serverUpdateState.fromVersion}
serverLabel={versionMismatchServerLabel}
state={serverUpdateState}
/>
) : versionMismatch ? (
Expand Down
13 changes: 8 additions & 5 deletions apps/web/src/components/ServerUpdateAction.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,10 @@ describe("ServerUpdateAction", () => {
});

describe("ServerUpdateProgress", () => {
it("renders the chosen horizontal step rail without an animated spinner", () => {
it("renders the monochrome step rail with only the active step highlighted", () => {
const markup = renderToStaticMarkup(
<ServerUpdateProgress
fromVersion="0.0.30"
serverLabel="bb-1"
state={{
status: "running",
stage: "resuming",
Expand All @@ -119,16 +118,19 @@ describe("ServerUpdateProgress", () => {
expect(markup).toContain("0.0.31");
expect(markup).toContain("Download");
expect(markup).toContain("Install");
expect(markup).toContain("Resume");
expect(markup).toContain("Waiting for bb-1 to accept commands.");
expect(markup).toContain("Resuming");
// The wait state is monochrome and calm: no success/warning colors, no
// status sentence, one duty-cycled pulse on the active step.
expect(markup).not.toContain("text-success");
expect(markup).not.toContain("text-primary");
expect(markup).toContain("animate-status-pulse");
expect(markup).not.toContain("animate-spin");
});

it("keeps the failed stage visible with its retryable error", () => {
const markup = renderToStaticMarkup(
<ServerUpdateProgress
fromVersion="0.0.30"
serverLabel="bb-1"
state={{
status: "failed",
stage: "installing",
Expand All @@ -141,5 +143,6 @@ describe("ServerUpdateProgress", () => {

expect(markup).toContain('role="alert"');
expect(markup).toContain("The package could not be verified.");
expect(markup).not.toContain("animate-status-pulse");
});
});
96 changes: 42 additions & 54 deletions apps/web/src/components/ServerUpdateAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,91 +15,83 @@ import { Button } from "./ui/button";
import { toastManager } from "./ui/toast";

const UPDATE_STEPS = [
{ stage: "downloading", label: "Download" },
{ stage: "installing", label: "Install" },
{ stage: "resuming", label: "Resume" },
{ stage: "downloading", label: "Download", activeLabel: "Downloading" },
{ stage: "installing", label: "Install", activeLabel: "Installing" },
{ stage: "resuming", label: "Resume", activeLabel: "Resuming" },
] as const;
const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();

function updateFailureMessage(error: unknown): string {
return error instanceof Error ? error.message : "Server update failed.";
}

function updateStatusCopy(
state: Exclude<ServerUpdateState, { status: "idle" }>,
serverLabel: string,
): string {
if (state.status === "failed") {
return state.message;
}
switch (state.stage) {
case "downloading":
return "Downloading the matching server version.";
case "installing":
return `Installing and verifying t3@${state.targetVersion}.`;
case "resuming":
return `Waiting for ${serverLabel} to accept commands.`;
}
}

/**
* Monochrome step rail for an in-flight server update. The update is a
* wait, not a warning: done steps recede to gray, the active step is the
* only bright element and pulses. Failure keeps the rail but surfaces the
* error below it.
*/
export function ServerUpdateProgress({
fromVersion,
serverLabel,
state,
}: {
readonly fromVersion: string;
readonly serverLabel: string;
readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
}) {
const currentIndex = UPDATE_STEPS.findIndex(({ stage }) => stage === state.stage);

return (
<div className="mt-1 min-w-0 space-y-2.5">
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground/70">
{fromVersion} <span aria-hidden="true">→</span> {state.targetVersion}
</p>
<ol className="flex min-w-0 items-center" aria-label="Server update progress">
{UPDATE_STEPS.map((step, index) => {
const complete = index < currentIndex;
const current = index === currentIndex;
const failed = current && state.status === "failed";
const running = current && state.status === "running";
return (
<li
key={step.stage}
className={cn(
"flex min-w-0 items-center text-xs font-medium",
index < UPDATE_STEPS.length - 1 ? "flex-1" : null,
complete
? "text-success"
? "text-muted-foreground"
: failed
? "text-destructive"
: current
? "text-primary"
: "text-muted-foreground/60",
: running
? "animate-status-pulse text-foreground"
: "text-muted-foreground/50",
)}
aria-current={current && state.status === "running" ? "step" : undefined}
aria-current={running ? "step" : undefined}
>
<span
className={cn(
"grid size-4 shrink-0 place-items-center rounded-full border",
complete
? "border-success bg-success text-success-foreground"
: failed
? "border-destructive bg-destructive text-destructive-foreground"
: current
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/35",
)}
aria-hidden="true"
>
{complete ? <CheckIcon className="size-3" strokeWidth={3} /> : null}
</span>
<span className="ml-1.5 truncate">{step.label}</span>
{complete ? (
<CheckIcon
className="size-3.5 shrink-0 opacity-70"
strokeWidth={2.5}
aria-hidden="true"
/>
) : (
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
failed
? "bg-destructive"
: running
? "bg-foreground"
: "border border-muted-foreground/40",
)}
aria-hidden="true"
/>
)}
<span className="ml-1.5 truncate">{running ? step.activeLabel : step.label}</span>
{index < UPDATE_STEPS.length - 1 ? (
<span
className={cn(
"mx-2 h-px min-w-3 flex-1",
complete ? "bg-success/60" : "bg-border",
complete ? "bg-muted-foreground/40" : "bg-border",
)}
aria-hidden="true"
/>
Expand All @@ -108,15 +100,11 @@ export function ServerUpdateProgress({
);
})}
</ol>
<p
className={cn(
"text-xs",
state.status === "failed" ? "text-destructive" : "text-muted-foreground",
)}
role={state.status === "failed" ? "alert" : "status"}
>
{updateStatusCopy(state, serverLabel)}
</p>
{state.status === "failed" ? (
<p className="text-xs text-destructive" role="alert">
{state.message}
</p>
) : null}
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ComposerBannerStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const exitTransitionStyle = {

export interface ComposerBannerStackItem {
readonly id: string;
readonly variant: "error" | "info" | "success" | "warning";
readonly variant: "default" | "error" | "info" | "success" | "warning";
readonly icon: ReactNode;
readonly title: ReactNode;
readonly description?: ReactNode;
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1434,7 +1434,6 @@ function SavedBackendListRow({
<div className="max-w-md">
<ServerUpdateProgress
fromVersion={serverUpdateState.fromVersion}
serverLabel={`${environment.label} server`}
state={serverUpdateState}
/>
</div>
Expand Down Expand Up @@ -3011,7 +3010,6 @@ export function ConnectionsSettings() {
primaryServerUpdateState.status !== "idle" ? (
<ServerUpdateProgress
fromVersion={primaryServerUpdateState.fromVersion}
serverLabel={primaryEnvironment?.label ?? "this server"}
state={primaryServerUpdateState}
/>
) : primaryVersionMismatch ? (
Expand Down
Loading