8 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 14158047ac Make the README link in docs/API.md absolute
Release / release (push) Successful in 1m6s
The docs viewer serves docs/*.md at /docs/<slug> but doesn't ship
README.md as a route, so the relative ../README.md link resolved to
a dead /README.md path in the rendered web UI. Point it at the file's
Gitea URL instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 10:01:00 +02:00
valknarandClaude Sonnet 5 e5fb28ef31 Widen the doc detail view to max-w-5xl
Matches the width already used on the run and new-run cards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 09:57:19 +02:00
valknarandClaude Sonnet 5 e7b2b9add2 Fix docs pages overflowing horizontally on mobile
Release / release (push) Successful in 1m36s
GFM tables (config reference's Field/Type/Default/Notes tables) are
wider than a phone screen and don't wrap, so without their own scroll
container they forced the whole page to scroll horizontally instead.
Wrap rendered tables in an overflow-x-auto div, and let long unbroken
strings in table cells and inline code (env var names, paths) break
instead of forcing extra width.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 09:23:31 +02:00
valknarandClaude Sonnet 5 5574ffd1c8 Add a minimal 404 page matching the app's style
Two boundaries share the same content: src/app/not-found.tsx catches
genuinely unmatched URLs (rendered bare in the root layout), and
(app)/not-found.tsx catches notFound() calls from within app routes
(already used by scripts/[scriptId] and runs/[runId]) so it renders
nested inside AppLayout, keeping nav and footer visible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 03:12:19 +02:00
valknarandClaude Sonnet 5 adeb21be19 Fix Re-run button squeezing the run metadata row in the header
CardAction's default row-span-2 reserved header column 2 across both
the title row and the metadata dl row below it, shrinking the dl's
available width (cramping the Run ID column) to fit around the
button. Scope the button to just the title row and let the dl span
the full header width on its own row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 03:05:55 +02:00
valknarandClaude Sonnet 5 335e7624b4 Quote variable values with spaces in the displayed run command line
Values containing spaces (e.g. an env-passed SCENE="Glitz and glam")
rendered as bare, space-separated words in the run detail view,
indistinguishable from separate argv/env entries. Now anything outside
a safe bareword character set is quoted and escaped for display only -
actual execution is unaffected, since values are always passed as
discrete argv elements/env vars, never through a shell.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 03:01:53 +02:00
valknarandClaude Sonnet 5 23a4e2ebc0 Widen run and new-run cards to max-w-5xl with a multi-column form layout
The narrower max-w-2xl card left a lot of unused width on scripts with
several variables, forcing a long single-column scroll. Widening the
card and laying out variable inputs in a responsive grid (up to 3
columns) uses that space; textareas and checkbox groups still span the
full width since they don't shrink well into a column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:37:11 +02:00
valknar b77041cfa8 Size the run terminal by aspect ratio instead of viewport height
h-[60vh] made the terminal's height track the viewport regardless of
its actual width, so it read as too tall on narrower layouts.
aspect-video keeps it at 16/9 relative to its own width instead.
2026-08-16 18:15:17 +02:00
11 changed files with 102 additions and 15 deletions
+2 -2
View File
@@ -7,8 +7,8 @@ When `auth.enabled: true`, every endpoint below except `/api/healthz`, `/api/aut
header using a token from `triggershell users add-token`. header using a token from `triggershell users add-token`.
`triggershell run <scriptId>` is a first-party client of this exact REST + WS contract (see `triggershell run <scriptId>` is a first-party client of this exact REST + WS contract (see
[Running scripts from the CLI](../README.md#running-scripts-from-the-cli)) - nothing below is [Running scripts from the CLI](https://dev.pivoine.art/valknar/triggershell/src/branch/main/README.md#running-scripts-from-the-cli)) -
CLI-specific. nothing below is CLI-specific.
## Auth ## Auth
+1 -1
View File
@@ -27,7 +27,7 @@ export default async function DocPage({ params }: DocPageProps) {
if (content === null) notFound(); if (content === null) notFound();
return ( return (
<div className="mx-auto flex max-w-3xl flex-col gap-6"> <div className="mx-auto flex max-w-5xl flex-col gap-6">
<Link <Link
href="/docs" href="/docs"
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-sm" className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-sm"
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundContent } from "@/components/layout/not-found-content";
export default function NotFound() {
return <NotFoundContent />;
}
+4 -4
View File
@@ -66,12 +66,12 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
})); }));
return ( return (
<div className="mx-auto flex max-w-2xl flex-col gap-4"> <div className="mx-auto flex max-w-5xl flex-col gap-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{run.scriptName}</CardTitle> <CardTitle>{run.scriptName}</CardTitle>
{script && ( {script && (
<CardAction> <CardAction className="row-span-1">
<Link <Link
href={`/scripts/${run.scriptId}?fromRun=${run.id}`} href={`/scripts/${run.scriptId}?fromRun=${run.id}`}
className={buttonVariants({ variant: "outline", size: "sm" })} className={buttonVariants({ variant: "outline", size: "sm" })}
@@ -81,7 +81,7 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
</Link> </Link>
</CardAction> </CardAction>
)} )}
<dl className="text-muted-foreground grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3"> <dl className="text-muted-foreground col-span-2 grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
<div> <div>
<dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase"> <dt className="font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Triggered by Triggered by
@@ -119,7 +119,7 @@ export default async function RunDetailPage({ params }: RunDetailPageProps) {
<span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase"> <span className="text-muted-foreground font-mono text-[0.7rem] font-medium tracking-widest uppercase">
Variables Variables
</span> </span>
<dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2"> <dl className="grid gap-x-6 gap-y-2 rounded-md border p-3 text-xs sm:grid-cols-2 lg:grid-cols-3">
{variableEntries.map(({ key, label, value }) => ( {variableEntries.map(({ key, label, value }) => (
<div key={key} className="flex flex-col gap-0.5"> <div key={key} className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">{label}</dt> <dt className="text-muted-foreground">{label}</dt>
+1 -1
View File
@@ -55,7 +55,7 @@ export default async function ScriptPage({
} }
return ( return (
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-5xl">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{script.name}</CardTitle> <CardTitle>{script.name}</CardTitle>
+5
View File
@@ -0,0 +1,5 @@
import { NotFoundContent } from "@/components/layout/not-found-content";
export default function NotFound() {
return <NotFoundContent />;
}
+19 -1
View File
@@ -2,6 +2,19 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// GFM tables (the config reference's Field/Type/Default/Notes tables) are wider than a phone
// screen and don't wrap - without their own scroll container the table forces the whole page
// to scroll horizontally instead. The typography plugin's table styles still apply to `table`
// here (its selectors match any descendant, not just direct children of `.prose`), so this
// wrapper only adds the scroll boundary.
function Table(props: React.ComponentProps<"table">) {
return (
<div className="overflow-x-auto">
<table {...props} />
</div>
);
}
export function MarkdownViewer({ content }: { content: string }) { export function MarkdownViewer({ content }: { content: string }) {
return ( return (
<div <div
@@ -13,9 +26,14 @@ export function MarkdownViewer({ content }: { content: string }) {
// elsewhere in the app (see the "no scripts configured" message on the dashboard). // elsewhere in the app (see the "no scripts configured" message on the dashboard).
"prose-code:before:content-none prose-code:after:content-none", "prose-code:before:content-none prose-code:after:content-none",
"prose-code:rounded prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:font-normal prose-code:text-foreground", "prose-code:rounded prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:font-normal prose-code:text-foreground",
// Long unbroken strings (env var names, paths) in table cells or inline code would
// otherwise force their column/line wider than the viewport instead of wrapping.
"prose-td:break-words prose-th:break-words prose-code:break-words",
)} )}
> >
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown> <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ table: Table }}>
{content}
</ReactMarkdown>
</div> </div>
); );
} }
+19 -1
View File
@@ -14,6 +14,11 @@ import { defaultValuesForScript } from "@/lib/config/defaults";
import type { ClientScript } from "@/lib/config/serialize"; import type { ClientScript } from "@/lib/config/serialize";
import { FieldRenderer } from "./field-renderer"; import { FieldRenderer } from "./field-renderer";
/** Controls whose content doesn't shrink well into a narrow grid column - long-form text,
* or a group of checkboxes that reads better as a single wide list - so they span the full
* grid width instead of sharing a row with other fields. */
const WIDE_CONTROLS = new Set(["textarea", "checkboxGroup"]);
/** `initialValues` comes from a previous run's (already-redacted) variables when re-running - /** `initialValues` comes from a previous run's (already-redacted) variables when re-running -
* secret fields are deliberately excluded there (their stored value is just "***", not the real * secret fields are deliberately excluded there (their stored value is just "***", not the real
* one), so those always fall through to the normal default/empty state and have to be re-entered. */ * one), so those always fall through to the normal default/empty state and have to be re-entered. */
@@ -93,9 +98,22 @@ export function DynamicForm({
This script takes no parameters. This script takes no parameters.
</p> </p>
)} )}
{script.variables.length > 0 && (
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
{script.variables.map((variable) => ( {script.variables.map((variable) => (
<FieldRenderer key={variable.name} variable={variable} /> <div
key={variable.name}
className={
WIDE_CONTROLS.has(variable.control)
? "sm:col-span-2 lg:col-span-3"
: undefined
}
>
<FieldRenderer variable={variable} />
</div>
))} ))}
</div>
)}
<Button <Button
type="submit" type="submit"
disabled={form.formState.isSubmitting} disabled={form.formState.isSubmitting}
@@ -0,0 +1,29 @@
import Link from "next/link";
import { Home } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function NotFoundContent() {
return (
<div className="mx-auto flex max-w-md flex-col items-center gap-3 py-24 text-center">
<span className="text-primary font-mono text-6xl font-semibold tracking-tight">
404
</span>
<h1 className="font-heading text-xl font-medium">Page not found</h1>
<p className="text-muted-foreground text-sm">
The page you&rsquo;re looking for doesn&rsquo;t exist or may have been
moved.
</p>
<Link
href="/"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"mt-2",
)}
>
<Home className="size-3.5" />
Back to dashboard
</Link>
</div>
);
}
+1 -1
View File
@@ -95,7 +95,7 @@ export const XtermView = forwardRef<XtermViewHandle, XtermViewProps>(
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="h-[60vh] overflow-hidden bg-[#101215] p-2" className="aspect-video w-full overflow-hidden bg-[#101215] p-2"
/> />
); );
}, },
+14 -2
View File
@@ -18,6 +18,15 @@ function stringifyValue(value: unknown, joinWith: string): string {
return String(value); return String(value);
} }
/** Quotes a value for the human-readable `redactedCommandLine` display only - the real
* invocation always passes values as discrete argv elements/env vars (see build note below),
* so this never affects execution. Without it, a value like "Glitz and glam" renders as three
* bare words indistinguishable from separate argv entries. */
function quoteForDisplay(value: string): string {
if (value !== "" && /^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value;
return `"${value.replace(/([$`"\\])/g, "\\$1")}"`;
}
/** Builds an argv-array invocation from validated variable values. Never produces a shell string. */ /** Builds an argv-array invocation from validated variable values. Never produces a shell string. */
export function buildInvocation( export function buildInvocation(
script: ScriptConfig, script: ScriptConfig,
@@ -43,7 +52,10 @@ export function buildInvocation(
const argName = variable.argName!; const argName = variable.argName!;
const value = stringifyValue(raw, variable.joinWith); const value = stringifyValue(raw, variable.joinWith);
argv.push(argName, value); argv.push(argName, value);
redactedArgv.push(argName, variable.secret ? REDACTED : value); redactedArgv.push(
argName,
variable.secret ? REDACTED : quoteForDisplay(value),
);
break; break;
} }
case "flag": { case "flag": {
@@ -57,7 +69,7 @@ export function buildInvocation(
const value = stringifyValue(raw, variable.joinWith); const value = stringifyValue(raw, variable.joinWith);
env[variable.envName!] = value; env[variable.envName!] = value;
redactedEnvAssignments.push( redactedEnvAssignments.push(
`${variable.envName}=${variable.secret ? REDACTED : value}`, `${variable.envName}=${variable.secret ? REDACTED : quoteForDisplay(value)}`,
); );
break; break;
} }