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:
@@ -12,6 +12,14 @@ export type QueuedPrompt = {
|
|||||||
/** Exactly what was typed, untrimmed — what the tray shows and what gets delivered. */
|
/** Exactly what was typed, untrimmed — what the tray shows and what gets delivered. */
|
||||||
raw: string;
|
raw: string;
|
||||||
prefix: 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[];
|
ids: string[];
|
||||||
images: { filename: string; dataUrl: string }[];
|
images: { filename: string; dataUrl: string }[];
|
||||||
};
|
};
|
||||||
@@ -179,7 +187,15 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
const ids = items.flatMap((item) => item.ids);
|
const ids = items.flatMap((item) => item.ids);
|
||||||
const images = items.flatMap((item) => item.images);
|
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;
|
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text;
|
||||||
|
if (hidden) prompt = `${hidden}\n\n${prompt}`;
|
||||||
if (prefix) prompt = `${prefix}${prompt}`;
|
if (prefix) prompt = `${prefix}${prompt}`;
|
||||||
|
|
||||||
// Send the cwd on every message (not just the first): OpenCode rebuilds its working-directory
|
// 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,
|
cwd,
|
||||||
undefined,
|
undefined,
|
||||||
thinkingLevel,
|
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;
|
userScrolledRef.current = false;
|
||||||
return true;
|
return true;
|
||||||
@@ -232,6 +250,32 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
await deliverBatch(batch);
|
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 handleSend = async () => {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -503,6 +547,7 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
input,
|
input,
|
||||||
setInput,
|
setInput,
|
||||||
handleSend,
|
handleSend,
|
||||||
|
sendWithHiddenPrefix,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handleEscape,
|
handleEscape,
|
||||||
queued,
|
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 type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||||
import { useAudioRecording } from '../useAudioRecording';
|
import { useAudioRecording } from '../useAudioRecording';
|
||||||
import { ModelSelector } from './ModelSelector';
|
import { ModelSelector } from './ModelSelector';
|
||||||
|
import { BackgroundJobDialog } from './BackgroundJobDialog';
|
||||||
|
import { useWorkspace } from '../../../components/Workspace';
|
||||||
import { AttachmentList } from './AttachmentList';
|
import { AttachmentList } from './AttachmentList';
|
||||||
import { AttachButton } from './AttachButton';
|
import { AttachButton } from './AttachButton';
|
||||||
import { WebpageDialog } from './WebpageDialog';
|
import { WebpageDialog } from './WebpageDialog';
|
||||||
@@ -41,10 +43,14 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
|||||||
queued,
|
queued,
|
||||||
sendQueuedNow,
|
sendQueuedNow,
|
||||||
unqueue,
|
unqueue,
|
||||||
|
sendWithHiddenPrefix,
|
||||||
} = manager;
|
} = manager;
|
||||||
|
|
||||||
const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput);
|
const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput);
|
||||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
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?
|
// Does the model actually accept images?
|
||||||
//
|
//
|
||||||
@@ -154,9 +160,16 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
|||||||
hasStarted={hasStarted}
|
hasStarted={hasStarted}
|
||||||
thinkingLevel={thinkingLevel}
|
thinkingLevel={thinkingLevel}
|
||||||
onThinkingChange={setThinkingLevel}
|
onThinkingChange={setThinkingLevel}
|
||||||
|
onBackgroundJob={() => setJobDialogOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
||||||
|
<BackgroundJobDialog
|
||||||
|
open={jobDialogOpen}
|
||||||
|
onOpenChange={setJobDialogOpen}
|
||||||
|
cwd={cwd}
|
||||||
|
onSubmit={(intent, preamble) => void sendWithHiddenPrefix(intent, preamble)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { Radio } from 'lucide-react';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import type { ModelOption } from 'state/useModels';
|
import type { ModelOption } from 'state/useModels';
|
||||||
import { getProviderDisplayName } from 'state/useModels';
|
import { getProviderDisplayName } from 'state/useModels';
|
||||||
@@ -38,6 +39,8 @@ type ModelSelectorProps = {
|
|||||||
*/
|
*/
|
||||||
thinkingLevel?: string | null;
|
thinkingLevel?: string | null;
|
||||||
onThinkingChange?: (level: string | null) => void;
|
onThinkingChange?: (level: string | null) => void;
|
||||||
|
/** Opens the background-job composer. Absent where the feature is not offered. */
|
||||||
|
onBackgroundJob?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ModelSelector({
|
export function ModelSelector({
|
||||||
@@ -49,6 +52,7 @@ export function ModelSelector({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
hasStarted,
|
hasStarted,
|
||||||
|
onBackgroundJob,
|
||||||
}: ModelSelectorProps) {
|
}: ModelSelectorProps) {
|
||||||
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
|
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
|
||||||
|
|
||||||
@@ -120,6 +124,19 @@ export function ModelSelector({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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
|
{/* 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
|
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.
|
send-claude-code.ts, not send-opencode.ts — so pressing it changed the label and nothing else.
|
||||||
|
|||||||
Reference in New Issue
Block a user