a background-job button in the chat composer

A `Radio` button between the harness and model selectors opens a small composer:
you write one sentence, the agent receives it under a preamble of mechanics that
never appears in your bubble.

── Four orthogonal choices, not modes ──

  who writes the command   a literal one, or an intent the agent works out
  is there a condition     always PROSE — a condition worth watching is a sentence
                           and worth arguing about before it arms, not a predicate
                           typed into a box
  does firing end it       exit-is-the-notification, or act-and-keep-going
  how long                 this session / N minutes / until stopped

Ticking the watcher forces prompt mode rather than allowing a combination that is
then rejected on submit.

── The rule that inverts, which is why the checkbox exists ──

"Stay silent while waiting" is right when output is DEFERRED to exit — it all
lands in context at once. For a watcher that never exits, output streams to the
pinned pane and never reaches context, so a line per event is the point rather
than a cost. The preamble says the opposite thing in each case; with one rule it
would have suppressed the only signal a persistent watcher gives.

── Hidden without hacks ──

`sendWithHiddenPrefix` reuses the display-text argument `sendPrompt` already
takes: the agent gets preamble + text, the transcript shows text. A NEW
`hiddenPrefix` field rather than the existing per-prompt `prefix`, which carries
attachment references — those are part of what you said and belong in the bubble.

── What it says out loud ──

"If what I asked for is not completely clear, ASK ME FIRST." A wrong condition
fails silently — never fires, or fires constantly — and is discovered days later.
Arming it late costs a minute.

And the duration note does not promise what it cannot keep. Traced the process
tree: the job is a great-grandchild of the claude process, so `pm2 restart
officer-claude-code` kills every watcher on the box. "Until stopped" therefore
reads "all of these end if the session does" rather than "forever". A job that
must outlive deploys belongs under PM2, not under a chat.

cwd comes from the panel, so nothing has to be typed — the dashboard rooting from
earlier today.

tsgo clean, frontend builds, 851 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 01:48:25 +00:00
co-authored by Claude Opus 5
parent 5c40d4423d
commit f0c93fa716
5 changed files with 386 additions and 1 deletions
@@ -12,6 +12,14 @@ export type QueuedPrompt = {
/** Exactly what was typed, untrimmed — what the tray shows and what gets delivered. */
raw: string;
prefix: string;
/**
* Instructions the agent receives but the transcript does not show.
*
* Distinct from `prefix`, which is attachment references — those are part of what you said and belong
* in the bubble. This is boilerplate you did not type: the background-job preamble, sent above your one
* sentence so the mechanics arrive without a wall of text in your own message.
*/
hiddenPrefix?: string;
ids: string[];
images: { filename: string; dataUrl: string }[];
};
@@ -179,7 +187,15 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
const ids = items.flatMap((item) => item.ids);
const images = items.flatMap((item) => item.images);
// Per-message hidden instructions, above the session-wide ones. Joined when a batch carries several,
// which only happens if two were queued together.
const hidden = items
.map((item) => item.hiddenPrefix)
.filter(Boolean)
.join('\n\n');
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text;
if (hidden) prompt = `${hidden}\n\n${prompt}`;
if (prefix) prompt = `${prefix}${prompt}`;
// Send the cwd on every message (not just the first): OpenCode rebuilds its working-directory
@@ -191,7 +207,9 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
cwd,
undefined,
thinkingLevel,
promptPrefix ? text : undefined,
// The display text. Given either kind of hidden instruction, the bubble shows only what the
// user actually wrote.
promptPrefix || hidden ? text : undefined,
);
userScrolledRef.current = false;
return true;
@@ -232,6 +250,32 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
await deliverBatch(batch);
};
/**
* Send a message with instructions the transcript will not show.
*
* Used by the background-job composer: you write one sentence, the agent receives it under a preamble
* of mechanics. Bypasses the input box entirely — there is nothing to type and nothing to clear.
*
* Queues behind a running turn like anything else, rather than being dropped or racing it.
*/
const sendWithHiddenPrefix = async (text: string, hiddenPrefix: string) => {
const body = text.trim();
if (!body) return;
const item: QueuedPrompt = {
id: `${Date.now()}-${queuedRef.current.length}`,
raw: body,
prefix: '',
hiddenPrefix,
ids: [],
images: [],
};
if (isGenerating) {
setQueue([...queuedRef.current, item]);
return;
}
await deliverBatch([item]);
};
const handleSend = async () => {
const text = input.trim();
if (!text) return;
@@ -503,6 +547,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
input,
setInput,
handleSend,
sendWithHiddenPrefix,
handleKeyDown,
handleEscape,
queued,
@@ -0,0 +1,111 @@
/**
* The preamble sent above a background-job request.
*
* The user writes one sentence; this arrives above it and never appears in their bubble (see
* `sendWithHiddenPrefix`). It exists because the mechanics below are load-bearing and easy to get
* subtly wrong in ways that produce a job which looks alive and informs nobody.
*
* Kept as one exported constant so moving it to editable settings later is an import change, not a
* refactor.
*/
export type BackgroundJobSpec = {
/** `command` runs verbatim; `prompt` is an intent the agent works out. A watcher is always `prompt`. */
mode: 'command' | 'prompt';
/** The literal command, or the intent. */
body: string;
/** Prose: what to wait for and what to do when it happens. Empty means no watcher. */
condition: string;
/** Whether firing ends the job. Ignored without a condition. */
stopOnFire: boolean;
/** `session` = until this chat ends. A number = minutes. `until-stopped` = until told otherwise. */
duration: 'session' | 'until-stopped' | number;
/** Where it runs. The panel's own directory, so nothing has to be typed. */
cwd: string;
};
const durationLine = (d: BackgroundJobSpec['duration']): string => {
if (d === 'session') return 'Run until this session ends. Do not bound the loop by time.';
if (d === 'until-stopped') return 'Run until I stop it. Do not bound the loop by time.';
return `Run for about ${d} minute${d === 1 ? '' : 's'}, then exit 1 to say it timed out.`;
};
export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
const watching = spec.condition.trim().length > 0;
// With a watcher the condition IS the intent, so the body is optional and omitted when empty —
// repeating "watch the repos" under a condition that already says it reads as two requirements.
const what = watching
? [`WATCH — in prose, not as a script:`, spec.condition.trim()]
.concat(spec.body.trim() ? ['', `Context:`, spec.body.trim()] : [])
.join('\n')
: spec.mode === 'command'
? [`COMMAND — run this verbatim:`, spec.body.trim()].join('\n')
: [`RUN — work out the command for this, then start it:`, spec.body.trim()].join('\n');
const rules: string[] = [
`1. Use your harness's background mechanism — in Claude Code, the Bash tool with`,
` \`run_in_background: true\`. Not \`nohup\`, not \`&\`, not a detached shell. An untracked`,
` process can die and tell nobody; it looks identical to a live one.`,
``,
`2. The wait happens in the SHELL. No scheduled wake-ups, no re-invoking yourself to check.`,
` Every model-side tick re-reads this conversation to learn nothing.`,
``,
`3. ${durationLine(spec.duration)}`,
];
if (spec.mode === 'command' && !watching) {
rules.push(
``,
`4. Do not modify, wrap, or add flags to the command. Do not explain it. Do nothing else.`,
` If it exits, that is a crash: report it with the tail of its output.`,
);
}
if (watching) {
rules.push(
``,
`4. Put the test INTO the shell condition, so firing already means it matters.`,
``,
`5. Prefer blocking to polling: \`inotifywait\`, \`tail --pid=N -f /dev/null\`, \`docker events\`.`,
` Poll only when the source can only be asked.`,
``,
`6. Probe read-only — \`git ls-remote\`, never \`git fetch\`. Mutate only AFTER it fires, and only`,
` as asked above. \`timeout\` every network call, or a hung one leaves it alive and blind.`,
``,
spec.stopOnFire
? [
`7. EXIT when it fires — the exit IS the notification. A loop that prints and continues has`,
` told no one. Stay silent until then: everything printed lands in context at once on exit.`,
` Report what fired. Re-arm only if I ask.`,
].join('\n')
: [
`7. Do NOT exit when it fires. Do what was asked, print ONE line saying what happened, and`,
` keep going. Here the stream is the notification, not the exit — the output is visible`,
` live, so a line per event is the point. Still print nothing on a tick where nothing`,
` happened, and never let one failure end the job: log it and continue.`,
].join('\n'),
);
}
return [
`Start a background job. The mechanics below are load-bearing; follow them exactly.`,
``,
what,
``,
`WORKING DIRECTORY: ${spec.cwd}`,
``,
`MECHANICS:`,
``,
...rules,
``,
`BEFORE YOU START:`,
``,
`If what I asked for is not completely clear — the condition, what counts as firing, what to do`,
`when it does — ASK ME FIRST and settle it in conversation. Do not guess and arm it. A wrong`,
`condition fails silently: it either never fires or fires constantly, and I find out days later.`,
`Arming it late costs a minute; arming it wrong costs whatever I was relying on it for.`,
``,
`When it is armed, tell me in one line what you started and how to stop it.`,
].join('\n');
}
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { Radio } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { buildBackgroundJobPrompt, type BackgroundJobSpec } from '../background-job-prompt';
// Spawning a background job.
//
// Four decisions, and they are orthogonal rather than modes:
//
// who writes the command a literal one, or an intent for the agent to work out
// is there a condition watching is always PROSE — a condition worth watching is a sentence,
// not a predicate, and it is worth arguing about before it arms
// does firing end it exit-is-the-notification, or act-and-keep-going
// how long this session / a while / until stopped
//
// Ticking the watcher forces prompt mode, because a scripted condition is the thing this deliberately
// does not offer.
type BackgroundJobDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
cwd: string;
onSubmit: (intent: string, preamble: string) => void;
};
type Duration = 'session' | 'until-stopped' | 'timed';
export const BackgroundJobDialog = ({ open, onOpenChange, cwd, onSubmit }: BackgroundJobDialogProps) => {
const [mode, setMode] = useState<'command' | 'prompt'>('command');
const [body, setBody] = useState('');
const [watching, setWatching] = useState(false);
const [condition, setCondition] = useState('');
const [stopOnFire, setStopOnFire] = useState(true);
const [duration, setDuration] = useState<Duration>('session');
const [minutes, setMinutes] = useState(60);
useEffect(() => {
if (!open) return;
setMode('command');
setBody('');
setWatching(false);
setCondition('');
setStopOnFire(true);
setDuration('session');
setMinutes(60);
}, [open]);
// A watcher is always prompted. Flipping the checkbox flips the mode rather than leaving an
// impossible combination selected and rejecting it on submit.
const toggleWatching = (next: boolean) => {
setWatching(next);
if (next) setMode('prompt');
};
// Watching needs a condition; the body is then optional context. Without a watcher the body IS
// the job, so it is required.
const canSubmit = watching ? condition.trim().length > 0 : body.trim().length > 0;
const submit = () => {
if (!canSubmit) return;
const spec: BackgroundJobSpec = {
mode,
body,
condition: watching ? condition : '',
stopOnFire,
duration: duration === 'timed' ? minutes : duration,
cwd,
};
// The intent is what the transcript shows; the preamble rides underneath it.
onSubmit(watching ? condition.trim() : body.trim(), buildBackgroundJobPrompt(spec));
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Radio className="h-4 w-4" />
Background job
</DialogTitle>
<DialogDescription>
Runs in <code className="font-mono text-xs">{cwd}</code>, tracked by the harness — if it dies, you are told.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={mode === 'command'}
disabled={watching}
onChange={() => setMode('command')}
/>
<span className={watching ? 'opacity-40' : ''}>Command — run exactly this</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" checked={mode === 'prompt'} onChange={() => setMode('prompt')} />
<span>Prompt — describe it, the agent works it out</span>
</label>
<Input
autoFocus
value={body}
onChange={(ev) => setBody(ev.target.value)}
placeholder={watching ? 'optional context' : mode === 'command' ? 'bun dev' : 'start the dev server'}
className={mode === 'command' ? 'font-mono text-sm' : 'text-sm'}
/>
</div>
<div className="space-y-2 rounded-md border border-duck-dark/10 p-3">
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={watching} onChange={(ev) => toggleWatching(ev.target.checked)} />
Watch for something
</label>
{watching && (
<>
<Textarea
value={condition}
onChange={(ev) => setCondition(ev.target.value)}
rows={3}
placeholder="watch our Gitea repos; on any new push, pull it and push to the GitHub mirror"
className="text-sm"
/>
<p className="text-xs text-duck-dark/50">
In prose. The agent will ask before arming if anything is unclear.
</p>
<div className="space-y-1 pt-1">
<label className="flex items-center gap-2 text-sm">
<input type="radio" checked={stopOnFire} onChange={() => setStopOnFire(true)} />
Stop when it fires
</label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" checked={!stopOnFire} onChange={() => setStopOnFire(false)} />
Keep going — act each time and carry on
</label>
</div>
</>
)}
</div>
<div className="space-y-1">
<Label className="text-xs uppercase tracking-wide text-duck-dark/40">Duration</Label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" checked={duration === 'session'} onChange={() => setDuration('session')} />
This session
</label>
<label className="flex items-center gap-2 text-sm">
<input type="radio" checked={duration === 'timed'} onChange={() => setDuration('timed')} />
For
<input
type="number"
min={1}
value={minutes}
onFocus={() => setDuration('timed')}
onChange={(ev) => setMinutes(Math.max(1, Number(ev.target.value) || 1))}
className="w-16 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-sm"
/>
minutes
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={duration === 'until-stopped'}
onChange={() => setDuration('until-stopped')}
/>
Until stopped
</label>
{/* Said plainly rather than promised. The job is a descendant of this session's process, so
nothing here outlives the session — including "until stopped". */}
<p className="pt-1 text-xs text-duck-dark/40">
All of these end if the session does — the job runs underneath it.
</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={submit} disabled={!canSubmit}>
Start
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
import { useAudioRecording } from '../useAudioRecording';
import { ModelSelector } from './ModelSelector';
import { BackgroundJobDialog } from './BackgroundJobDialog';
import { useWorkspace } from '../../../components/Workspace';
import { AttachmentList } from './AttachmentList';
import { AttachButton } from './AttachButton';
import { WebpageDialog } from './WebpageDialog';
@@ -41,10 +43,14 @@ export const InputArea = ({ manager }: InputAreaProps) => {
queued,
sendQueuedNow,
unqueue,
sendWithHiddenPrefix,
} = manager;
const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput);
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
const [jobDialogOpen, setJobDialogOpen] = useState(false);
// The panel's own directory, so a background job needs no path typed into it.
const { cwd } = useWorkspace();
// Does the model actually accept images?
//
@@ -154,9 +160,16 @@ export const InputArea = ({ manager }: InputAreaProps) => {
hasStarted={hasStarted}
thinkingLevel={thinkingLevel}
onThinkingChange={setThinkingLevel}
onBackgroundJob={() => setJobDialogOpen(true)}
/>
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
<BackgroundJobDialog
open={jobDialogOpen}
onOpenChange={setJobDialogOpen}
cwd={cwd}
onSubmit={(intent, preamble) => void sendWithHiddenPrefix(intent, preamble)}
/>
</div>
);
};
@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { Radio } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { ModelOption } from 'state/useModels';
import { getProviderDisplayName } from 'state/useModels';
@@ -38,6 +39,8 @@ type ModelSelectorProps = {
*/
thinkingLevel?: string | null;
onThinkingChange?: (level: string | null) => void;
/** Opens the background-job composer. Absent where the feature is not offered. */
onBackgroundJob?: () => void;
};
export function ModelSelector({
@@ -49,6 +52,7 @@ export function ModelSelector({
isConnected,
isGenerating,
hasStarted,
onBackgroundJob,
}: ModelSelectorProps) {
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
@@ -120,6 +124,19 @@ export function ModelSelector({
))}
</div>
)}
{/* Background job. Sits between the harness (left) and the model (right), in the gap
`justify-between` already leaves — session-level, like its neighbours, rather than about the
message being typed. `Radio` because it listens; a play button would imply one-shot. */}
{onBackgroundJob && (
<button
type="button"
onClick={onBackgroundJob}
title="Start a background job or watcher"
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground cursor-pointer"
>
<Radio className="h-4 w-4" />
</button>
)}
{/* The thinking toggle used to render here. It is gone because it did nothing: `thinking` is
accepted on the wire (types.ts, websocket.ts) and forwarded by NEITHER channel — not
send-claude-code.ts, not send-opencode.ts — so pressing it changed the label and nothing else.