a copy button on the console, and the failure case spelled out

a failure is something somebody PASTES — into a search, into a message, back to
whoever wrote the thing that broke. selecting text inside a scrolled box, on a
phone, is the point at which people screenshot an error instead, and a screenshot
of a stack trace helps nobody.

the button is pinned to the corner of the BOX and not scrolled with the text: one
that leaves the viewport when the transcript is long is missing exactly when the
transcript is worth copying. `pr-10` keeps the output from running underneath it,
and it carries its own dark background because it sits over the console rather
than beside it.

`copyToClipboard` from helpers, the same one CopyField uses, rather than reaching
for navigator.clipboard here.

the no-timer-on-failure rule was already the behaviour; the comment now says the
part that was only implied. the half-done case is the one that matters and it is
named: a clone that worked followed by an install that did not is a failure, `ok`
is false, and the transcript of BOTH stays. the only thing that clears it is
starting the next attempt — the one moment it stops being true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 01:00:13 +01:00
co-authored by Claude Opus 5
parent 91aae3e981
commit c268e8438a
2 changed files with 35 additions and 7 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '2026.08.29.12',
version: '2026.08.29.13',
platform: '>=1.0.0',
label: 'Fofos',
+34 -6
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { CircleAlert, CircleCheck, CircleDashed, DownloadCloud, Loader2, RefreshCw } from 'lucide-react';
import { Check, CircleAlert, CircleCheck, CircleDashed, Copy, DownloadCloud, Loader2, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
import { Button } from '@/components/ui/button';
import { readableError } from './errors';
import { useFlash } from './useFlash';
@@ -26,8 +27,16 @@ import { useFlash } from './useFlash';
* been read by the time it finishes, and left on screen it becomes furniture that outlives its own
* truth — still sitting there after the root has been changed underneath it.
*
* A failure is the opposite. It stays until something else happens, because it is the only copy of
* why, and a transcript that scrolled past is one somebody has to reproduce to read.
* A failure is the opposite. It gets no timer at all rather than a long one — the only thing that
* takes it off the screen is starting the next attempt, which is the one moment it stops being true.
* That includes the half-done case, which is the most important of the three: a clone that worked
* followed by an install that did not is a failure, `ok` is false, and the transcript of both stays.
*
* ── And it can be copied ──
*
* A failure is something somebody pastes — into a search, into a message, back to whoever wrote the
* thing that broke. Selecting text inside a scrolled box, on a phone, is the point at which people
* take a screenshot of an error message instead, and a screenshot of a stack trace helps nobody.
*/
type Status = {
@@ -53,6 +62,7 @@ export const WebsiteCheckout = ({ rootPath }: { rootPath: string }) => {
const [output, setOutput] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [showing, show] = useFlash();
const [copied, flashCopied] = useFlash(1500);
const load = useCallback(async () => {
try {
@@ -147,9 +157,27 @@ export const WebsiteCheckout = ({ rootPath }: { rootPath: string }) => {
rather than like a paragraph of the form. Scrolled, not truncated: the useful line of a
failure is usually the last one. */}
{output && (failed || showing) && (
<pre className="max-h-40 overflow-auto rounded-md border border-border bg-black p-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
<code className={failed ? 'text-red-400' : 'text-neutral-200'}>{output}</code>
</pre>
<div className="relative">
<pre className="max-h-40 overflow-auto rounded-md border border-border bg-black p-3 pr-10 font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
<code className={failed ? 'text-red-400' : 'text-neutral-200'}>{output}</code>
</pre>
{/* Pinned to the corner of the BOX, not scrolled with the text — a copy button that leaves
the viewport when the transcript is long is missing exactly when the transcript is worth
copying. `pr-10` above keeps the text out from under it. Its own dark background because
it sits over the output rather than beside it. */}
<button
type="button"
onClick={async () => {
if (await copyToClipboard(output)) flashCopied();
}}
aria-label="Copy this output"
title="Copy"
className="absolute top-1.5 right-1.5 rounded border border-border/60 bg-black/80 p-1.5 text-neutral-400 transition-colors hover:text-neutral-100"
>
{copied ? <Check className="size-3.5 text-success" /> : <Copy className="size-3.5" />}
</button>
</div>
)}
</div>
);