Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
144 changes: 56 additions & 88 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1777,6 +1777,53 @@ export default function ThreadTerminalDrawer({
);
}

// The single-terminal row and the split sidebar header share these controls,
// so neither layout floats them over terminal text.
const terminalActions = (
<div className="inline-flex h-full items-stretch">
<TerminalActionButton
disabled={!canOperateTerminal}
className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
hasReachedSplitLimit
? "cursor-not-allowed opacity-64 hover:bg-transparent"
: "hover:bg-accent/70"
}`}
onClick={onSplitTerminalAction}
label={splitTerminalActionLabel}
>
<SquareSplitHorizontal className="size-3.25" />
</TerminalActionButton>
<TerminalActionButton
disabled={!canOperateTerminal}
className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
hasReachedSplitLimit
? "cursor-not-allowed opacity-64 hover:bg-transparent"
: "hover:bg-accent/70"
}`}
onClick={onSplitTerminalVerticalAction}
label={splitTerminalVerticalActionLabel}
>
<SquareSplitVertical className="size-3.25" />
</TerminalActionButton>
<TerminalActionButton
disabled={!canOperateTerminal}
className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
onClick={onNewTerminalAction}
label={newTerminalActionLabel}
>
<Plus className="size-3.25" />
</TerminalActionButton>
<TerminalActionButton
disabled={!canOperateTerminal}
className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
label={closeTerminalActionLabel}
>
<Trash2 className="size-3.25" />
</TerminalActionButton>
</div>
);

return (
<aside
ref={drawerRef}
Expand All @@ -1801,52 +1848,14 @@ export default function ThreadTerminalDrawer({
) : null}

{!hasTerminalSidebar && (
<div className="pointer-events-none absolute right-2 top-2 z-20">
<div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background shadow-xs">
<TerminalActionButton
disabled={!canOperateTerminal}
className={`p-1 text-foreground/90 transition-colors ${
hasReachedSplitLimit
? "cursor-not-allowed opacity-64 hover:bg-transparent"
: "hover:bg-accent"
}`}
onClick={onSplitTerminalAction}
label={splitTerminalActionLabel}
>
<SquareSplitHorizontal className="size-3.25" />
</TerminalActionButton>
<div className="h-4 w-px bg-border/80" />
<TerminalActionButton
disabled={!canOperateTerminal}
className={`p-1 text-foreground/90 transition-colors ${
hasReachedSplitLimit
? "cursor-not-allowed opacity-64 hover:bg-transparent"
: "hover:bg-accent"
}`}
onClick={onSplitTerminalVerticalAction}
label={splitTerminalVerticalActionLabel}
>
<SquareSplitVertical className="size-3.25" />
</TerminalActionButton>
<div className="h-4 w-px bg-border/80" />
<TerminalActionButton
disabled={!canOperateTerminal}
className="p-1 text-foreground/90 transition-colors hover:bg-accent"
onClick={onNewTerminalAction}
label={newTerminalActionLabel}
>
<Plus className="size-3.25" />
</TerminalActionButton>
<div className="h-4 w-px bg-border/80" />
<TerminalActionButton
disabled={!canOperateTerminal}
className="p-1 text-foreground/90 transition-colors hover:bg-accent"
onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
label={closeTerminalActionLabel}
>
<Trash2 className="size-3.25" />
</TerminalActionButton>
</div>
<div
className={cn(
"flex shrink-0 items-stretch justify-end border-b border-border/70",
// The drawer's resize strip covers its top 6px; keep the buttons below it.
isPanel ? "h-[22px]" : "h-7 pt-1.5",
)}
>
{terminalActions}
</div>
)}

Expand Down Expand Up @@ -1878,48 +1887,7 @@ export default function ThreadTerminalDrawer({
{hasTerminalSidebar && (
<aside className="flex w-36 min-w-36 flex-col border border-border/70 bg-muted/10">
<div className="flex h-[22px] items-stretch justify-end border-b border-border/70">
<div className="inline-flex h-full items-stretch">
<TerminalActionButton
disabled={!canOperateTerminal}
className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
hasReachedSplitLimit
? "cursor-not-allowed opacity-64 hover:bg-transparent"
: "hover:bg-accent/70"
}`}
onClick={onSplitTerminalAction}
label={splitTerminalActionLabel}
>
<SquareSplitHorizontal className="size-3.25" />
</TerminalActionButton>
<TerminalActionButton
disabled={!canOperateTerminal}
className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
hasReachedSplitLimit
? "cursor-not-allowed opacity-64 hover:bg-transparent"
: "hover:bg-accent/70"
}`}
onClick={onSplitTerminalVerticalAction}
label={splitTerminalVerticalActionLabel}
>
<SquareSplitVertical className="size-3.25" />
</TerminalActionButton>
<TerminalActionButton
disabled={!canOperateTerminal}
className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
onClick={onNewTerminalAction}
label={newTerminalActionLabel}
>
<Plus className="size-3.25" />
</TerminalActionButton>
<TerminalActionButton
disabled={!canOperateTerminal}
className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
label={closeTerminalActionLabel}
>
<Trash2 className="size-3.25" />
</TerminalActionButton>
</div>
{terminalActions}
</div>

<div className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
Expand Down
52 changes: 52 additions & 0 deletions apps/web/test/fixtures/terminal-layout/capture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export const frozenTime = Date.UTC(2026, 9, 21, 22, 30);

// Freeze wall-clock labels without changing animation or recording timing.
export function freezeClock() {
const RealDate = globalThis.__evidenceRealDate ?? Date;
globalThis.__evidenceRealDate = RealDate;
globalThis.Date = class extends RealDate {
constructor(...args) {
super(...(args.length ? args : [frozenTime]));
}
static now() {
return frozenTime;
}
};
}

export async function metadata(provenance) {
await document.fonts.ready;
const box = (selector) =>
[...document.querySelectorAll(selector)].map((el) => {
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
});
return {
...provenance,
userAgent: navigator.userAgent,
platform: navigator.platform,
locale: navigator.language,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
frozenTime: new Date().toISOString(),
devicePixelRatio,
viewport: { width: innerWidth, height: innerHeight },
visualViewportScale: visualViewport.scale,
rootFontSize: getComputedStyle(document.documentElement).fontSize,
rootClass: document.documentElement.className,
fonts: [...document.fonts]
.filter((f) => f.status === "loaded")
.map((f) => ({ family: f.family, style: f.style, weight: f.weight })),
terminal: box("[data-thread-terminal-drawer]"),
canvases: box("canvas"),
composer: box('[role="textbox"][contenteditable="true"]'),
scroll: [...document.querySelectorAll("*")]
.filter((e) => e.scrollTop || e.scrollLeft)
.map((e) => ({
tag: e.tagName,
className: e.className,
top: e.scrollTop,
left: e.scrollLeft,
})),
reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches,
};
}
85 changes: 85 additions & 0 deletions apps/web/test/fixtures/terminal-layout/driver.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as NodeChildProcess from "node:child_process";
import * as NodeFSP from "node:fs/promises";
import * as NodePath from "node:path";

// The caller supplies the harness's T3 preview tool dispatcher, not Playwright.
export async function createDriver(
callPreview,
{ tabId, outputDir, repoDir, run, phase, sourceTreeSha },
) {
await NodeFSP.mkdir(outputDir, { recursive: true });
await NodeFSP.mkdir("/tmp/t3-terminal-evidence", { recursive: true });
const events = [];
const captures = [];
const started = Date.now();
const git = (...args) =>
NodeChildProcess.execFileSync("git", args, { cwd: repoDir, encoding: "utf8" }).trim();
const provenance = {
headSha: git("rev-parse", "HEAD"),
sourceTreeSha,
terminalBlob: git("hash-object", "apps/web/src/components/ThreadTerminalDrawer.tsx"),
workingTreeStatus: git("status", "--short"),
phase,
};
let milestones;
let pendingMilestones;
const decode = (result) =>
result.structuredContent ??
JSON.parse(result.content.find((item) => item.type === "text").text);
// Watch before the action that starts a shell, so fast prompts cannot race the watcher.
const arm = async () => {
await milestones?.return();
milestones = NodeFSP.watch("/tmp/t3-terminal-evidence");
pendingMilestones = milestones.next();
};
const call = async (name, args = {}) => {
if (name === "press" && args.key === "Enter") await arm();
const result = await callPreview(name, { ...args, tabId });
events.push({ run, ms: Date.now() - started, op: name, args, isError: !!result.isError });
if (result.isError) throw new Error(JSON.stringify(result));
return decode(result);
};
const evaluate = async (expression) => (await call("evaluate", { expression })).value;
const driver = {
call,
evaluate,
arm,
ready: async (pane) => {
if (!milestones) throw new Error(`Terminal readiness for ${pane} was not armed`);
for (;;) {
const event = await pendingMilestones;
if (event.done) throw new Error("Terminal readiness watcher ended");
if (event.value.filename === `ready-${pane}`) break;
pendingMilestones = milestones.next();
}
await milestones.return();
milestones = undefined;
pendingMilestones = undefined;
},
columns: async () =>
Number(
(await NodeFSP.readFile("/tmp/t3-terminal-evidence/input-size", "utf8"))
.trim()
.split(/\s+/)[1],
),
note: async (note, data) => events.push({ run, ms: Date.now() - started, note, data }),
checkpoint: async (checkpoint) => {
const meta = await evaluate(
`(async()=>{const m=await import('/test/fixtures/terminal-layout/capture.mjs');return m.metadata(${JSON.stringify(provenance)})})()`,
);
const snapshot = await call("snapshot", { save: true });
const target = NodePath.join(outputDir, `${run}-${checkpoint}.png`);
await NodeFSP.copyFile(snapshot.screenshotPath, target);
captures.push({ run, checkpoint, path: target, meta });
events.push({ run, ms: Date.now() - started, checkpoint, path: target });
},
finish: async () => {
await milestones?.return();
await NodeFSP.writeFile(
NodePath.join(outputDir, `${run}.json`),
JSON.stringify({ provenance, events, captures }, null, 2),
);
},
};
return driver;
}
38 changes: 38 additions & 0 deletions apps/web/test/fixtures/terminal-layout/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"frozenUtc": "2026-10-21T22:30:00.000Z",
"viewports": {
"W": [1440, 900],
"N": [390, 900]
},
"themes": ["light", "dark"],
"static": ["P1-W-L", "P1-W-D", "P1-N-L", "P1-N-D", "D1-W-L", "D1-W-D", "D1-N-L", "D1-N-D"],
"recordings": ["RP-W-D", "RP-N-L", "RD-W-D", "RD-N-L"],
"permissionUnavailable": ["Q1-N-L", "Q2-W-D"],
"shellCommand": "bash <checkout>/apps/web/test/fixtures/terminal-layout/shell.sh A",
"drawerHeight": 280,
"diff": {
"zero": {
"threshold": 0,
"gain": 8
},
"thresholded": {
"threshold": 8,
"gain": 8
},
"raw": {
"threshold": 0,
"gain": 1
}
},
"historical": {
"baselineApp": "70e5e384dab96a1d1e4a253ee5a9415ed5d8ce30",
"afterApp": "ec679087f9029cc9be36fc76e1f95e2ffb692dce"
},
"baselineApp": "b5a226e49c52ecab4ddc2d678a7012d97e651a6a",
"afterApp": "Resolve HEAD at capture time; includes finalized fixtures",
"terminalBlobs": {
"before": "0a12a6535c2d56325ef3193068b44c5ef08eb4b2",
"after": "9626c2640b7d20fc38c972ed6fd3af250a434c16"
},
"setup": "Pair an isolated dev server; createDriver dispatches T3 preview tools for one owned tab; setup creates a fresh draft and shell. Call replay with the returned threadRef/threadKey. Use typeFirstLine for pending-input captures. Stop the server before seed.mjs."
}
Loading
Loading