start a commander from a plugin chat, without knowing where anything lives
pointing a commander at a mailbox meant typing its path, which meant a file
browser and an account allowed to walk the disk. only the owner has that — so
"watch my email and tell me when X" was a super-user feature, in a product whose
whole point is that it should not be.
every plugin chat already mounts `ChatHeader` with a `chatContext`, and that
context IS the address: the server turns `{context:'email', contextId:'me@…'}`
into the mailbox's own directory, beside its `emails.db`. so one button on that
header reaches every plugin chat there will ever be, and a new plugin gets it by
declaring the context it already had to declare to have a chat at all. that is
what makes this common to all those chats rather than copied into each.
THE CLIENT SENDS NO PATH, because it has none. a plugin's chats live under
$DATA_PATH/<email>/plugins/<app>/chats and an email chat beside its own database;
both are built server-side and deliberately never sent to the browser. the
resolver moved out of chat.ts into `chat-context.ts` so command-hq uses the SAME
mapping — two copies would be two chances for a commander to watch a directory
its own chat has never been in.
it NAVIGATES rather than creating on the spot. a commander with no first turn has
no transcript, and the grid filters those rows out — so "create it silently"
makes something the person cannot then find. the setup screen is also where the
first turn happens, which is where the job actually gets settled. the button
carries the context there, pre-fills the name, and leaves one button to press.
the reference is shown as what it WATCHES rather than as a path, because there is
no path to show.
caught by testing rather than by reading: `resolveChatContextDir` reads the query
string, and command-hq takes its context in a JSON body — so the first version
resolved every commander to the owner's home. that failure is invisible, since a
commander watching the wrong directory just answers about the wrong thing
forever. the value-taking `chatContextDir` is what command-hq calls.
verified live: context 'email' resolves to email_accounts, plugin/proxy-manager
to its own chats directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -12,6 +12,7 @@ import { useWorkspace, cwdToPath } from '../../components/Workspace';
|
||||
import { usePanelSession } from './use-panel-session';
|
||||
import type { ChatMessage } from './types';
|
||||
import { useTranslation } from '../../locales';
|
||||
import { WatchWithCommanderButton } from './WatchWithCommanderButton';
|
||||
|
||||
export type ChatSessionSelection = {
|
||||
sessionId: string | null;
|
||||
@@ -45,9 +46,15 @@ const title = (s: { title: string }) => s.title || '(untitled)';
|
||||
export const ChatHeader = ({
|
||||
panelId,
|
||||
chatContext,
|
||||
commanderLabel,
|
||||
}: {
|
||||
panelId?: string;
|
||||
chatContext?: { context: string; contextId?: string } | null;
|
||||
/**
|
||||
* What a commander started from here would be watching, in the plugin's own words — "Email", "Proxy
|
||||
* hosts". Absent means no button: a chat with nothing to name is a chat with nothing to watch.
|
||||
*/
|
||||
commanderLabel?: string;
|
||||
}) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const { workspace, cwd } = useWorkspace();
|
||||
@@ -181,6 +188,10 @@ export const ChatHeader = ({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="shrink-0 truncate text-[10px] font-mono opacity-60">{cwd}</span>
|
||||
{/* Every plugin chat mounts this header with a context, and that context IS the address — so one
|
||||
button here reaches all of them, and a new plugin gets it by declaring what it already had to
|
||||
declare to have a chat at all. */}
|
||||
{chatContext && commanderLabel && <WatchWithCommanderButton chatContext={chatContext} label={commanderLabel} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Radar } from 'lucide-react';
|
||||
import { commandHqNewPath } from '../CommandHQ/command-hq-routes';
|
||||
|
||||
/**
|
||||
* Turn this chat into a commander watching the same place.
|
||||
*
|
||||
* ── Why it lives on the chat header ──
|
||||
*
|
||||
* Every plugin chat mounts `ChatHeader` with a `chatContext`, and that context IS the address — the
|
||||
* server turns `{context:'email', contextId:'me@…'}` into the mailbox's own directory. So one button
|
||||
* here reaches every plugin chat there will ever be, and a new plugin gets it by declaring the context
|
||||
* it already had to declare to have a chat at all.
|
||||
*
|
||||
* ── Why the client sends no path ──
|
||||
*
|
||||
* It has none. A plugin's chats live under `$DATA_PATH/<email>/plugins/<app>/chats` and an email chat
|
||||
* beside its own `emails.db`; both are built server-side and deliberately never sent to the browser.
|
||||
* The request carries the context and the server resolves it with the same function the chat router
|
||||
* uses, so a commander cannot end up watching a directory its own chat has never been in.
|
||||
*
|
||||
* ── Why this matters beyond a shortcut ──
|
||||
*
|
||||
* Pointing a commander at a mailbox used to mean knowing its path and typing it, which meant a file
|
||||
* browser and an account allowed to walk the disk. Only the owner has that. This asks for neither, so
|
||||
* "watch my email and tell me when X" stops being a super-user feature.
|
||||
*/
|
||||
export const WatchWithCommanderButton = ({
|
||||
chatContext,
|
||||
label,
|
||||
}: {
|
||||
chatContext: { context: string; contextId?: string };
|
||||
label: string;
|
||||
}) => {
|
||||
/*
|
||||
It NAVIGATES rather than creating on the spot, and that is not timidity.
|
||||
|
||||
A commander with no first turn has no transcript, and the Command HQ grid filters those rows out —
|
||||
so "create it silently" would make something the person cannot then find. The setup screen is also
|
||||
where the first turn happens, which is where the job actually gets settled. So this carries the
|
||||
context there, pre-fills the name, and leaves one button to press.
|
||||
|
||||
A `<Link>` rather than a click handler: it is a navigation, so it should middle-click and be
|
||||
bookmarkable like one.
|
||||
*/
|
||||
const search = new URLSearchParams({
|
||||
context: chatContext.context,
|
||||
...(chatContext.contextId ? { contextId: chatContext.contextId } : {}),
|
||||
label,
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`${commandHqNewPath()}?${search.toString()}`}
|
||||
title={`Start a commander watching ${label.toLowerCase()}`}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Radar className="h-3.5 w-3.5" />
|
||||
Commander
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -165,11 +165,14 @@ const CommanderSetup = ({
|
||||
name,
|
||||
onName,
|
||||
referencePath,
|
||||
contextLabel,
|
||||
onPickReferencePath,
|
||||
}: {
|
||||
name: string;
|
||||
onName: (next: string) => void;
|
||||
referencePath: string | null;
|
||||
/** Named instead of picked — the reference is a context the server resolves. See `NewCommanderSetup`. */
|
||||
contextLabel?: string | null;
|
||||
onPickReferencePath: () => void;
|
||||
}) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
@@ -197,19 +200,34 @@ const CommanderSetup = ({
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t('commandHq.referencePath')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickReferencePath}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
referencePath
|
||||
? 'border-border bg-background'
|
||||
: 'border-dashed border-border text-muted-foreground hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">{referencePath ?? 'Choose a directory…'}</span>
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">{t('commandHq.referencePathHint')}</span>
|
||||
{/* A context is named, not chosen. There is no directory to show — the browser was never told
|
||||
one — so this states WHAT is watched and leaves the path to the server. */}
|
||||
{contextLabel ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm">
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{contextLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickReferencePath}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
referencePath
|
||||
? 'border-border bg-background'
|
||||
: 'border-dashed border-border text-muted-foreground hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{referencePath ?? 'Choose a directory…'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{contextLabel
|
||||
? 'Resolved by the server — this chat already knows where it lives.'
|
||||
: t('commandHq.referencePathHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,11 +249,24 @@ function NewCommanderSetup({
|
||||
category: number | 'none' | undefined;
|
||||
onCreated: (commander: CommandHqCommanderCreated) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
/*
|
||||
Arrived from a plugin chat, carrying what it is about rather than where it is.
|
||||
|
||||
`?context=email&contextId=me@…` is the same pair that chat already speaks, and the SERVER turns it
|
||||
into a directory — the browser has never been told one. So the picker is replaced by a label, the
|
||||
reference is resolved at create time, and somebody who cannot walk the disk can still point a
|
||||
commander at their own mailbox. Without a context this is the ordinary flow, unchanged.
|
||||
*/
|
||||
const [searchParams] = useSearchParams();
|
||||
const context = searchParams.get('context');
|
||||
const contextId = searchParams.get('contextId');
|
||||
const contextLabel = searchParams.get('label');
|
||||
|
||||
const [name, setName] = useState(contextLabel ? (contextId ? `${contextLabel} — ${contextId}` : contextLabel) : '');
|
||||
const [referencePath, setReferencePath] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const ready = name.trim().length > 0 && !!referencePath;
|
||||
const ready = name.trim().length > 0 && (!!referencePath || !!context);
|
||||
const createCommander = useCreateCommander();
|
||||
|
||||
const onStart = async () => {
|
||||
@@ -244,7 +275,8 @@ function NewCommanderSetup({
|
||||
try {
|
||||
const commander = await createCommander.mutateAsync({
|
||||
name: name.trim(),
|
||||
referencePath: referencePath!,
|
||||
// One or the other: a path the person picked, or a context only the server can resolve.
|
||||
...(context ? { context, ...(contextId ? { contextId } : {}) } : { referencePath: referencePath! }),
|
||||
categoryId: typeof category === 'number' ? category : undefined,
|
||||
});
|
||||
onCreated(commander);
|
||||
@@ -260,11 +292,14 @@ function NewCommanderSetup({
|
||||
name={name}
|
||||
onName={setName}
|
||||
referencePath={referencePath}
|
||||
contextLabel={context ? (contextLabel ?? context) : null}
|
||||
onPickReferencePath={() => setPickerOpen(true)}
|
||||
/>
|
||||
<StartCommanderButton
|
||||
ready={ready && !creating}
|
||||
blockedBy={!name.trim() ? 'Name it first' : !referencePath ? 'Choose a reference path' : 'Creating…'}
|
||||
blockedBy={
|
||||
!name.trim() ? 'Name it first' : !referencePath && !context ? 'Choose a reference path' : 'Creating…'
|
||||
}
|
||||
onStart={() => void onStart()}
|
||||
/>
|
||||
<NewCommanderGuide />
|
||||
|
||||
@@ -26,8 +26,20 @@ export function useCreateCommander() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: { name: string; referencePath: string; categoryId?: number | null }) =>
|
||||
post<CommandHqCommanderCreated>('/command-hq/commanders', input),
|
||||
/*
|
||||
A path OR a context, never both, and the union says so rather than making both optional.
|
||||
|
||||
`referencePath` is what the person picked in the directory picker. `context` is what a plugin chat
|
||||
carries — `{context:'email', contextId:'me@…'}` — which the SERVER resolves to a directory, because
|
||||
the browser is never told one. Optional-both would compile for a call that sends neither, and the
|
||||
route answers that with a 400 nobody sees until it happens.
|
||||
*/
|
||||
mutationFn: (
|
||||
input: { name: string; categoryId?: number | null } & (
|
||||
| { referencePath: string; context?: never; contextId?: never }
|
||||
| { context: string; contextId?: string; referencePath?: never }
|
||||
),
|
||||
) => post<CommandHqCommanderCreated>('/command-hq/commanders', input),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user