3 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 6d7788e48b Keep the TriggerShell wordmark visible on mobile
Only the icon-only nav links, username, and logout label needed to
collapse to fit a phone-width header - the brand text alone doesn't
push it over, so keep it always shown for identity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:40:37 +02:00
valknarandClaude Sonnet 5 88f561230e Fix header horizontal overflow on mobile
The nav bar's brand text, both nav-link labels, the username, and the
logout label were all always rendered, easily exceeding a phone-width
viewport since nothing could shrink or wrap. Collapse to icon-only
below the sm breakpoint (labels stay in the DOM via sr-only so they're
still announced to screen readers, just not painted) and truncate a
long username instead of letting it force overflow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:36:55 +02:00
valknarandClaude Sonnet 5 7269886f9e Render run output with xterm.js instead of plain text
Replaces the plain-text output <div> with a real xterm.js terminal
(@xterm/xterm + @xterm/addon-fit), so ANSI color/control codes from
scripts render as actual colors instead of raw escape characters.
stderr chunks are wrapped in ANSI red so failures stand out even from
tools that don't colorize their own output. Also sets FORCE_COLOR=1/
CLICOLOR_FORCE=1 as env defaults (real/script env still wins) since
scripts run without a real TTY and most tools auto-disable color
without one of these overrides.

Fixed a React Strict Mode bug found while testing: the initial log was
written to the terminal via a "write once" ref flag in the parent,
but Strict Mode's dev-only mount->cleanup->remount cycle creates a
fresh Terminal on the real mount, so that flag silently skipped
writing to the surviving instance - the terminal looked completely
blank until live output arrived. Fixed by writing initialData inside
the same effect that creates the Terminal, so it's correct by
construction regardless of how many times the effect runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:29:03 +02:00
6 changed files with 2685 additions and 4940 deletions
+2
View File
@@ -22,6 +22,8 @@
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.7.0",
"@hookform/resolvers": "^5.8.0", "@hookform/resolvers": "^5.8.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"argon2": "^0.45.1", "argon2": "^0.45.1",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
+2548 -4893
View File
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -29,11 +29,11 @@ export function Nav({
return ( return (
<header className="border-b bg-background/95 sticky top-0 z-10 backdrop-blur"> <header className="border-b bg-background/95 sticky top-0 z-10 backdrop-blur">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4"> <div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-2 px-4">
<div className="flex items-center gap-6"> <div className="flex min-w-0 items-center gap-3 sm:gap-6">
<Link <Link
href="/" href="/"
className="flex items-center gap-2 font-semibold tracking-tight" className="flex shrink-0 items-center gap-2 font-semibold tracking-tight"
> >
<Terminal className="size-5" /> <Terminal className="size-5" />
TriggerShell TriggerShell
@@ -44,22 +44,24 @@ export function Nav({
key={href} key={href}
href={href} href={href}
className={cn( className={cn(
"text-muted-foreground hover:text-foreground flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors", "text-muted-foreground hover:text-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium transition-colors sm:px-3",
pathname === href && "bg-muted text-foreground", pathname === href && "bg-muted text-foreground",
)} )}
> >
<Icon className="size-4" /> <Icon className="size-4" />
{label} <span className="sr-only sm:not-sr-only">{label}</span>
</Link> </Link>
))} ))}
</nav> </nav>
</div> </div>
{authEnabled && ( {authEnabled && (
<div className="flex items-center gap-3"> <div className="flex shrink-0 items-center gap-2 sm:gap-3">
<span className="text-muted-foreground text-sm">{username}</span> <span className="text-muted-foreground hidden max-w-32 truncate text-sm sm:inline-block">
{username}
</span>
<Button variant="ghost" size="sm" onClick={handleLogout}> <Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="size-4" /> <LogOut className="size-4" />
Log out <span className="sr-only sm:not-sr-only">Log out</span>
</Button> </Button>
</div> </div>
)} )}
+13 -38
View File
@@ -1,14 +1,14 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useRef, useState } from "react";
import { Square } from "lucide-react"; import { Square } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useRunSocket } from "@/hooks/use-run-socket"; import { useRunSocket } from "@/hooks/use-run-socket";
import type { RunStatus } from "@/lib/db/schema"; import type { RunStatus } from "@/lib/db/schema";
import type { ServerMessage } from "@/lib/ws/protocol"; import type { ServerMessage } from "@/lib/ws/protocol";
import { RunStatusBadge } from "./run-status-badge"; import { RunStatusBadge } from "./run-status-badge";
import { XtermView, type XtermViewHandle } from "./xterm-view";
interface RunTerminalProps { interface RunTerminalProps {
runId: string; runId: string;
@@ -17,11 +17,8 @@ interface RunTerminalProps {
initialExitCode: number | null; initialExitCode: number | null;
} }
interface Line { const ANSI_RED = "\x1b[31m";
key: number; const ANSI_RESET = "\x1b[0m";
stream: "stdout" | "stderr";
text: string;
}
export function RunTerminal({ export function RunTerminal({
runId, runId,
@@ -31,18 +28,17 @@ export function RunTerminal({
}: RunTerminalProps) { }: RunTerminalProps) {
const [status, setStatus] = useState<RunStatus>(initialStatus); const [status, setStatus] = useState<RunStatus>(initialStatus);
const [exitCode, setExitCode] = useState<number | null>(initialExitCode); const [exitCode, setExitCode] = useState<number | null>(initialExitCode);
const [lines, setLines] = useState<Line[]>(() => const termRef = useRef<XtermViewHandle>(null);
initialLog ? [{ key: -1, stream: "stdout", text: initialLog }] : [],
);
const bottomRef = useRef<HTMLDivElement>(null);
const nextKey = useRef(0);
const { cancel } = useRunSocket(runId, (message: ServerMessage) => { const { cancel } = useRunSocket(runId, (message: ServerMessage) => {
if (message.type === "output") { if (message.type === "output") {
setLines((prev) => [ // Scripts that colorize their own output (via ANSI codes) render as-is; stderr additionally
...prev, // gets wrapped in red so failures stand out even from tools that don't colorize themselves.
{ key: nextKey.current++, stream: message.stream, text: message.chunk }, const chunk =
]); message.stream === "stderr"
? `${ANSI_RED}${message.chunk}${ANSI_RESET}`
: message.chunk;
termRef.current?.write(chunk);
} else if (message.type === "status") { } else if (message.type === "status") {
setStatus(message.status); setStatus(message.status);
setExitCode(message.exitCode ?? null); setExitCode(message.exitCode ?? null);
@@ -54,10 +50,6 @@ export function RunTerminal({
} }
}); });
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [lines]);
const isActive = status === "queued" || status === "running"; const isActive = status === "queued" || status === "running";
async function handleCancel() { async function handleCancel() {
@@ -85,24 +77,7 @@ export function RunTerminal({
</Button> </Button>
)} )}
</div> </div>
<div className="max-h-[60vh] overflow-y-auto rounded-lg bg-zinc-950 p-4 font-mono text-xs leading-relaxed text-zinc-100"> <XtermView ref={termRef} initialData={initialLog} />
{lines.length === 0 ? (
<span className="text-zinc-500">Waiting for output...</span>
) : (
lines.map((line) => (
<div
key={line.key}
className={cn(
"whitespace-pre-wrap break-all",
line.stream === "stderr" && "text-red-400",
)}
>
{line.text}
</div>
))
)}
<div ref={bottomRef} />
</div>
</div> </div>
); );
} }
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
export interface XtermViewHandle {
write: (data: string) => void;
}
const theme = {
background: "#09090b",
foreground: "#f4f4f5",
cursor: "#f4f4f5",
selectionBackground: "#3f3f46",
black: "#18181b",
red: "#f87171",
green: "#4ade80",
yellow: "#facc15",
blue: "#60a5fa",
magenta: "#c084fc",
cyan: "#22d3ee",
white: "#e4e4e7",
brightBlack: "#3f3f46",
brightRed: "#fca5a5",
brightGreen: "#86efac",
brightYellow: "#fde047",
brightBlue: "#93c5fd",
brightMagenta: "#d8b4fe",
brightCyan: "#67e8f9",
brightWhite: "#f4f4f5",
};
interface XtermViewProps {
/** Written into the terminal as soon as it's created. Only read once, on mount. */
initialData?: string;
}
/** A read-only xterm.js instance: interprets ANSI colors/control codes from streamed script
* output, unlike a plain <pre> which would just show raw escape characters.
*
* `initialData` is written inside the same effect that creates the Terminal - not via a
* ref call from the parent after the fact - specifically because React's Strict Mode (dev
* only) mounts, cleans up, and re-mounts every effect once: a fresh Terminal is created on
* each real mount, so a "write once" flag in the parent would write to the first (disposed)
* instance and silently skip the surviving one, leaving the terminal blank until the next
* live write arrives.
*/
export const XtermView = forwardRef<XtermViewHandle, XtermViewProps>(
function XtermView({ initialData }, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const term = new Terminal({
convertEol: true,
disableStdin: true,
cursorBlink: false,
fontSize: 12,
lineHeight: 1.4,
fontFamily: "ui-monospace, Menlo, monospace",
theme,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(containerRef.current);
fitAddon.fit();
if (initialData) term.write(initialData);
termRef.current = term;
const resizeObserver = new ResizeObserver(() => {
try {
fitAddon.fit();
} catch {
// container has zero size (e.g. mid-layout-shift) - next resize will retry
}
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
term.dispose();
termRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is intentionally only used on mount
}, []);
useImperativeHandle(ref, () => ({
write: (data: string) => termRef.current?.write(data),
}));
return (
<div
ref={containerRef}
className="h-[60vh] overflow-hidden rounded-lg bg-zinc-950 p-2"
/>
);
},
);
+10 -1
View File
@@ -107,7 +107,16 @@ async function executeRun(
try { try {
const subprocess = execa(script.command, invocation.argv, { const subprocess = execa(script.command, invocation.argv, {
cwd, cwd,
env: { ...process.env, ...invocation.env }, // Scripts run without a real TTY, so most CLI tools auto-disable color; these are the two
// most widely honored override conventions (not universal - true TTY-detecting tools like
// GNU coreutils' `--color=auto` still won't colorize without a pty). Real env/script-set
// env still win, so this is only a default.
env: {
FORCE_COLOR: "1",
CLICOLOR_FORCE: "1",
...process.env,
...invocation.env,
},
timeout: script.timeoutSeconds * 1000, timeout: script.timeoutSeconds * 1000,
cancelSignal: controller.signal, cancelSignal: controller.signal,
reject: false, reject: false,