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. */
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user