Skip to content

Commit 333caab

Browse files
feat(preview): viewers show the host setup command, ready to copy
When the server's browser needs host setup, web shows the exact command in a copyable command block with Try again, and mobile shows it with a copy action. The onboarding wizard's command block moves to a shared component for this. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent ebab39f commit 333caab

7 files changed

Lines changed: 97 additions & 52 deletions

File tree

‎apps/mobile/src/features/browser/PreviewStreamWebView.tsx‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import previewStreamScript from "@t3tools/mobile-preview-stream";
22
import {
33
previewStreamControlLabel,
4+
previewStreamHostSetupMessage,
45
type PreviewStreamControl,
56
type PreviewStreamDownload,
67
type PreviewStreamFileChooser,
78
type PreviewStreamInput,
89
} from "@t3tools/client-runtime/preview/server-browser-stream";
9-
import type { EnvironmentId } from "@t3tools/contracts";
10+
import type { EnvironmentId, PreviewStreamHostSetup } from "@t3tools/contracts";
1011
import {
1112
useEffect,
1213
useEffectEvent,
@@ -252,6 +253,7 @@ function PreviewStreamDocumentView({
252253
const [status, setStatus] = useState<"connecting" | "streaming" | "error">("connecting");
253254
const [error, setError] = useState<string | null>(null);
254255
const [gone, setGone] = useState(false);
256+
const [hostSetup, setHostSetup] = useState<PreviewStreamHostSetup | null>(null);
255257
const [started, setStarted] = useState(false);
256258
const [control, setControl] = useState<PreviewStreamControl | null>(null);
257259
const [promptText, setPromptText] = useState("");
@@ -384,6 +386,10 @@ function PreviewStreamDocumentView({
384386
fail("This tab was closed.");
385387
onGone?.();
386388
return;
389+
case "hostSetup":
390+
setHostSetup({ need: message.need, command: message.command });
391+
fail(previewStreamHostSetupMessage(message));
392+
return;
387393
case "viewport":
388394
onViewport?.(message);
389395
return;
@@ -495,13 +501,30 @@ function PreviewStreamDocumentView({
495501
{status === "error" ? error : "Connecting to browser..."}
496502
</AppText>
497503
)}
504+
{status === "error" && hostSetup && !compact ? (
505+
<Pressable
506+
accessibilityRole="button"
507+
accessibilityLabel={`Copy ${hostSetup.command}`}
508+
className="w-full flex-row items-center gap-2 rounded-xl border border-secondary-border bg-secondary px-4 py-3"
509+
onPress={() => {
510+
void Clipboard.setStringAsync(hostSetup.command).catch(() => undefined);
511+
}}
512+
>
513+
<AppText selectable className="flex-1 font-mono text-sm text-secondary-foreground">
514+
{hostSetup.command}
515+
</AppText>
516+
<AppText className="text-xs text-foreground-muted">Copy</AppText>
517+
</Pressable>
518+
) : null}
498519
{status === "error" && !gone && !compact ? (
499520
<Pressable
500521
accessibilityRole="button"
501522
className="rounded-full border border-secondary-border bg-secondary px-6 py-3"
502523
onPress={onRetry}
503524
>
504-
<AppText className="text-secondary-foreground">Reconnect</AppText>
525+
<AppText className="text-secondary-foreground">
526+
{hostSetup ? "Try again" : "Reconnect"}
527+
</AppText>
505528
</Pressable>
506529
) : null}
507530
</View>

‎apps/mobile/src/features/browser/preview-stream-document.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access";
2+
import type { PreviewStreamHostSetup } from "@t3tools/contracts";
23
import type {
34
PreviewStreamControl,
45
PreviewStreamDownload,
@@ -24,6 +25,7 @@ export type PreviewStreamMessage =
2425
}
2526
| { readonly type: "unauthorized" }
2627
| { readonly type: "gone" }
28+
| ({ readonly type: "hostSetup" } & PreviewStreamHostSetup)
2729
| { readonly type: "viewport"; readonly width: number; readonly height: number }
2830
| { readonly type: "clipboard"; readonly text: string }
2931
| ({ readonly type: "download" } & PreviewStreamDownload)

‎apps/mobile/src/features/browser/preview-stream.browser.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {
22
createPreviewFramePainter,
33
createPreviewStreamClient,
4-
previewStreamHostSetupMessage,
54
previewStreamModifiers,
65
type PreviewStreamClient,
76
type PreviewStreamControl,
@@ -234,8 +233,7 @@ export function start(configuration: PreviewStreamConfiguration) {
234233
},
235234
onUnauthorized: () => post({ type: "unauthorized" }),
236235
onGone: () => post({ type: "gone" }),
237-
onHostSetup: (setup) =>
238-
post({ type: "status", status: "error", detail: previewStreamHostSetupMessage(setup) }),
236+
onHostSetup: (setup) => post({ type: "hostSetup", ...setup }),
239237
},
240238
);
241239
client = next;

‎apps/web/src/browser/ServerBrowserSurface.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from "react";
3636

3737
import { AgentCursorMark } from "~/components/preview/AgentBrowserCursor";
38+
import { CommandBlock } from "~/components/CommandBlock";
3839
import { Button } from "~/components/ui/button";
3940
import { Input } from "~/components/ui/input";
4041
import { toastManager } from "~/components/ui/toast";
@@ -868,10 +869,11 @@ export function ServerBrowserSurface(props: {
868869
) : null}
869870
</div>
870871
{visible && hostSetup ? (
871-
<div className="visible absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-background p-3 text-center">
872-
<p role="alert" className="max-w-sm text-xs text-muted-foreground">
872+
<div className="visible absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-background p-4 text-center">
873+
<p role="alert" className="max-w-sm text-sm text-muted-foreground">
873874
{previewStreamHostSetupMessage(hostSetup)}
874875
</p>
876+
<CommandBlock command={hostSetup.command} className="w-full max-w-md text-left" />
875877
<Button variant="outline" size="sm" onClick={() => setHostSetup(null)}>
876878
Try again
877879
</Button>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Check, Copy } from "lucide";
2+
3+
import { MorphIcon } from "~/components/MorphIcon";
4+
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
5+
import { cn } from "~/lib/utils";
6+
7+
import { Button } from "./ui/button";
8+
9+
/** A shell command to copy and run elsewhere, such as on the environment's host. */
10+
export function CommandBlock({
11+
command,
12+
className,
13+
prominent = false,
14+
}: {
15+
readonly command: string;
16+
readonly className?: string;
17+
readonly prominent?: boolean;
18+
}) {
19+
const { copyToClipboard, isCopied } = useCopyToClipboard({
20+
timeout: 1500,
21+
target: "command",
22+
});
23+
return (
24+
<div
25+
className={cn(
26+
"flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 font-mono",
27+
prominent ? "px-4 py-3.5 text-base" : "px-3 py-2.5 text-sm",
28+
className,
29+
)}
30+
>
31+
<span className="min-w-0 truncate">
32+
<span className="mr-2 text-muted-foreground">$</span>
33+
{command}
34+
</span>
35+
<Button
36+
size="icon-xs"
37+
variant="ghost"
38+
aria-label="Copy command"
39+
onClick={() => copyToClipboard(command, undefined)}
40+
>
41+
<MorphIcon className="size-3.5" icon={isCopied ? Check : Copy} />
42+
</Button>
43+
</div>
44+
);
45+
}

‎apps/web/src/components/onboarding/WelcomeWizard.tsx‎

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import {
3030
MonitorIcon,
3131
TerminalIcon,
3232
} from "lucide-react";
33-
import { Check, Copy } from "lucide";
3433
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3534

3635
import { TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../../appearanceFonts";
@@ -52,7 +51,6 @@ import {
5251
resolveOnboardingProviderLoginCommand,
5352
selectOnboardingProvidersByDriver,
5453
} from "../../onboarding/providerReadiness.logic";
55-
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
5654
import { newProjectId, randomUUID } from "../../lib/utils";
5755
import { agentSessionImport } from "../../state/agentSessions";
5856
import { readProjects, useProjects } from "../../state/entities";
@@ -76,8 +74,8 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
7674
import { T3Wordmark } from "../T3Wordmark";
7775
import { Alert, AlertDescription } from "../ui/alert";
7876
import { Button } from "../ui/button";
77+
import { CommandBlock } from "../CommandBlock";
7978
import { Checkbox } from "../ui/checkbox";
80-
import { MorphIcon } from "~/components/MorphIcon";
8179
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
8280
import { Input } from "../ui/input";
8381
import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
@@ -1713,40 +1711,3 @@ function StepShell({
17131711
</>
17141712
);
17151713
}
1716-
1717-
function CommandBlock({
1718-
command,
1719-
className,
1720-
prominent = false,
1721-
}: {
1722-
readonly command: string;
1723-
readonly className?: string;
1724-
readonly prominent?: boolean;
1725-
}) {
1726-
const { copyToClipboard, isCopied } = useCopyToClipboard({
1727-
timeout: 1500,
1728-
target: "command",
1729-
});
1730-
return (
1731-
<div
1732-
className={cn(
1733-
"flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 font-mono",
1734-
prominent ? "px-4 py-3.5 text-base" : "px-3 py-2.5 text-sm",
1735-
className,
1736-
)}
1737-
>
1738-
<span className="min-w-0 truncate">
1739-
<span className="mr-2 text-muted-foreground">$</span>
1740-
{command}
1741-
</span>
1742-
<Button
1743-
size="icon-xs"
1744-
variant="ghost"
1745-
aria-label="Copy command"
1746-
onClick={() => copyToClipboard(command, undefined)}
1747-
>
1748-
<MorphIcon className="size-3.5" icon={isCopied ? Check : Copy} />
1749-
</Button>
1750-
</div>
1751-
);
1752-
}

‎packages/client-runtime/src/preview/serverBrowserStream.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ export function createPreviewStreamClient(
368368
}
369369
if (event.code === PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE) {
370370
stopped = true;
371-
events.onHostSetup?.(event.reason === "libraries" ? "libraries" : "sandbox");
371+
events.onHostSetup?.(decodeHostSetup(event.reason));
372372
return;
373373
}
374374
// Rejected upgrades surface as 1006 before open for both cookies and tickets.
@@ -455,8 +455,22 @@ export function createPreviewFramePainter(
455455
};
456456
}
457457

458-
/** What a viewer tells the person when the server's browser needs host setup. */
458+
/** A malformed reason still stops the viewer and offers the usual command. */
459+
const decodeHostSetup = (reason: string): PreviewStreamHostSetup => {
460+
try {
461+
const value = JSON.parse(reason) as Partial<PreviewStreamHostSetup>;
462+
if (
463+
(value.need === "sandbox" || value.need === "libraries") &&
464+
typeof value.command === "string"
465+
) {
466+
return { need: value.need, command: value.command };
467+
}
468+
} catch {}
469+
return { need: "sandbox", command: "sudo npx t3 browser setup" };
470+
};
471+
472+
/** What a viewer tells the person; `command` is shown beside it, ready to copy. */
459473
export const previewStreamHostSetupMessage = (setup: PreviewStreamHostSetup) =>
460-
setup === "sandbox"
461-
? "This server's browser needs Chrome's sandbox, which the host blocks. Its operator can allow it with the AppArmor profile in T3 Code's remote access guide, or set T3CODE_SERVER_BROWSER_SANDBOX=0."
462-
: "This server's browser is missing system libraries. Its operator can install Chrome's libraries as described in T3 Code's remote access guide.";
474+
setup.need === "sandbox"
475+
? "This server's host blocks the sandbox its browser runs in. Run this once on the host, then try again:"
476+
: "This server's host is missing libraries its browser needs. Run this once on the host, then try again:";

0 commit comments

Comments
 (0)